Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/electron/src/main/lib/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,12 @@ export class AgentOrchestrator {
// ANTHROPIC_BASE_URL 等)干扰 SDK 的认证和请求目标。
// 即使 index.ts 启动时已清理过一次,initializeRuntime() 中的
// loadShellEnv() 可能从 shell 配置文件(~/.zshrc 等)重新注入这些变量。
// 另外,开发模式下如果 Proma 是从 Claude Code 会话里启动的,父进程会带上
// CLAUDECODE=1;若原样透传给 SDK 子进程,Claude Code CLI 会把它判定为嵌套会话,
// 直接退出并报 "Claude Code process exited with code 1"。
const cleanEnv: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith('ANTHROPIC_')) {
if (!key.startsWith('ANTHROPIC_') && key !== 'CLAUDECODE') {
cleanEnv[key] = value
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/electron/src/renderer/atoms/agent-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
import type { AgentSessionMeta, AgentMessage, AgentEvent, AgentWorkspace, AgentPendingFile, RetryAttempt, PromaPermissionMode, PermissionRequest, AskUserRequest, ThinkingConfig, AgentEffort, TaskUsage, AgentTeamData } from '@proma/shared'
import type { ScrollMemoryState } from '@/lib/scroll-memory'

/** 活动状态 */
export type ActivityStatus = 'pending' | 'running' | 'completed' | 'error' | 'backgrounded'
Expand Down Expand Up @@ -561,6 +562,9 @@ export const agentSidePanelOpenMapAtom = atom<Map<string, boolean>>(new Map())
/** 侧面板当前活跃 Tab(per-session Map) */
export const agentSidePanelTabMapAtom = atom<Map<string, SidePanelTab>>(new Map())

/** Agent 消息滚动位置(per-session) */
export const agentScrollMemoryAtom = atom<Map<string, ScrollMemoryState>>(new Map())

/**
* Team 活动缓存 — 以 sessionId 为 key
*
Expand Down
97 changes: 96 additions & 1 deletion apps/electron/src/renderer/atoms/chat-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import type { ConversationMeta, ChatMessage, FileAttachment, ChatToolActivity } from '@proma/shared'
import type { ScrollMemoryState } from '@/lib/scroll-memory'
import { deleteMapEntry } from '@/lib/utils'

/** 选中的模型信息 */
export interface SelectedModel {
Expand Down Expand Up @@ -241,4 +243,97 @@ export const conversationContextLengthAtom = atom<Map<string, ContextLengthValue
export const conversationThinkingEnabledAtom = atom<Map<string, boolean>>(new Map())

/** 每个对话的并排模式 */
export const conversationParallelModeAtom = atom<Map<string, boolean>>(new Map())
export const conversationParallelModeAtom = atom<Map<string, boolean>>(new Map())

/** 标准消息列表滚动位置(per-conversation) */
export const conversationScrollMemoryAtom = atom<Map<string, ScrollMemoryState>>(new Map())

/** 非底部阅读位置下,是否已加载完整历史(per-conversation) */
export const conversationLoadedAllHistoryAtom = atom<Map<string, boolean>>(new Map())

/** 非底部阅读位置下,是否需要首次直接加载完整历史(per-conversation) */
export const conversationNeedsFullHistoryAtom = atom<Map<string, boolean>>(new Map())

/** 标记某个对话已加载完整历史 */
export const markConversationHistoryLoadedAtom = atom(
null,
(_get, set, conversationId: string) => {
set(conversationLoadedAllHistoryAtom, (prev) => {
const map = new Map(prev)
map.set(conversationId, true)
return map
})
set(conversationNeedsFullHistoryAtom, (prev) => deleteMapEntry(prev, conversationId))
}
)

/** 根据当前滚动状态同步历史加载需求 */
export const updateConversationHistoryRequirementAtom = atom(
null,
(get, set, payload: { conversationId: string; scrollState: ScrollMemoryState }) => {
const { conversationId, scrollState } = payload

if (scrollState.atBottom) {
set(conversationNeedsFullHistoryAtom, (prev) => {
if (!prev.has(conversationId)) return prev
const map = new Map(prev)
map.delete(conversationId)
return map
})
return
}

const loadedAll = get(conversationLoadedAllHistoryAtom).get(conversationId) ?? false
if (loadedAll) return

set(conversationNeedsFullHistoryAtom, (prev) => {
if (prev.get(conversationId) === true) return prev
const map = new Map(prev)
map.set(conversationId, true)
return map
})
}
)

/** 指定对话是否应直接加载完整历史 */
export const conversationShouldLoadFullHistoryAtom = atom((get) => (conversationId: string): boolean => {
return get(conversationNeedsFullHistoryAtom).get(conversationId) ?? false
})

/** 指定对话是否已加载完整历史 */
export const conversationLoadedAllHistorySelectorAtom = atom((get) => (conversationId: string): boolean => {
return get(conversationLoadedAllHistoryAtom).get(conversationId) ?? false
})

/** 当前对话的标准滚动状态 */
export const currentConversationScrollMemoryAtom = atom<ScrollMemoryState | null>((get) => {
const currentId = get(currentConversationIdAtom)
if (!currentId) return null
return get(conversationScrollMemoryAtom).get(currentId) ?? null
})

/** 当前对话是否记住了非底部阅读位置 */
export const currentConversationHasHistoricScrollAtom = atom<boolean>((get) => {
const scrollState = get(currentConversationScrollMemoryAtom)
return scrollState !== null && !scrollState.atBottom
})

/** 指定对话是否记住了非底部阅读位置 */
export const conversationHasHistoricScrollAtom = atom((get) => (conversationId: string): boolean => {
const scrollState = get(conversationScrollMemoryAtom).get(conversationId)
return scrollState !== undefined && !scrollState.atBottom
})

/** 当前对话是否应直接加载完整历史 */
export const shouldLoadFullHistoryAtom = atom<boolean>((get) => {
const currentId = get(currentConversationIdAtom)
if (!currentId) return false
return get(conversationNeedsFullHistoryAtom).get(currentId) ?? false
})

/** 当前对话是否已加载完整历史 */
export const currentConversationLoadedAllHistoryAtom = atom<boolean>((get) => {
const currentId = get(currentConversationIdAtom)
if (!currentId) return false
return get(conversationLoadedAllHistoryAtom).get(currentId) ?? false
})
63 changes: 61 additions & 2 deletions apps/electron/src/renderer/components/agent/AgentMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import * as React from 'react'
import { useAtomValue } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import { Bot, FileText, FileImage, RotateCw, AlertTriangle, ChevronDown, ChevronRight, Plus, Minimize2 } from 'lucide-react'
import {
Message,
Expand Down Expand Up @@ -35,7 +35,10 @@ import { ToolActivityList } from './ToolActivityItem'
import { BackgroundTasksPanel } from './BackgroundTasksPanel'
import { useBackgroundTasks } from '@/hooks/useBackgroundTasks'
import { userProfileAtom } from '@/atoms/user-profile'
import { agentScrollMemoryAtom } from '@/atoms/agent-atoms'
import { cn } from '@/lib/utils'
import { isScrollMemoryStateEqual } from '@/lib/scroll-memory'
import type { ScrollMemoryState } from '@/lib/scroll-memory'
import type { AgentMessage, RetryAttempt } from '@proma/shared'
import type { ToolActivity, AgentStreamState } from '@/atoms/agent-atoms'

Expand Down Expand Up @@ -502,6 +505,11 @@ function AgentMessageItem({ message, onRetry, onRetryInNewSession, onCompact }:

export function AgentMessages({ sessionId, messages, streaming, streamState, onRetry, onRetryInNewSession, onCompact }: AgentMessagesProps): React.ReactElement {
const userProfile = useAtomValue(userProfileAtom)
const scrollMemoryMap = useAtomValue(agentScrollMemoryAtom)
const setAgentScrollMemory = useSetAtom(agentScrollMemoryAtom)
const scrollMemory = scrollMemoryMap.get(sessionId) ?? null
const [ready, setReady] = React.useState(false)
const prevSessionIdRef = React.useRef<string | null>(null)

// 从 streamState 属性中计算派生值
const streamingContent = streamState?.content ?? ''
Expand All @@ -518,6 +526,50 @@ export function AgentMessages({ sessionId, messages, streaming, streamState, onR
isStreaming: streaming,
})

React.useEffect(() => {
if (sessionId !== prevSessionIdRef.current) {
prevSessionIdRef.current = sessionId
setReady(false)
}
}, [sessionId])

const handleScrollMemoryChange = React.useCallback((state: ScrollMemoryState): void => {
setAgentScrollMemory((prev) => {
const current = prev.get(sessionId)
if (current && isScrollMemoryStateEqual(current, state)) return prev

const map = new Map(prev)
map.set(sessionId, state)
return map
})
}, [sessionId, setAgentScrollMemory])

const handleRestoreComplete = React.useCallback((): void => {
setReady(true)
}, [])

React.useEffect(() => {
if (ready) return

if (messages.length === 0 && !streaming) {
setReady(true)
return
}

if (scrollMemory?.atBottom === false) return

let cancelled = false
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!cancelled) setReady(true)
})
})

return () => {
cancelled = true
}
}, [messages.length, streaming, ready, scrollMemory?.atBottom])

// 迷你地图数据
const minimapItems: MinimapItem[] = React.useMemo(
() => messages.map((m) => ({
Expand All @@ -531,7 +583,14 @@ export function AgentMessages({ sessionId, messages, streaming, streamState, onR
)

return (
<Conversation>
<Conversation
className={ready ? 'opacity-100 transition-opacity duration-200' : 'opacity-0'}
scrollMemory={scrollMemory}
onScrollMemoryChange={handleScrollMemoryChange}
restoreKey={sessionId}
restoreVersion={messages.length}
onRestoreComplete={handleRestoreComplete}
>
<ConversationContent>
{messages.length === 0 && !streaming ? (
<EmptyState />
Expand Down
Loading