feat: improve H5 chat header with floating session rail and new chat entry
Move recent session chips below the nav on mobile H5/WeChat with three visible tabs and horizontal swipe, and add a new-chat icon next to notifications. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+92
-51
@@ -4,6 +4,7 @@ import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
|
|||||||
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
|
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
|
||||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||||
import { getSessionDisplayName } from '../utils/sessions';
|
import { getSessionDisplayName } from '../utils/sessions';
|
||||||
|
import { isH5OrWechatClient } from '../utils/wechat';
|
||||||
import { BalanceRing } from './BalanceRing';
|
import { BalanceRing } from './BalanceRing';
|
||||||
import { HistorySidebar } from './HistorySidebar';
|
import { HistorySidebar } from './HistorySidebar';
|
||||||
import { TKMindAvatar } from './TKMindAvatar';
|
import { TKMindAvatar } from './TKMindAvatar';
|
||||||
@@ -24,6 +25,7 @@ function RecentSessionRail({
|
|||||||
loadingMore,
|
loadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
chipWidth,
|
chipWidth,
|
||||||
|
variant = 'inline',
|
||||||
onSelect,
|
onSelect,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
}: {
|
}: {
|
||||||
@@ -34,6 +36,7 @@ function RecentSessionRail({
|
|||||||
loadingMore: boolean;
|
loadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
chipWidth?: number | null;
|
chipWidth?: number | null;
|
||||||
|
variant?: 'inline' | 'floating';
|
||||||
onSelect: (session: SessionSummary) => void;
|
onSelect: (session: SessionSummary) => void;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -91,12 +94,14 @@ function RecentSessionRail({
|
|||||||
|
|
||||||
if (!loading && recentSessions.length === 0) return null;
|
if (!loading && recentSessions.length === 0) return null;
|
||||||
|
|
||||||
|
const isFloating = variant === 'floating';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="header-session-rail"
|
className={`header-session-rail${isFloating ? ' header-session-rail-floating' : ''}`}
|
||||||
aria-label="最近对话入口"
|
aria-label="最近对话入口"
|
||||||
style={
|
style={
|
||||||
chipWidth && chipWidth > 0
|
!isFloating && chipWidth && chipWidth > 0
|
||||||
? ({ ['--header-session-chip-width' as string]: `${chipWidth}px` })
|
? ({ ['--header-session-chip-width' as string]: `${chipWidth}px` })
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -285,11 +290,6 @@ export function ChatView({
|
|||||||
: pendingSessionTitle;
|
: pendingSessionTitle;
|
||||||
|
|
||||||
const moreMenuItems = [
|
const moreMenuItems = [
|
||||||
{
|
|
||||||
id: 'new-session',
|
|
||||||
label: '新会话',
|
|
||||||
onClick: () => void handleNewSession(),
|
|
||||||
},
|
|
||||||
...(onOpenAdmin
|
...(onOpenAdmin
|
||||||
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
|
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
|
||||||
: []),
|
: []),
|
||||||
@@ -298,8 +298,21 @@ export function ChatView({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const useFloatingSessionRail = useMemo(() => isH5OrWechatClient(), []);
|
||||||
|
|
||||||
|
const recentSessionRailProps = {
|
||||||
|
sessions,
|
||||||
|
activeSessionId: session?.id,
|
||||||
|
armedSessionId,
|
||||||
|
loading: sessionsLoading,
|
||||||
|
loadingMore: sessionsLoadingMore,
|
||||||
|
hasMore: sessionsHasMore,
|
||||||
|
onSelect: handleRecentSessionSelect,
|
||||||
|
onLoadMore: () => void loadMoreSessions(),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className={`app-shell${useFloatingSessionRail ? ' app-shell-h5' : ''}`}>
|
||||||
<HistorySidebar
|
<HistorySidebar
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
@@ -317,37 +330,33 @@ export function ChatView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
|
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
|
||||||
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
|
<div className={useFloatingSessionRail ? 'header-shell header-shell-sticky' : undefined}>
|
||||||
<div className="header-left">
|
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
|
||||||
<button
|
<div className="header-left">
|
||||||
type="button"
|
<button
|
||||||
className="header-menu-btn"
|
type="button"
|
||||||
aria-label="展开历史对话"
|
className="header-menu-btn"
|
||||||
onClick={() => setSidebarOpen(true)}
|
aria-label="展开历史对话"
|
||||||
>
|
onClick={() => setSidebarOpen(true)}
|
||||||
☰
|
>
|
||||||
</button>
|
☰
|
||||||
<TKMindAvatar size="sm" className="header-brand-avatar" />
|
</button>
|
||||||
<div>
|
<TKMindAvatar size="sm" className="header-brand-avatar" />
|
||||||
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
|
<div>
|
||||||
<div className="header-sub">
|
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
|
||||||
{isConnectingTitle ? <ChatLoadingSpinner /> : null}
|
<div className="header-sub">
|
||||||
<span>{sessionTitle}</span>
|
{isConnectingTitle ? <ChatLoadingSpinner /> : null}
|
||||||
|
<span>{sessionTitle}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="header-actions header-actions-desktop">
|
||||||
<div className="header-actions header-actions-desktop">
|
{!useFloatingSessionRail && (
|
||||||
<RecentSessionRail
|
<RecentSessionRail
|
||||||
sessions={sessions}
|
{...recentSessionRailProps}
|
||||||
activeSessionId={session?.id}
|
chipWidth={desktopChipWidth}
|
||||||
armedSessionId={armedSessionId}
|
/>
|
||||||
loading={sessionsLoading}
|
)}
|
||||||
loadingMore={sessionsLoadingMore}
|
|
||||||
hasMore={sessionsHasMore}
|
|
||||||
chipWidth={desktopChipWidth}
|
|
||||||
onSelect={handleRecentSessionSelect}
|
|
||||||
onLoadMore={() => void loadMoreSessions()}
|
|
||||||
/>
|
|
||||||
{typeof balanceCents === 'number' && (
|
{typeof balanceCents === 'number' && (
|
||||||
<BalanceRing
|
<BalanceRing
|
||||||
balanceCents={balanceCents}
|
balanceCents={balanceCents}
|
||||||
@@ -359,6 +368,22 @@ export function ChatView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-icon-btn"
|
||||||
|
aria-label="新聊天"
|
||||||
|
title="新聊天"
|
||||||
|
onClick={() => void handleNewSession()}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 5v14M5 12h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{onOpenAdmin && (
|
{onOpenAdmin && (
|
||||||
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
|
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
|
||||||
管理
|
管理
|
||||||
@@ -374,9 +399,6 @@ export function ChatView({
|
|||||||
我的空间
|
我的空间
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
|
||||||
新会话
|
|
||||||
</button>
|
|
||||||
{user && <WechatAccountButton returnTo={window.location.pathname} />}
|
{user && <WechatAccountButton returnTo={window.location.pathname} />}
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||||
@@ -386,17 +408,12 @@ export function ChatView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions header-actions-mobile">
|
<div className="header-actions header-actions-mobile">
|
||||||
<RecentSessionRail
|
{!useFloatingSessionRail && (
|
||||||
sessions={sessions}
|
<RecentSessionRail
|
||||||
activeSessionId={session?.id}
|
{...recentSessionRailProps}
|
||||||
armedSessionId={armedSessionId}
|
chipWidth={null}
|
||||||
loading={sessionsLoading}
|
/>
|
||||||
loadingMore={sessionsLoadingMore}
|
)}
|
||||||
hasMore={sessionsHasMore}
|
|
||||||
chipWidth={null}
|
|
||||||
onSelect={handleRecentSessionSelect}
|
|
||||||
onLoadMore={() => void loadMoreSessions()}
|
|
||||||
/>
|
|
||||||
{typeof balanceCents === 'number' && (
|
{typeof balanceCents === 'number' && (
|
||||||
<BalanceRing
|
<BalanceRing
|
||||||
balanceCents={balanceCents}
|
balanceCents={balanceCents}
|
||||||
@@ -408,6 +425,22 @@ export function ChatView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-icon-btn"
|
||||||
|
aria-label="新聊天"
|
||||||
|
title="新聊天"
|
||||||
|
onClick={() => void handleNewSession()}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 5v14M5 12h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{onOpenSpace && (
|
{onOpenSpace && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -434,6 +467,14 @@ export function ChatView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{useFloatingSessionRail && (
|
||||||
|
<RecentSessionRail
|
||||||
|
{...recentSessionRailProps}
|
||||||
|
variant="floating"
|
||||||
|
chipWidth={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!online && (
|
{!online && (
|
||||||
<div className="banner banner-offline">网络已断开,请检查连接后重试</div>
|
<div className="banner banner-offline">网络已断开,请检查连接后重试</div>
|
||||||
|
|||||||
@@ -645,6 +645,56 @@ body,
|
|||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-shell-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-shell-sticky .header {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
z-index: auto;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-h5 .header-session-rail-floating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
flex: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-h5 .header-shell-sticky:has(.header-home) .header-session-rail-floating {
|
||||||
|
background: rgba(12, 18, 30, 0.72);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-bottom-color: rgba(103, 123, 156, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-h5 .header-session-rail-floating .header-session-rail-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-h5 .header-session-rail-floating .header-session-rail-track {
|
||||||
|
--header-session-chip-width: calc((100% - 16px) / 3);
|
||||||
|
grid-auto-columns: calc((100% - 16px) / 3);
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
touch-action: pan-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-h5 .header-session-rail-floating .header-session-chip {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.header-icon-btn {
|
.header-icon-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ export function isWechatContext(options?: {
|
|||||||
return isWechatEntryUrl(options?.search);
|
return isWechatEntryUrl(options?.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手机 H5 或微信内置浏览器:导航会话标签采用下方悬浮条布局 */
|
||||||
|
export function isH5OrWechatClient(options?: {
|
||||||
|
userAgent?: string;
|
||||||
|
search?: string;
|
||||||
|
serverInWechat?: boolean;
|
||||||
|
}) {
|
||||||
|
return isMobileBrowser(options?.userAgent) || isWechatContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWechatAuthorizeUrl(options?: {
|
export function buildWechatAuthorizeUrl(options?: {
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
utmSource?: string;
|
utmSource?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user