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:
john
2026-07-03 23:21:50 +08:00
parent c3c487a8fb
commit 553af7ca7a
3 changed files with 151 additions and 51 deletions
+92 -51
View File
@@ -4,6 +4,7 @@ import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
import { isH5OrWechatClient } from '../utils/wechat';
import { BalanceRing } from './BalanceRing';
import { HistorySidebar } from './HistorySidebar';
import { TKMindAvatar } from './TKMindAvatar';
@@ -24,6 +25,7 @@ function RecentSessionRail({
loadingMore,
hasMore,
chipWidth,
variant = 'inline',
onSelect,
onLoadMore,
}: {
@@ -34,6 +36,7 @@ function RecentSessionRail({
loadingMore: boolean;
hasMore: boolean;
chipWidth?: number | null;
variant?: 'inline' | 'floating';
onSelect: (session: SessionSummary) => void;
onLoadMore: () => void;
}) {
@@ -91,12 +94,14 @@ function RecentSessionRail({
if (!loading && recentSessions.length === 0) return null;
const isFloating = variant === 'floating';
return (
<div
className="header-session-rail"
className={`header-session-rail${isFloating ? ' header-session-rail-floating' : ''}`}
aria-label="最近对话入口"
style={
chipWidth && chipWidth > 0
!isFloating && chipWidth && chipWidth > 0
? ({ ['--header-session-chip-width' as string]: `${chipWidth}px` })
: undefined
}
@@ -285,11 +290,6 @@ export function ChatView({
: pendingSessionTitle;
const moreMenuItems = [
{
id: 'new-session',
label: '新会话',
onClick: () => void handleNewSession(),
},
...(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 (
<div className="app-shell">
<div className={`app-shell${useFloatingSessionRail ? ' app-shell-h5' : ''}`}>
<HistorySidebar
open={sidebarOpen}
sessions={sessions}
@@ -317,37 +330,33 @@ export function ChatView({
/>
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
<div className="header-left">
<button
type="button"
className="header-menu-btn"
aria-label="展开历史对话"
onClick={() => setSidebarOpen(true)}
>
</button>
<TKMindAvatar size="sm" className="header-brand-avatar" />
<div>
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
<div className="header-sub">
{isConnectingTitle ? <ChatLoadingSpinner /> : null}
<span>{sessionTitle}</span>
<div className={useFloatingSessionRail ? 'header-shell header-shell-sticky' : undefined}>
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
<div className="header-left">
<button
type="button"
className="header-menu-btn"
aria-label="展开历史对话"
onClick={() => setSidebarOpen(true)}
>
</button>
<TKMindAvatar size="sm" className="header-brand-avatar" />
<div>
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
<div className="header-sub">
{isConnectingTitle ? <ChatLoadingSpinner /> : null}
<span>{sessionTitle}</span>
</div>
</div>
</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()}
/>
<div className="header-actions header-actions-desktop">
{!useFloatingSessionRail && (
<RecentSessionRail
{...recentSessionRailProps}
chipWidth={desktopChipWidth}
/>
)}
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
@@ -359,6 +368,22 @@ export function ChatView({
/>
)}
<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 && (
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
@@ -374,9 +399,6 @@ export function ChatView({
</button>
)}
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
</button>
{user && <WechatAccountButton returnTo={window.location.pathname} />}
{onLogout && (
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
@@ -386,17 +408,12 @@ 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()}
/>
{!useFloatingSessionRail && (
<RecentSessionRail
{...recentSessionRailProps}
chipWidth={null}
/>
)}
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
@@ -408,6 +425,22 @@ export function ChatView({
/>
)}
<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 && (
<button
type="button"
@@ -434,6 +467,14 @@ export function ChatView({
/>
</div>
</header>
{useFloatingSessionRail && (
<RecentSessionRail
{...recentSessionRailProps}
variant="floating"
chipWidth={null}
/>
)}
</div>
{!online && (
<div className="banner banner-offline"></div>
+50
View File
@@ -645,6 +645,56 @@ body,
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 {
display: flex;
align-items: center;
+9
View File
@@ -89,6 +89,15 @@ export function isWechatContext(options?: {
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?: {
returnTo?: string;
utmSource?: string;