feat: polish mindspace decoupling flows
This commit is contained in:
+226
-12
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useChat } from '../context/ChatProvider';
|
||||
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { getSessionDisplayName } from '../utils/sessions';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
@@ -9,12 +9,149 @@ import { HistorySidebar } from './HistorySidebar';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
||||
import { ConversationPackagePanel } from './ConversationPackagePanel';
|
||||
import { WechatBindPrompt } from './WechatBindPrompt';
|
||||
import { WechatAccountButton } from './WechatAccountButton';
|
||||
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
|
||||
import { NotificationCenter } from './NotificationCenter';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
function RecentSessionRail({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
armedSessionId,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
chipWidth,
|
||||
onSelect,
|
||||
onLoadMore,
|
||||
}: {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId?: string | null;
|
||||
armedSessionId?: string | null;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
chipWidth?: number | null;
|
||||
onSelect: (session: SessionSummary) => void;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const compactLabel = (value: string, maxChars = 8) => {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) return '未命名';
|
||||
return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}...` : trimmed;
|
||||
};
|
||||
|
||||
const railRef = useRef<HTMLDivElement | null>(null);
|
||||
const recentSessions = useMemo(() => sessions, [sessions]);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateScrollState = () => {
|
||||
const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth);
|
||||
setCanScrollLeft(node.scrollLeft > 8);
|
||||
setCanScrollRight(node.scrollLeft < maxScrollLeft - 8);
|
||||
};
|
||||
|
||||
updateScrollState();
|
||||
node.addEventListener('scroll', updateScrollState, { passive: true });
|
||||
window.addEventListener('resize', updateScrollState);
|
||||
return () => {
|
||||
node.removeEventListener('scroll', updateScrollState);
|
||||
window.removeEventListener('resize', updateScrollState);
|
||||
};
|
||||
}, [recentSessions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const maybeLoadMore = () => {
|
||||
if (!hasMore || loadingMore) return;
|
||||
const remaining = node.scrollWidth - (node.scrollLeft + node.clientWidth);
|
||||
if (remaining <= 96) onLoadMore();
|
||||
};
|
||||
|
||||
maybeLoadMore();
|
||||
node.addEventListener('scroll', maybeLoadMore, { passive: true });
|
||||
return () => node.removeEventListener('scroll', maybeLoadMore);
|
||||
}, [hasMore, loadingMore, onLoadMore, recentSessions.length]);
|
||||
|
||||
const scrollByPage = (direction: -1 | 1) => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
const amount = Math.max(180, Math.round(node.clientWidth * 0.82)) * direction;
|
||||
node.scrollBy({ left: amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (!loading && recentSessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="header-session-rail"
|
||||
aria-label="最近对话入口"
|
||||
style={
|
||||
chipWidth && chipWidth > 0
|
||||
? ({ ['--header-session-chip-width' as string]: `${chipWidth}px` })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="header-session-rail-arrow header-session-rail-arrow-left"
|
||||
aria-label="查看更早的最近对话"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollByPage(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
className="header-session-rail-track"
|
||||
ref={railRef}
|
||||
>
|
||||
{recentSessions.map((item) => {
|
||||
const active = item.id === activeSessionId;
|
||||
const armed = item.id === armedSessionId;
|
||||
const displayName = getSessionDisplayName(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`header-session-chip${active ? ' is-active' : ''}${armed ? ' is-armed' : ''}`}
|
||||
aria-pressed={active || armed}
|
||||
title={displayName}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<strong>{compactLabel(displayName, 8)}</strong>
|
||||
<span>{armed ? '再次点击' : item.message_count > 0 ? `${item.message_count}条` : '继续'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(loading || loadingMore) && (
|
||||
<div className="header-session-chip header-session-chip-loading" aria-hidden="true">
|
||||
<strong>{loading ? '加载中…' : '继续加载…'}</strong>
|
||||
<span>{loading ? '正在准备最近对话' : '滑到这里会自动接上更多'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="header-session-rail-arrow header-session-rail-arrow-right"
|
||||
aria-label="查看更多最近对话"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollByPage(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
user,
|
||||
capabilities,
|
||||
@@ -72,6 +209,29 @@ export function ChatView({
|
||||
} = useChat();
|
||||
const online = useNetworkStatus();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [packagePanelSession, setPackagePanelSession] = useState<SessionSummary | null>(null);
|
||||
const [armedSessionId, setArmedSessionId] = useState<string | null>(null);
|
||||
const [desktopChipWidth, setDesktopChipWidth] = useState<number | null>(null);
|
||||
const spaceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = spaceButtonRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = Math.round(node.getBoundingClientRect().width);
|
||||
if (nextWidth > 0) setDesktopChipWidth(nextWidth);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateWidth) : null;
|
||||
resizeObserver?.observe(node);
|
||||
window.addEventListener('resize', updateWidth);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateWidth);
|
||||
};
|
||||
}, [onOpenSpace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
@@ -86,17 +246,43 @@ export function ChatView({
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenConversationPackage = (sessionSummary: SessionSummary) => {
|
||||
setPackagePanelSession(sessionSummary);
|
||||
};
|
||||
|
||||
const handleRecentSessionSelect = (sessionSummary: SessionSummary) => {
|
||||
const isSameSession = session?.id === sessionSummary.id;
|
||||
const isArmed = armedSessionId === sessionSummary.id;
|
||||
if (isSameSession && isArmed) {
|
||||
handleOpenConversationPackage(sessionSummary);
|
||||
setArmedSessionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPackagePanelSession(null);
|
||||
setArmedSessionId(sessionSummary.id);
|
||||
if (!isSameSession) {
|
||||
handleSelectSession(sessionSummary.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
void newSession();
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const isConnectingTitle = !session && chatState !== 'idle';
|
||||
const pendingSessionTitle =
|
||||
chatState === 'connecting'
|
||||
? '正在创建新对话…'
|
||||
: chatState === 'waiting'
|
||||
? '请求已发出…'
|
||||
: '连接中…';
|
||||
const sessionTitle = session
|
||||
? getSessionDisplayName(session)
|
||||
: chatState === 'idle'
|
||||
? '新对话'
|
||||
: '连接中…';
|
||||
: pendingSessionTitle;
|
||||
|
||||
const moreMenuItems = [
|
||||
{
|
||||
@@ -104,9 +290,6 @@ export function ChatView({
|
||||
label: '新会话',
|
||||
onClick: () => void handleNewSession(),
|
||||
},
|
||||
...(onOpenFeedback
|
||||
? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }]
|
||||
: []),
|
||||
...(onOpenAdmin
|
||||
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
|
||||
: []),
|
||||
@@ -154,6 +337,17 @@ export function ChatView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions header-actions-desktop">
|
||||
<RecentSessionRail
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
armedSessionId={armedSessionId}
|
||||
loading={sessionsLoading}
|
||||
loadingMore={sessionsLoadingMore}
|
||||
hasMore={sessionsHasMore}
|
||||
chipWidth={desktopChipWidth}
|
||||
onSelect={handleRecentSessionSelect}
|
||||
onLoadMore={() => void loadMoreSessions()}
|
||||
/>
|
||||
{typeof balanceCents === 'number' && (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
@@ -171,15 +365,15 @@ export function ChatView({
|
||||
</button>
|
||||
)}
|
||||
{onOpenSpace && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenSpace?.()}>
|
||||
<button
|
||||
ref={spaceButtonRef}
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => onOpenSpace?.()}
|
||||
>
|
||||
我的空间
|
||||
</button>
|
||||
)}
|
||||
{onOpenFeedback && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenFeedback()}>
|
||||
反馈
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
||||
新会话
|
||||
</button>
|
||||
@@ -192,6 +386,17 @@ export function ChatView({
|
||||
</div>
|
||||
|
||||
<div className="header-actions header-actions-mobile">
|
||||
<RecentSessionRail
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
armedSessionId={armedSessionId}
|
||||
loading={sessionsLoading}
|
||||
loadingMore={sessionsLoadingMore}
|
||||
hasMore={sessionsHasMore}
|
||||
chipWidth={null}
|
||||
onSelect={handleRecentSessionSelect}
|
||||
onLoadMore={() => void loadMoreSessions()}
|
||||
/>
|
||||
{typeof balanceCents === 'number' && (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
@@ -311,6 +516,15 @@ export function ChatView({
|
||||
onOpenSpace?.({ categoryCode: result.categoryCode });
|
||||
}}
|
||||
/>
|
||||
<ConversationPackagePanel
|
||||
sessionId={packagePanelSession?.id ?? null}
|
||||
sessionName={packagePanelSession ? getSessionDisplayName(packagePanelSession) : null}
|
||||
open={Boolean(packagePanelSession)}
|
||||
onClose={() => {
|
||||
setPackagePanelSession(null);
|
||||
setArmedSessionId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user