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 { 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user