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 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>
+50
View File
@@ -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;
+9
View File
@@ -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;