fix: fix chat outline active

main
yannyang 2026-07-27 12:21:37 +08:00
parent 2a4dc72059
commit 9da58f0cb1
11 changed files with 244 additions and 53 deletions

View File

@ -13,7 +13,8 @@ import {
LeftOutlined,
RightOutlined,
UserOutlined,
CreditCardOutlined
CreditCardOutlined,
ShoppingCartOutlined
} from '@ant-design/icons';
import { useAuth } from '../store/auth';
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
@ -55,7 +56,7 @@ const NAV_GROUPS: Array<{
{
label: '商城',
items: [
{ to: '/points-mall', icon: <CompassOutlined />, label: 'Token 商城' },
{ to: '/points-mall', icon: <ShoppingCartOutlined />, label: 'Token 商城' },
{ to: '/pricing', icon: <CreditCardOutlined />, label: '会员计划' }
]
}

View File

@ -5,6 +5,67 @@
display: flex;
flex-direction: column;
height: 100%;
transition: width 0.3s ease;
position: relative;
}
.chat-side.is-collapsed {
width: 68px !important;
}
.chat-agent-sidebar-toggle {
position: absolute;
right: -12px;
top: 24px;
width: 24px;
height: 24px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1001;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
color: var(--color-text-secondary);
font-size: 12px;
transition: all 0.2s;
}
.chat-agent-sidebar-toggle:hover {
color: var(--color-brand);
border-color: var(--color-brand);
transform: scale(1.1);
}
.chat-side.is-collapsed .chat-agent-sidebar-header {
padding: 16px 8px 8px;
}
.chat-side.is-collapsed .chat-agent-create-btn {
padding: 0 !important;
width: 40px;
margin: 0 auto;
}
.chat-side.is-collapsed .chat-agent-sidebar-list {
padding: 8px;
}
.chat-side.is-collapsed .chat-agent-item {
justify-content: center;
padding: 8px 0;
border-left: none;
}
.chat-side.is-collapsed .chat-agent-item.active {
background: var(--color-surface-3);
}
.chat-side.is-collapsed .chat-agent-item.active::after {
right: 4px;
bottom: 4px;
}
.chat-side.is-full {

View File

@ -1,5 +1,5 @@
import { Button, Spin, Empty, Tabs } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Spin, Empty, Tabs, Tooltip } from 'antd';
import { PlusOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { useEffect, useState, useMemo } from 'react';
import type { Agent } from '../../../api';
import './AgentSidebar.css';
@ -12,8 +12,18 @@ export default function AgentSidebar(props: {
onCreate: () => void;
onSelect: (agentId: string) => void;
isSidebar?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
}) {
const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
const {
agentList,
activeAgentId,
onCreate,
onSelect,
isSidebar = true,
collapsed = false,
onToggleCollapse
} = props;
const [loadingTimeout, setLoadingTimeout] = useState(false);
const [activeTab, setActiveTab] = useState('mine');
@ -52,7 +62,13 @@ export default function AgentSidebar(props: {
}, [filteredList]);
return (
<aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
<aside className={`chat-side${isSidebar ? '' : ' is-full'}${collapsed ? ' is-collapsed' : ''}`}>
{onToggleCollapse && (
<div className="chat-agent-sidebar-toggle" onClick={onToggleCollapse}>
{collapsed ? <RightOutlined /> : <LeftOutlined />}
</div>
)}
<div className="chat-agent-sidebar-header">
<Button
block
@ -61,10 +77,11 @@ export default function AgentSidebar(props: {
onClick={onCreate}
className="chat-agent-create-btn"
>
{!collapsed && '创建智能体'}
</Button>
</div>
{!collapsed && (
<div className="chat-agent-tabs">
<Tabs
activeKey={activeTab}
@ -76,18 +93,21 @@ export default function AgentSidebar(props: {
]}
/>
</div>
)}
<div className="chat-agent-sidebar-list">
{agentList.length > 0 ? (
groupedAgents.map(([groupName, list]) => (
<div key={groupName} className="chat-agent-group">
{!collapsed && (
<div className="chat-agent-group-label">
<span>{groupName}</span>
<span className="chat-agent-group-count">{list.length}</span>
</div>
)}
{list.map((a) => {
const isActive = a.id === activeAgentId;
return (
const content = (
<div
key={a.id}
onClick={() => onSelect(a.id)}
@ -103,28 +123,41 @@ export default function AgentSidebar(props: {
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
{!collapsed && (
<div className="chat-agent-info">
<div className="chat-agent-name">{a.name}</div>
</div>
)}
</div>
);
if (collapsed) {
return (
<Tooltip key={a.id} title={a.name} placement="right">
{content}
</Tooltip>
);
}
return content;
})}
</div>
))
) : !loadingTimeout ? (
<div className="chat-agent-sidebar-status">
<Spin />
<div className="chat-agent-loading-text">...</div>
{!collapsed && <div className="chat-agent-loading-text">...</div>}
</div>
) : (
<div className="chat-agent-sidebar-status">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
!collapsed && (
<span className="chat-agent-empty-text">
<br />
</span>
)
}
/>
</div>

View File

@ -13,6 +13,9 @@ function formatAgentModel(raw: string | null | undefined) {
const names = parsed
.map((x: any) => String(x?.name || x?.model || x?.id || '').trim())
.filter(Boolean);
if (names.length > 2) {
return names.slice(0, 2).join(', ') + '...';
}
if (names.length) return names.join(', ');
} else if (parsed && typeof parsed === 'object') {
const name = String((parsed as any).name || (parsed as any).model || (parsed as any).id || '').trim();
@ -53,7 +56,7 @@ export default function ChatHeader(props: {
<span className="chat-header-name">{agent.name}</span>
</div>
<div className="chat-header-desc">
<span className="chat-header-model">#{modelText}</span>
<span className="chat-header-model">{modelText}</span>
</div>
</div>
</div>

View File

@ -108,6 +108,10 @@
background: var(--color-brand-soft);
}
.is-collapsed .chat-outline-item.active {
background: transparent;
}
.chat-outline-index {
font-size: 12px;
font-weight: 700;

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { App as AntApp, Empty } from 'antd';
import type { ChatPageLogicOutput } from '../ChatPageLogic';
import { markdownToPlainText } from '../utils/copy';
@ -16,6 +16,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
const { message } = AntApp.useApp();
const viewport = useDesktopViewport();
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
const [agentSidebarCollapsed, setAgentSidebarCollapsed] = useState(false);
const isAutoScrolling = useRef(false);
const {
id,
@ -43,11 +45,85 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
handleNewSession,
} = logic;
// 1. 初始化时,如果有历史消息,激活最后一条 Agent 消息
useEffect(() => {
if (messages.length > 0 && !highlightId) {
const agentMsgs = messages.filter(m => m.role === 'assistant' || m.role === 'agent' || m.speaker?.type === 'agent');
if (agentMsgs.length > 0) {
setHighlightId(agentMsgs[agentMsgs.length - 1].id);
}
}
}, [messages.length]);
// 2. 监听滚动,自动更新大纲激活态
useEffect(() => {
const bodyEl = bodyRef.current;
if (!bodyEl) return;
const handleScroll = () => {
if (isAutoScrolling.current) return;
const messageEls = bodyEl.querySelectorAll('.message-item-container[data-is-agent="true"]');
if (messageEls.length === 0) return;
// 检查是否滚动到底部
const isAtBottom = bodyEl.scrollHeight - bodyEl.scrollTop <= bodyEl.clientHeight + 100;
if (isAtBottom) {
const agentMsgs = messages.filter(m => m.role === 'assistant' || m.role === 'agent' || m.speaker?.type === 'agent');
const lastAgentMsgId = agentMsgs[agentMsgs.length - 1]?.id;
if (lastAgentMsgId && lastAgentMsgId !== highlightId) {
setHighlightId(lastAgentMsgId);
}
return;
}
let currentActiveId = highlightId;
const containerRect = bodyEl.getBoundingClientRect();
// 找到当前视口中占据主要位置的 Agent 消息
for (let i = 0; i < messageEls.length; i++) {
const el = messageEls[i] as HTMLElement;
const rect = el.getBoundingClientRect();
// 判定准则:消息头部进入视口顶部感应区
if (rect.top >= containerRect.top - 20 && rect.top <= containerRect.top + 150) {
const idAttr = el.getAttribute('data-msg-id');
if (idAttr) {
currentActiveId = idAttr;
break;
}
}
}
if (currentActiveId && currentActiveId !== highlightId) {
setHighlightId(currentActiveId);
}
};
bodyEl.addEventListener('scroll', handleScroll);
return () => bodyEl.removeEventListener('scroll', handleScroll);
}, [messages, highlightId]);
const handleJump = useCallback((msgId: string) => {
isAutoScrolling.current = true;
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) {
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
// 滚动结束后重置标志位,避免触发 handleScroll
setTimeout(() => {
isAutoScrolling.current = false;
}, 800);
}
}, [setHighlightId]);
return (
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
<AgentSidebar
agentList={agentList}
activeAgentId={id}
collapsed={agentSidebarCollapsed}
onToggleCollapse={() => setAgentSidebarCollapsed(!agentSidebarCollapsed)}
onCreate={() => navigate('/agents/new')}
onSelect={(aid) => navigate(`/chat/${aid}`)}
/>
@ -125,11 +201,7 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
activeId={highlightId}
collapsed={outlineCollapsed}
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
onJump={(msgId) => {
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
}}
onJump={handleJump}
/>
</div>
)}

View File

@ -3,7 +3,7 @@
}
.message-item-container.highlighted {
background: var(--color-surface-2);
/* background: var(--color-surface-2); */
}
.message-item-assistant,

View File

@ -58,7 +58,9 @@ export default function MessageItem(props: {
return (
<div
id={'msg-' + message.id}
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
className={`message-item-container ${highlighted ? 'highlighted' : ''} ${bubbleRole}`}
data-msg-id={message.id}
data-is-agent={!isUser}
>
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
<Avatar

View File

@ -60,14 +60,19 @@ export function useChatData(args: {
if (rid !== roomId) return;
setMessages(Array.isArray(his.messages) ? his.messages : []);
setBranches(his.branches || {});
requestAnimationFrame(() => {
const checkScroll = () => {
if (!initialScrollDoneRef.current) {
scrollBottom(true);
initialScrollDoneRef.current = true;
} else {
scrollBottom();
}
});
};
requestAnimationFrame(checkScroll);
setTimeout(checkScroll, 100);
setTimeout(checkScroll, 500);
};
useEffect(() => {
@ -104,10 +109,8 @@ export function useChatData(args: {
if (!highlightId) return;
const el = document.getElementById('msg-' + highlightId);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
const t = setTimeout(() => setHighlightId(null), 3000);
return () => clearTimeout(t);
}, [highlightId, setHighlightId]);
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [highlightId]);
return {
agent,

View File

@ -6,6 +6,7 @@ export function useChatScroll() {
const initialScrollDoneRef = useRef(false);
const scrollRafRef = useRef<number | null>(null);
const scrollProgrammaticAtRef = useRef(0);
const forceScrollPendingRef = useRef(false);
const lastScrollTopRef = useRef(0);
const userScrollLockRef = useRef(false);
const lastUserScrollAtRef = useRef(0);
@ -16,14 +17,18 @@ export function useChatScroll() {
if (force) {
userScrollLockRef.current = false;
autoScrollRef.current = true;
forceScrollPendingRef.current = true;
}
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) {
return;
}
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
if (scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
forceScrollPendingRef.current = false;
const el = bodyRef.current;
if (!el) return;
scrollProgrammaticAtRef.current = Date.now();
@ -55,7 +60,7 @@ export function useChatScroll() {
const distance = el.scrollHeight - nextTop - el.clientHeight;
const scrollDelta = nextTop - lastTop;
if (scrollDelta < 0) {
if (scrollDelta < 0 && !forceScrollPendingRef.current) {
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
}
lastScrollTopRef.current = nextTop;
@ -67,11 +72,19 @@ export function useChatScroll() {
userScrollLockRef.current = false;
}
}
// 只有当不是程序化滚动,且没有强制滚动在等待时,才根据距离更新 autoScroll 状态
if (!isProgrammatic && !forceScrollPendingRef.current) {
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
}
// 如果 autoScroll 被关闭或者是用户锁定状态,且当前有一个待执行的 RAF
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current && !isProgrammatic && !forceScrollPendingRef.current) {
// 只有当明确是用户向上滚动了,才取消待执行的程序化滚动
if (scrollDelta < 0) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
}
};
const onWindowWheelCapture = (e: WheelEvent) => {

View File

@ -3,7 +3,6 @@
}
.chat-shell.desktop-standardPc .chat-side {
width: 300px;
flex-shrink: 0;
}