Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/electron/src/renderer/atoms/agent-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,16 @@ export const agentSessionStreamingStateAtomFamily = atomFamily((sessionId: strin
*/
export const liveMessagesMapAtom = atom<Map<string, SDKMessage[]>>(new Map())

/**
* 单个 session 的 live SDKMessage 派生 atomFamily — 按 sessionId 切片订阅。
*
* 用于 Tab 预览等轻量订阅点:只在本 session 的实时消息变化时重渲染,
* 避免直接订阅全局 Map 被任意会话的流式更新触发整树重渲染。
*/
export const agentSessionLiveMessagesAtomFamily = atomFamily((sessionId: string) =>
atom((get) => get(liveMessagesMapAtom).get(sessionId)),
)

export const agentPendingPromptAtom = atom<AgentPendingPrompt | null>(null)

/**
Expand Down
12 changes: 11 additions & 1 deletion apps/electron/src/renderer/atoms/chat-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { atomFamily, atomWithStorage } from 'jotai/utils'
import type { ConversationMeta, ChatMessage, FileAttachment, ChatToolActivity, Channel } from '@proma/shared'

/** 全局渠道列表缓存(启动时加载一次,设置变更时刷新) */
Expand Down Expand Up @@ -61,6 +61,16 @@ export interface ConversationStreamState {
*/
export const streamingStatesAtom = atom<Map<string, ConversationStreamState>>(new Map())

/**
* 单个 conversation 的 streaming state 派生 atomFamily — 按 conversationId 切片订阅。
*
* 直接订阅 streamingStatesAtom 会让任意对话的流式更新都触发订阅者整树重渲染。
* 本 family 让订阅者只在本 conversation 的 state 引用变化时重渲染。
*/
export const conversationStreamingStateAtomFamily = atomFamily((conversationId: string) =>
atom((get) => get(streamingStatesAtom).get(conversationId)),
)

/**
* 当前正在流式输出的对话 ID 集合(派生只读原子)
* 用于侧边栏绿色呼吸点指示器
Expand Down
29 changes: 29 additions & 0 deletions apps/electron/src/renderer/atoms/tab-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import type { SDKMessage } from '@proma/shared'
import {
streamingConversationIdsAtom,
} from './chat-atoms'
Expand Down Expand Up @@ -113,6 +114,34 @@ export interface TabMinimapItem {
}
export const tabMinimapCacheAtom = atom<Map<string, TabMinimapItem[]>>(new Map())

/** Tab 预览中的单条工具活动(统一 agent/chat 两种来源) */
export interface TabStreamActivity {
/** 工具调用 ID(chat 合并后取首个 toolCallId) */
id: string
toolName: string
input: Record<string, unknown>
done: boolean
isError?: boolean
}

/** Tab 预览中的会话流运行数据(流式运行时由 TabBarItem 组装) */
export interface TabStreamRunData {
kind: 'agent' | 'chat'
/** 恒为 true(非运行状态由 streamRun 为 null 表达),保留字段以便未来扩展 */
running: boolean
/** 实时生成的文本内容 */
content: string
/** Chat 会话的实时推理内容(thinking),Agent 会话由 liveMessages 承载 */
reasoning?: string
/** 流式绑定的模型 ID */
model?: string
/** 流式开始时间戳 */
startedAt?: number
activities: TabStreamActivity[]
/** Agent 会话的实时 SDKMessage 流(thinking / tool_use / text 按时间顺序),Chat 会话无此字段 */
liveMessages?: SDKMessage[]
}

/** Scratch Pad 编辑内容(HTML 字符串,供 TipTap 编辑器使用) */
export const scratchPadContentAtom = atom<string>('')
/** Scratch Pad 内容是否已从磁盘加载 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
conversationThinkingEnabledAtom,
conversationParallelModeAtom,
agentSideChatMapAtom,
conversationStreamingStateAtomFamily,
} from '@/atoms/chat-atoms'
import {
agentSessionsAtom,
Expand All @@ -53,6 +54,7 @@ import {
agentFileChangesCurrentRunAtom,
agentDiffDataAtom,
agentStreamingStatesAtom,
agentSessionLiveMessagesAtomFamily,
liveMessagesMapAtom,
agentSessionPendingFilesAtom,
agentSessionStreamingStateAtomFamily,
Expand Down Expand Up @@ -914,6 +916,7 @@ export function LeftSidebar({ width, noTransition }: LeftSidebarProps): React.Re
// atomFamily 内部缓存(Jotai 对 string key 强引用 Map,不显式 remove 永不释放)。
// 删除/归档是会话的终态,连同草稿一起清理,无需像关闭 Tab 那样保留可恢复输入。
agentSessionStreamingStateAtomFamily.remove(id)
agentSessionLiveMessagesAtomFamily.remove(id)
agentSessionDraftAtomFamily.remove(id)
agentSessionDraftHtmlAtomFamily.remove(id)
agentPendingFilesAtomFamily.remove(id)
Expand Down Expand Up @@ -1256,6 +1259,9 @@ export function LeftSidebar({ width, noTransition }: LeftSidebarProps): React.Re
// 即使后端报错,也从本地列表移除(可能是对话已不存在)
setConversations((prev) => prev.filter((c) => c.id !== pendingDeleteId))
} finally {
// atomFamily 内部缓存(Jotai 对 string key 强引用 Map,不显式 remove 永不释放)。
// 删除是对话的终态,清理流式状态派生缓存条目。
conversationStreamingStateAtomFamily.remove(pendingDeleteId)
setPendingDeleteId(null)
}
}
Expand Down
1 change: 1 addition & 0 deletions apps/electron/src/renderer/components/tabs/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ function TabBarInner({
key={tab.id}
id={tab.id}
type={tab.type}
sessionId={tab.sessionId}
title={tab.title}
workspaceName={tab.type === 'agent' ? workspaceNameBySessionId.get(tab.sessionId) : undefined}
isAutomation={tab.type === 'agent' && automationSessionIds.has(tab.sessionId)}
Expand Down
82 changes: 80 additions & 2 deletions apps/electron/src/renderer/components/tabs/TabBarItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@ import { createPortal } from 'react-dom'
import { useAtomValue } from 'jotai'
import { FileText, StickyNote, X, Clock, GitBranch } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TabType, TabMinimapItem } from '@/atoms/tab-atoms'
import type { TabType, TabMinimapItem, TabStreamRunData } from '@/atoms/tab-atoms'
import type { SessionIndicatorStatus } from '@/atoms/agent-atoms'
import { agentSessionStreamingStateAtomFamily, agentSessionLiveMessagesAtomFamily } from '@/atoms/agent-atoms'
import { conversationStreamingStateAtomFamily } from '@/atoms/chat-atoms'
import { tabMinimapCacheAtom } from '@/atoms/tab-atoms'
import { interfaceVariantAtom } from '@/atoms/theme'
import { Spinner } from '@/components/ui/spinner'
import { TabPreviewPanel } from './TabPreviewPanel'
import type { ChatToolActivity } from '@proma/shared'

export interface TabBarItemProps {
id: string
type: TabType
/** 该 Tab 所属会话 ID(preview Tab 为 owner agent sessionId) */
sessionId: string
title: string
workspaceName?: string
isActive: boolean
Expand Down Expand Up @@ -52,6 +57,7 @@ export interface TabBarItemProps {
export function TabBarItem({
id,
type,
sessionId,
title,
workspaceName,
isActive,
Expand All @@ -76,6 +82,47 @@ export function TabBarItem({
const interfaceVariant = useAtomValue(interfaceVariantAtom)
const isClassic = interfaceVariant === 'classic'

// 订阅该 Tab 所属会话的实时流式状态(按 sessionId 切片,避免整树重渲染)
const agentStream = useAtomValue(agentSessionStreamingStateAtomFamily(sessionId))
const chatStream = useAtomValue(conversationStreamingStateAtomFamily(sessionId))
// Agent 会话的实时 SDKMessage 流(完整会话流数据源)
const liveAgentMessages = useAtomValue(agentSessionLiveMessagesAtomFamily(sessionId))

// 组装 Tab 预览面板用的会话流运行数据(仅运行中返回,否则保持 null)
const streamRun = React.useMemo<TabStreamRunData | null>(() => {
if (type === 'agent' || type === 'preview') {
if (!agentStream?.running) return null
return {
kind: 'agent',
running: true,
content: agentStream.content ?? '',
model: agentStream.model,
startedAt: agentStream.startedAt,
liveMessages: liveAgentMessages,
activities: agentStream.toolActivities.map((ta) => ({
id: ta.toolUseId,
toolName: ta.toolName,
input: ta.input,
done: ta.done,
isError: ta.isError,
})),
}
}
if (type === 'chat') {
if (!chatStream?.streaming) return null
return {
kind: 'chat',
running: true,
content: chatStream.content ?? '',
reasoning: chatStream.reasoning ?? '',
model: chatStream.model,
startedAt: chatStream.startedAt,
activities: mergeChatActivities(chatStream.toolActivities),
}
}
return null
}, [type, agentStream, chatStream, liveAgentMessages])

React.useEffect(() => {
const el = buttonRef.current
if (!el) return
Expand Down Expand Up @@ -218,6 +265,7 @@ export function TabBarItem({
buttonRef={buttonRef}
title={title}
items={previewItems}
streamRun={streamRun}
isLeaving={isLeaving}
onMouseEnter={onPanelHoverEnter}
onMouseLeave={onPanelHoverLeave}
Expand All @@ -232,13 +280,15 @@ function TabPreviewDropdown({
buttonRef,
title,
items,
streamRun,
isLeaving,
onMouseEnter,
onMouseLeave,
}: {
buttonRef: React.RefObject<HTMLButtonElement | null>
title: string
items: TabMinimapItem[]
streamRun: TabStreamRunData | null
isLeaving: boolean
onMouseEnter: () => void
onMouseLeave: () => void
Expand Down Expand Up @@ -271,8 +321,36 @@ function TabPreviewDropdown({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<TabPreviewPanel title={title} items={items} isLeaving={isLeaving} />
<TabPreviewPanel title={title} items={items} streamRun={streamRun} isLeaving={isLeaving} />
</div>,
document.body
)
}

/**
* 合并 Chat 模式的 start/result 工具活动事件为统一的活动列表
* (与 ChatToolActivityIndicator 合并逻辑一致)
*/
function mergeChatActivities(activities: ChatToolActivity[]): TabStreamRunData['activities'] {
const map = new Map<string, TabStreamRunData['activities'][number]>()
for (const a of activities) {
const existing = map.get(a.toolCallId)
if (a.type === 'start') {
map.set(a.toolCallId, {
id: a.toolCallId,
toolName: a.toolName,
input: a.input ?? existing?.input ?? {},
done: false,
})
} else if (a.type === 'result') {
map.set(a.toolCallId, {
id: a.toolCallId,
toolName: existing?.toolName ?? a.toolName,
input: a.input ?? existing?.input ?? {},
done: true,
isError: a.isError,
})
}
}
return Array.from(map.values())
}
Loading