From 49dcd6e8d2300173e7ab0d533af41faef4eae1e9 Mon Sep 17 00:00:00 2001 From: tfoit666 <171774674+tfoit666@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:09:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E3=80=81=E6=BB=9A=E5=8A=A8=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=92=8C=20Agent=20=E5=90=AF=E5=8A=A8=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一侧边栏/标签拖拽排序与会话滚动位置恢复,减少切换会话时的跳动,并避免从 Claude Code 环境启动 Agent 子进程时因继承环境变量而直接退出。 Co-Authored-By: Claude Opus 4.6 --- .../src/main/lib/agent-orchestrator.ts | 5 +- .../src/renderer/atoms/agent-atoms.ts | 4 + .../electron/src/renderer/atoms/chat-atoms.ts | 97 ++- .../components/agent/AgentMessages.tsx | 63 +- .../components/agent/TeamActivityPanel.tsx | 173 +++-- .../components/ai-elements/conversation.tsx | 222 +++++- .../components/ai-elements/scroll-minimap.tsx | 63 +- .../components/app-shell/LeftSidebar.tsx | 729 ++++++++++++++---- .../renderer/components/chat/ChatMessages.tsx | 218 ++++-- .../src/renderer/components/chat/ChatView.tsx | 65 +- .../src/renderer/components/tabs/TabBar.tsx | 113 ++- .../renderer/components/tabs/TabBarItem.tsx | 174 +++-- .../src/renderer/lib/scroll-memory.ts | 80 ++ apps/electron/src/renderer/lib/utils.ts | 8 + 14 files changed, 1636 insertions(+), 378 deletions(-) create mode 100644 apps/electron/src/renderer/lib/scroll-memory.ts diff --git a/apps/electron/src/main/lib/agent-orchestrator.ts b/apps/electron/src/main/lib/agent-orchestrator.ts index acd0b39c..1cfed542 100644 --- a/apps/electron/src/main/lib/agent-orchestrator.ts +++ b/apps/electron/src/main/lib/agent-orchestrator.ts @@ -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 = {} for (const [key, value] of Object.entries(process.env)) { - if (!key.startsWith('ANTHROPIC_')) { + if (!key.startsWith('ANTHROPIC_') && key !== 'CLAUDECODE') { cleanEnv[key] = value } } diff --git a/apps/electron/src/renderer/atoms/agent-atoms.ts b/apps/electron/src/renderer/atoms/agent-atoms.ts index ec16f402..95368728 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.ts @@ -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' @@ -561,6 +562,9 @@ export const agentSidePanelOpenMapAtom = atom>(new Map()) /** 侧面板当前活跃 Tab(per-session Map) */ export const agentSidePanelTabMapAtom = atom>(new Map()) +/** Agent 消息滚动位置(per-session) */ +export const agentScrollMemoryAtom = atom>(new Map()) + /** * Team 活动缓存 — 以 sessionId 为 key * diff --git a/apps/electron/src/renderer/atoms/chat-atoms.ts b/apps/electron/src/renderer/atoms/chat-atoms.ts index 14b933e1..d3b569fb 100644 --- a/apps/electron/src/renderer/atoms/chat-atoms.ts +++ b/apps/electron/src/renderer/atoms/chat-atoms.ts @@ -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 { @@ -241,4 +243,97 @@ export const conversationContextLengthAtom = atom>(new Map()) /** 每个对话的并排模式 */ -export const conversationParallelModeAtom = atom>(new Map()) \ No newline at end of file +export const conversationParallelModeAtom = atom>(new Map()) + +/** 标准消息列表滚动位置(per-conversation) */ +export const conversationScrollMemoryAtom = atom>(new Map()) + +/** 非底部阅读位置下,是否已加载完整历史(per-conversation) */ +export const conversationLoadedAllHistoryAtom = atom>(new Map()) + +/** 非底部阅读位置下,是否需要首次直接加载完整历史(per-conversation) */ +export const conversationNeedsFullHistoryAtom = atom>(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((get) => { + const currentId = get(currentConversationIdAtom) + if (!currentId) return null + return get(conversationScrollMemoryAtom).get(currentId) ?? null +}) + +/** 当前对话是否记住了非底部阅读位置 */ +export const currentConversationHasHistoricScrollAtom = atom((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((get) => { + const currentId = get(currentConversationIdAtom) + if (!currentId) return false + return get(conversationNeedsFullHistoryAtom).get(currentId) ?? false +}) + +/** 当前对话是否已加载完整历史 */ +export const currentConversationLoadedAllHistoryAtom = atom((get) => { + const currentId = get(currentConversationIdAtom) + if (!currentId) return false + return get(conversationLoadedAllHistoryAtom).get(currentId) ?? false +}) \ No newline at end of file diff --git a/apps/electron/src/renderer/components/agent/AgentMessages.tsx b/apps/electron/src/renderer/components/agent/AgentMessages.tsx index 2182f926..294fd8d9 100644 --- a/apps/electron/src/renderer/components/agent/AgentMessages.tsx +++ b/apps/electron/src/renderer/components/agent/AgentMessages.tsx @@ -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, @@ -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' @@ -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(null) // 从 streamState 属性中计算派生值 const streamingContent = streamState?.content ?? '' @@ -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) => ({ @@ -531,7 +583,14 @@ export function AgentMessages({ sessionId, messages, streaming, streamState, onR ) return ( - + {messages.length === 0 && !streaming ? ( diff --git a/apps/electron/src/renderer/components/agent/TeamActivityPanel.tsx b/apps/electron/src/renderer/components/agent/TeamActivityPanel.tsx index ef376d4b..91ff9bc5 100644 --- a/apps/electron/src/renderer/components/agent/TeamActivityPanel.tsx +++ b/apps/electron/src/renderer/components/agent/TeamActivityPanel.tsx @@ -177,6 +177,38 @@ export function TeamActivityPanel({ sessionId }: TeamActivityPanelProps): React. : teammates.filter((t) => t.status !== 'running').length const totalCount = hasAgents ? agentEntries.length : teammates.length + const content = ( +
+ {/* Task Board */} + {mergedTasks.length > 0 && ( + + )} + + {/* Agent 卡片(有 overview 时) */} + {hasAgents && agentEntries.map((agent) => ( + setExpandedId(expandedId === agent.toolUseId ? null : agent.toolUseId)} + /> + ))} + + {/* 回退:TeammateState 卡片(无 overview 时) */} + {!hasAgents && displayTeammates.map((tm) => ( + setExpandedId(expandedId === tm.taskId ? null : tm.taskId)} + /> + ))} + + {/* Agent 通信时间线 */} + +
+ ) + return (
{/* Team 头部 */} @@ -241,35 +273,7 @@ export function TeamActivityPanel({ sessionId }: TeamActivityPanelProps): React.
-
- {/* Task Board */} - {mergedTasks.length > 0 && ( - - )} - - {/* Agent 卡片(有 overview 时) */} - {hasAgents && agentEntries.map((agent) => ( - setExpandedId(expandedId === agent.toolUseId ? null : agent.toolUseId)} - /> - ))} - - {/* 回退:TeammateState 卡片(无 overview 时) */} - {!hasAgents && displayTeammates.map((tm) => ( - setExpandedId(expandedId === tm.taskId ? null : tm.taskId)} - /> - ))} - - {/* Agent 通信时间线 */} - -
+ {content}
) @@ -493,7 +497,7 @@ function AgentCard({ agent, expanded, onToggle }: AgentCardProps): React.ReactEl return (
@@ -620,10 +624,8 @@ function AgentDetail({ agent }: { agent: TeamAgentInfo }): React.ReactElement { const hasUsage = !!tm?.usage const hasAnyContent = hasSummary || hasProgress || hasToolHistory || hasOutput || hasUsage - return ( + const detailContent = (
- - {/* 工作摘要 */} {hasSummary && ( @@ -674,7 +676,7 @@ function AgentDetail({ agent }: { agent: TeamAgentInfo }): React.ReactElement { {/* 无详细数据时的提示 */} {!hasAnyContent && ( -

+

{tm?.status === 'running' ? '等待 Agent 返回进度数据...' : agent.status === 'running' @@ -684,6 +686,15 @@ function AgentDetail({ agent }: { agent: TeamAgentInfo }): React.ReactElement { )}

) + + return ( +
+ + + {detailContent} + +
+ ) } // ============================================================================ @@ -702,7 +713,7 @@ function TeammateCard({ teammate, expanded, onToggle }: TeammateCardProps): Reac return (
@@ -788,51 +799,55 @@ function TeammateCard({ teammate, expanded, onToggle }: TeammateCardProps): Reac {expanded && ( -
+
- {teammate.summary && ( - - - - )} - {teammate.status === 'running' && teammate.progressDescription && ( - -

- {teammate.progressDescription} -

-
- )} - {teammate.usage && ( - -
- - - {teammate.usage.toolUses} 次工具调用 - - - - {formatTokens(teammate.usage.totalTokens)} tokens - - {teammate.usage.durationMs > 0 && ( - - - {formatElapsed(teammate.usage.durationMs / 1000)} - - )} -
-
- )} - {teammate.toolHistory.length > 0 && ( - - )} - {teammate.outputFile && ( - - )} - {!teammate.summary && !teammate.usage && teammate.toolHistory.length === 0 && !teammate.outputFile && ( -

- {teammate.status === 'running' ? '等待 Agent 返回进度数据...' : '暂无详细数据'} -

- )} + +
+ {teammate.summary && ( + + + + )} + {teammate.status === 'running' && teammate.progressDescription && ( + +

+ {teammate.progressDescription} +

+
+ )} + {teammate.usage && ( + +
+ + + {teammate.usage.toolUses} 次工具调用 + + + + {formatTokens(teammate.usage.totalTokens)} tokens + + {teammate.usage.durationMs > 0 && ( + + + {formatElapsed(teammate.usage.durationMs / 1000)} + + )} +
+
+ )} + {teammate.toolHistory.length > 0 && ( + + )} + {teammate.outputFile && ( + + )} + {!teammate.summary && !teammate.usage && teammate.toolHistory.length === 0 && !teammate.outputFile && ( +

+ {teammate.status === 'running' ? '等待 Agent 返回进度数据...' : '暂无详细数据'} +

+ )} +
+
)}
diff --git a/apps/electron/src/renderer/components/ai-elements/conversation.tsx b/apps/electron/src/renderer/components/ai-elements/conversation.tsx index 976744e6..ee315746 100644 --- a/apps/electron/src/renderer/components/ai-elements/conversation.tsx +++ b/apps/electron/src/renderer/components/ai-elements/conversation.tsx @@ -11,26 +11,234 @@ * - ConversationScrollButton — 滚动到底部按钮 */ +import * as React from 'react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { + captureScrollMemory, + isScrollMemoryStateEqual, + resolveScrollMemory, +} from '@/lib/scroll-memory' +import type { ScrollMemoryState } from '@/lib/scroll-memory' import { ArrowDownIcon } from 'lucide-react' import type { ComponentProps } from 'react' -import { useCallback } from 'react' -import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' +import { + StickToBottom, + useStickToBottomContext, +} from 'use-stick-to-bottom' // ===== Conversation 根容器 ===== -export type ConversationProps = ComponentProps +export interface ConversationProps extends ComponentProps { + scrollMemory?: ScrollMemoryState | null + onScrollMemoryChange?: (state: ScrollMemoryState) => void + restoreKey?: string + restoreVersion?: string | number + onRestoreComplete?: () => void +} + +function ConversationScrollMemory({ + scrollMemory, + onScrollMemoryChange, + restoreKey, + restoreVersion, + onRestoreComplete, +}: Pick): null { + const { scrollRef, stopScroll, scrollToBottom } = useStickToBottomContext() + const lastReportedStateRef = React.useRef(null) + const suppressSaveRef = React.useRef(false) + const restoredCycleRef = React.useRef(null) + const latestScrollMemoryRef = React.useRef(scrollMemory) + const captureFrameRef = React.useRef(null) + + const reportScrollMemory = React.useCallback((container: HTMLElement): void => { + if (suppressSaveRef.current) return + + const nextState = captureScrollMemory(container) + if (lastReportedStateRef.current && isScrollMemoryStateEqual(lastReportedStateRef.current, nextState)) { + return + } + + lastReportedStateRef.current = nextState + onScrollMemoryChange?.(nextState) + }, [onScrollMemoryChange]) + + const scheduleScrollMemoryReport = React.useCallback((container: HTMLElement): void => { + if (captureFrameRef.current !== null) return + + captureFrameRef.current = requestAnimationFrame(() => { + captureFrameRef.current = null + reportScrollMemory(container) + }) + }, [reportScrollMemory]) + + React.useEffect(() => { + latestScrollMemoryRef.current = scrollMemory + }, [scrollMemory]) + + const restoreCycleToken = React.useMemo(() => { + if (!restoreKey) return null + return [restoreKey, restoreVersion ?? ''].join(':') + }, [restoreKey, restoreVersion]) + + React.useEffect(() => { + suppressSaveRef.current = restoreCycleToken !== null + restoredCycleRef.current = null + }, [restoreCycleToken]) + + React.useEffect(() => { + const el = scrollRef.current + if (!el || !onScrollMemoryChange) return + + reportScrollMemory(el) + const handleScroll = (): void => { + scheduleScrollMemoryReport(el) + } + + el.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + el.removeEventListener('scroll', handleScroll) + if (captureFrameRef.current !== null) { + cancelAnimationFrame(captureFrameRef.current) + captureFrameRef.current = null + } + } + }, [scrollRef, onScrollMemoryChange, reportScrollMemory, scheduleScrollMemoryReport, restoreKey]) + + React.useLayoutEffect(() => { + if (!restoreCycleToken) { + suppressSaveRef.current = false + return + } + + if (restoredCycleRef.current === restoreCycleToken) return + + const el = scrollRef.current + if (!el) return + + const state = latestScrollMemoryRef.current + let cancelled = false + + if (!state || state.atBottom) { + stopScroll() + + requestAnimationFrame(() => { + const completeRestore = (): void => { + requestAnimationFrame(() => { + if (cancelled) return + + restoredCycleRef.current = restoreCycleToken + suppressSaveRef.current = false + reportScrollMemory(el) + onRestoreComplete?.() + }) + } + + const result = scrollToBottom('instant') + if (typeof result === 'boolean') { + completeRestore() + return + } + + void result.then(() => { + completeRestore() + }) + }) + + return () => { + cancelled = true + } + } + + stopScroll() + + const hasMessageAnchors = (): boolean => { + return el.querySelector('[data-message-id]') !== null + } + + const completeRestore = (): void => { + restoredCycleRef.current = restoreCycleToken + suppressSaveRef.current = false + reportScrollMemory(el) + onRestoreComplete?.() + } + + const restore = (): void => { + if (cancelled || !hasMessageAnchors()) return + + const latestState = latestScrollMemoryRef.current + if (!latestState) return + + el.scrollTop = resolveScrollMemory(el, latestState) + + requestAnimationFrame(() => { + if (cancelled || !hasMessageAnchors()) return + + const finalState = latestScrollMemoryRef.current + if (!finalState) return + + el.scrollTop = resolveScrollMemory(el, finalState) + completeRestore() + }) + } + + if (hasMessageAnchors()) { + requestAnimationFrame(restore) + return () => { + cancelled = true + } + } + + const observer = new MutationObserver(() => { + if (!hasMessageAnchors()) return + observer.disconnect() + requestAnimationFrame(restore) + }) + + observer.observe(el, { childList: true, subtree: true }) + return () => { + cancelled = true + observer.disconnect() + } + }, [restoreCycleToken, scrollRef, stopScroll, scrollToBottom, onScrollMemoryChange, onRestoreComplete]) + + return null +} + +export function Conversation({ + className, + scrollMemory, + onScrollMemoryChange, + restoreKey, + restoreVersion, + onRestoreComplete, + initial, + children, + ...props +}: ConversationProps): React.ReactElement { + const initialBehavior = scrollMemory?.atBottom === false ? false : (initial ?? 'instant') -export function Conversation({ className, ...props }: ConversationProps): React.ReactElement { return ( + > + {(context) => ( + <> + + {typeof children === 'function' ? children(context) : children} + + )} + ) } @@ -96,7 +304,7 @@ export function ConversationScrollButton({ }: ConversationScrollButtonProps): React.ReactElement | null { const { isAtBottom, scrollToBottom } = useStickToBottomContext() - const handleScrollToBottom = useCallback(() => { + const handleScrollToBottom = React.useCallback(() => { scrollToBottom() }, [scrollToBottom]) diff --git a/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx b/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx index 8b2a35a8..b72ab344 100644 --- a/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx +++ b/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx @@ -25,6 +25,12 @@ interface ScrollMinimapProps { items: MinimapItem[] } +interface ScrollMetrics { + scrollTop: number + scrollHeight: number + clientHeight: number +} + /** 最少消息数才显示迷你地图 */ const MIN_ITEMS = 4 /** 迷你地图最多渲染的横杠数 */ @@ -35,7 +41,12 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement const [hovered, setHovered] = React.useState(false) const [visibleIds, setVisibleIds] = React.useState>(new Set()) const closeTimerRef = React.useRef>() - const [canScroll, setCanScroll] = React.useState(false) + const updateFrameRef = React.useRef(null) + const [scrollMetrics, setScrollMetrics] = React.useState({ + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + }) React.useEffect(() => { const el = scrollRef.current @@ -43,7 +54,14 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement const update = (): void => { const { scrollTop, scrollHeight, clientHeight } = el - setCanScroll(scrollHeight > clientHeight + 10) + setScrollMetrics((prev) => ( + prev.scrollTop === scrollTop + && prev.scrollHeight === scrollHeight + && prev.clientHeight === clientHeight + ? prev + : { scrollTop, scrollHeight, clientHeight } + )) + if (scrollHeight <= 0) return const nodes = el.querySelectorAll('[data-message-id]') @@ -59,17 +77,37 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement setVisibleIds(ids) } + const scheduleUpdate = (): void => { + if (updateFrameRef.current !== null) return + + updateFrameRef.current = requestAnimationFrame(() => { + updateFrameRef.current = null + update() + }) + } + update() - el.addEventListener('scroll', update, { passive: true }) - const observer = new ResizeObserver(update) + el.addEventListener('scroll', scheduleUpdate, { passive: true }) + const observer = new ResizeObserver(scheduleUpdate) observer.observe(el) return () => { - el.removeEventListener('scroll', update) + el.removeEventListener('scroll', scheduleUpdate) observer.disconnect() + if (updateFrameRef.current !== null) { + cancelAnimationFrame(updateFrameRef.current) + updateFrameRef.current = null + } } }, [scrollRef, items]) + React.useEffect(() => { + return () => { + if (closeTimerRef.current) clearTimeout(closeTimerRef.current) + if (updateFrameRef.current !== null) cancelAnimationFrame(updateFrameRef.current) + } + }, []) + const handleMouseEnter = (): void => { if (closeTimerRef.current) clearTimeout(closeTimerRef.current) setHovered(true) @@ -86,7 +124,10 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, [scrollRef]) - if (items.length < MIN_ITEMS || !canScroll) return null + if (items.length < MIN_ITEMS) return null + + const canScroll = scrollMetrics.scrollHeight > scrollMetrics.clientHeight + 10 + if (!canScroll) return null // 迷你地图:超过 MAX_BARS 条时按比例采样,避免拥挤 const barCount = Math.min(items.length, MAX_BARS) @@ -100,8 +141,8 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement > {/* 悬浮弹出面板 */} {hovered && ( -
-
+
+
{items.map((item) => ( + ) +} - {/* 标题 */} - {title} +function TabBarItemContent({ + type, + title, + isStreaming, +}: Pick): React.ReactElement { + const Icon = type === 'chat' ? MessageSquare : Bot - {/* 流式指示器 */} + return ( + <> + + {title} {isStreaming && ( )} + + ) +} - {/* 关闭按钮 */} - { - if (e.key === 'Enter' || e.key === ' ') handleCloseClick(e as unknown as React.MouseEvent) - }} - > - - - +export function TabBarItem({ + id, + type, + title, + isActive, + isStreaming, + onActivate, + onClose, + onMiddleClick, + onDragStart, +}: TabBarItemProps): React.ReactElement { + return ( + + + + ) } diff --git a/apps/electron/src/renderer/lib/scroll-memory.ts b/apps/electron/src/renderer/lib/scroll-memory.ts new file mode 100644 index 00000000..ba2f9b5d --- /dev/null +++ b/apps/electron/src/renderer/lib/scroll-memory.ts @@ -0,0 +1,80 @@ +/** + * scroll-memory — 运行期滚动位置快照与恢复 + * + * 基于消息锚点(message id + offset)保存阅读位置, + * 避免仅靠 scrollTop 在历史加载或新消息插入后出现位置失真。 + */ + +export interface ScrollMemoryState { + scrollTop: number + atBottom: boolean + anchorMessageId?: string + anchorOffset?: number +} + +export const DEFAULT_SCROLL_MEMORY_STATE: ScrollMemoryState = { + scrollTop: 0, + atBottom: true, +} + +/** 与 use-stick-to-bottom 内部 near-bottom 阈值保持一致 */ +export const STICK_TO_BOTTOM_OFFSET_PX = 70 + +export function isScrollMemoryStateEqual(a: ScrollMemoryState, b: ScrollMemoryState): boolean { + return a.scrollTop === b.scrollTop + && a.atBottom === b.atBottom + && a.anchorMessageId === b.anchorMessageId + && a.anchorOffset === b.anchorOffset +} + +export function clampScrollTop(container: HTMLElement, scrollTop: number): number { + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) + return Math.min(Math.max(0, scrollTop), maxScrollTop) +} + +export function captureScrollMemory(container: HTMLElement): ScrollMemoryState { + const scrollTop = clampScrollTop(container, container.scrollTop) + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) + const atBottom = maxScrollTop - scrollTop <= STICK_TO_BOTTOM_OFFSET_PX + + if (atBottom) { + return { + scrollTop, + atBottom: true, + } + } + + const nodes = container.querySelectorAll('[data-message-id]') + for (const node of nodes) { + const top = node.offsetTop + const bottom = top + node.offsetHeight + if (bottom > scrollTop) { + return { + scrollTop, + atBottom: false, + anchorMessageId: node.dataset.messageId, + anchorOffset: scrollTop - top, + } + } + } + + return { + scrollTop, + atBottom: false, + } +} + +export function resolveScrollMemory(container: HTMLElement, state: ScrollMemoryState): number { + if (state.atBottom) { + return Math.max(0, container.scrollHeight - container.clientHeight) + } + + if (state.anchorMessageId) { + const anchor = container.querySelector(`[data-message-id="${state.anchorMessageId}"]`) + if (anchor) { + return clampScrollTop(container, anchor.offsetTop + (state.anchorOffset ?? 0)) + } + } + + return clampScrollTop(container, state.scrollTop) +} diff --git a/apps/electron/src/renderer/lib/utils.ts b/apps/electron/src/renderer/lib/utils.ts index 8dff152c..b0357187 100644 --- a/apps/electron/src/renderer/lib/utils.ts +++ b/apps/electron/src/renderer/lib/utils.ts @@ -4,3 +4,11 @@ import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)) } + +export function deleteMapEntry(map: Map, key: string): Map { + if (!map.has(key)) return map + + const next = new Map(map) + next.delete(key) + return next +}