fix(web): persist thinking, title sessions distinctly, and send follow-ups from the answer

Thinking disappeared on failure and never reached session storage. Keep the
sanitized chain on disk and on errors, and regroup the sidebar around reports,
charts, favorites, and dated history titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-22 09:10:47 +08:00
parent 649ba32034
commit 59559d4b24
27 changed files with 741 additions and 179 deletions
@@ -2,10 +2,11 @@
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Check } from "lucide-react";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { activityElapsedLabel } from "@/lib/chat-message-view";
import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view";
const labels = {
working: "正在处理任务…",
@@ -40,29 +41,77 @@ function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
}
function MessageThinkingTrace({
text,
hasAnswer,
}: Readonly<{
text: string;
hasAnswer: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const open = userOpen ?? !hasAnswer;
if (!text.trim()) return null;
return (
<details
className="message-thinking"
open={open}
onToggle={(event) => {
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary></summary>
<div className="message-thinking-body">{text}</div>
</details>
);
}
export function AgentActivityStatus({
state,
label = labels[state],
startedAt,
completedTrail,
thinkingText,
hasAnswer = false,
showLive = true,
}: Readonly<{
state: AgentActivityState;
label?: string;
startedAt?: number;
completedTrail?: string;
thinkingText?: string;
hasAnswer?: boolean;
showLive?: boolean;
}>) {
const completedSteps = activityCompletedSteps(completedTrail);
const live = showLive && !hasAnswer;
if (!live && completedSteps.length === 0 && !thinkingText?.trim()) return null;
return (
<div className="agent-activity-status">
<div className="agent-activity-status__row">
<div className="agent-activity-status__live" role="status">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
<span key={label} className="agent-activity-status__text">{label}</span>
</div>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</div>
{completedTrail ? (
<p className="agent-activity-status__trail" aria-hidden="true">{completedTrail}</p>
) : null}
<div className="agent-thinking-panel agent-activity-status">
{(live || completedSteps.length > 0) && (
<ol className="agent-thinking-timeline">
{completedSteps.map((step) => (
<li className="agent-thinking-step is-done" key={step}>
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
<span>{step}</span>
</li>
))}
{live ? (
<li className="agent-thinking-step is-live">
<span className="agent-thinking-marker is-live-marker">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
</span>
<span className="agent-activity-status__live" role="status">
<span key={label} className="agent-activity-status__text">{label}</span>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</span>
</li>
) : null}
</ol>
)}
{thinkingText ? <MessageThinkingTrace text={thinkingText} hasAnswer={hasAnswer} /> : null}
</div>
);
}
+120 -41
View File
@@ -2,13 +2,18 @@
import { Menu } from "@base-ui/react/menu";
import {
ChevronDown,
ChevronRight,
Clock3,
FileText,
Gift,
LogOut,
MessageSquareText,
Plus,
SquarePen,
Star,
UserPlus,
UserRound,
Users,
} from "lucide-react";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
@@ -42,8 +47,15 @@ export type SidebarAccount = {
avatar: BeamAvatar | null;
};
export type SidebarChart = {
readonly id: string;
readonly name: string;
readonly role: "self" | "other";
};
export type AppSidebarProps = {
sessions: readonly SidebarSession[];
charts: readonly SidebarChart[];
activeSessionId: string | null;
account: SidebarAccount;
accountMenuOpen: boolean;
@@ -55,6 +67,8 @@ export type AppSidebarProps = {
onNewChat: () => void;
onOpenReports: () => void;
onSelectSession: (sessionId: string) => void;
onSelectChart: (chartId: string) => void;
onAddChart: () => void;
onOpenProfile: () => void;
onOpenRedeem: () => void;
onOpenLogout: () => void;
@@ -62,6 +76,7 @@ export type AppSidebarProps = {
export function AppSidebar({
sessions,
charts,
activeSessionId,
account,
accountMenuOpen,
@@ -73,17 +88,21 @@ export function AppSidebar({
onNewChat,
onOpenReports,
onSelectSession,
onSelectChart,
onAddChart,
onOpenProfile,
onOpenRedeem,
onOpenLogout,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
const firstSessionRef = useRef<HTMLButtonElement>(null);
const historyHeadingRef = useRef<HTMLHeadingElement>(null);
const historyHeadingRef = useRef<HTMLElement>(null);
const isCollapsedDesktop = state === "collapsed" && !isMobile;
const showExpandedContent = !isCollapsedDesktop;
const menuPlacement = `${viewport}:${state}`;
const previousMenuPlacement = useRef(menuPlacement);
const favoriteSessions = sessions.filter((session) => session.pinned);
const historySessions = sessions.filter((session) => !session.pinned);
useEffect(() => {
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
@@ -109,6 +128,30 @@ export function AppSidebar({
});
}
function renderSession(session: SidebarSession, index: number) {
return (
<SidebarMenuItem key={session.id}>
<SidebarSessionRow
ref={index === 0 ? firstSessionRef : undefined}
session={session}
active={session.id === activeSessionId}
disabled={sessionControls.disabled}
menuOpen={sessionControls.menuSessionId === session.id}
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
onSelect={() => {
onSelectSession(session.id);
if (isMobile) setOpenMobile(false);
}}
onTogglePinned={() => sessionControls.onTogglePinned(session.id)}
onRename={() => sessionControls.onRename(session.id)}
onShare={() => sessionControls.onShare(session.id)}
onToggleArchived={() => sessionControls.onToggleArchived(session.id)}
onDelete={() => sessionControls.onDelete(session.id)}
/>
</SidebarMenuItem>
);
}
return (
<Sidebar className="sidebar" aria-label="对话导航">
<SidebarHeader className="sidebar-header">
@@ -125,8 +168,8 @@ export function AppSidebar({
disabled={newChatDisabled}
onClick={handleNewChat}
>
<Plus aria-hidden="true" />
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新对话"}</span> : null}
<SquarePen aria-hidden="true" />
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新对话"}</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
@@ -145,43 +188,79 @@ export function AppSidebar({
<SidebarContent>
{showExpandedContent ? (
<SidebarGroup className="session-nav" aria-label="聊天记录">
<div className="session-nav-header">
<SidebarGroupLabel className="sidebar-label" ref={historyHeadingRef} tabIndex={-1}>
{sessionControls.showingArchived ? "归档记录" : "聊天记录"}
</SidebarGroupLabel>
<button className="session-nav-toggle" type="button" onClick={sessionControls.onToggleArchivedView}>
{sessionControls.showingArchived ? "返回" : `归档 ${sessionControls.archivedCount}`}
</button>
</div>
<SidebarGroupContent>
{sessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{sessions.map((session, index) => (
<SidebarMenuItem key={session.id}>
<SidebarSessionRow
ref={index === 0 ? firstSessionRef : undefined}
session={session}
active={session.id === activeSessionId}
disabled={sessionControls.disabled}
menuOpen={sessionControls.menuSessionId === session.id}
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
onSelect={() => {
onSelectSession(session.id);
if (isMobile) setOpenMobile(false);
}}
onTogglePinned={() => sessionControls.onTogglePinned(session.id)}
onRename={() => sessionControls.onRename(session.id)}
onShare={() => sessionControls.onShare(session.id)}
onToggleArchived={() => sessionControls.onToggleArchived(session.id)}
onDelete={() => sessionControls.onDelete(session.id)}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</SidebarGroupContent>
</SidebarGroup>
<>
<SidebarGroup className="chart-nav" aria-label="星盘列表">
<div className="session-nav-header">
<SidebarGroupLabel className="sidebar-label">
<Users aria-hidden="true" />
</SidebarGroupLabel>
<button
className="session-nav-toggle"
type="button"
aria-label="添加星盘"
onClick={onAddChart}
>
<UserPlus aria-hidden="true" />
</button>
</div>
<SidebarGroupContent>
{charts.length === 0 ? <p className="sidebar-empty"></p> : (
<div className="chart-nav-list">
{charts.map((chart) => (
<button
className="chart-nav-chip"
key={chart.id}
type="button"
onClick={() => onSelectChart(chart.id)}
>
{chart.name}
</button>
))}
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="session-nav" aria-label="收藏对话">
<details className="sidebar-section" open>
<summary className="sidebar-section-summary">
<Star aria-hidden="true" />
<span></span>
<ChevronDown className="sidebar-section-chevron" aria-hidden="true" />
</summary>
<SidebarGroupContent>
{favoriteSessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{favoriteSessions.map((session, index) => renderSession(session, index))}
</SidebarMenu>
)}
</SidebarGroupContent>
</details>
</SidebarGroup>
<SidebarGroup className="session-nav" aria-label={sessionControls.showingArchived ? "归档记录" : "历史对话"}>
<details className="sidebar-section" open>
<summary className="sidebar-section-summary" ref={historyHeadingRef} tabIndex={-1}>
<Clock3 aria-hidden="true" />
<span>{sessionControls.showingArchived ? "归档记录" : "历史对话"}</span>
<ChevronDown className="sidebar-section-chevron" aria-hidden="true" />
</summary>
<div className="session-nav-header session-nav-header-inline">
<button className="session-nav-toggle" type="button" onClick={sessionControls.onToggleArchivedView}>
{sessionControls.showingArchived ? "返回" : `归档 ${sessionControls.archivedCount}`}
</button>
</div>
<SidebarGroupContent>
{historySessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
</SidebarMenu>
)}
</SidebarGroupContent>
</details>
</SidebarGroup>
</>
) : (
<SidebarMenu>
<SidebarMenuItem>
+7 -33
View File
@@ -1,11 +1,10 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef } from "react";
type GsapCore = typeof import("gsap")["gsap"];
@@ -31,30 +30,6 @@ function motionPreferred() {
if (motionPreferred()) prefetchOnIdle(loadGsap);
function MessageThinkingTrace({
text,
hasAnswer,
}: Readonly<{
text: string;
hasAnswer: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const open = userOpen ?? !hasAnswer;
if (!text.trim()) return null;
return (
<details
className="message-thinking"
open={open}
onToggle={(event) => {
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary></summary>
<div className="message-thinking-body">{text}</div>
</details>
);
}
export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
}
@@ -84,6 +59,8 @@ export function ChatMessageRow({
: message.state === "thinking" ? "working" : "composing";
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
const hasAnswer = Boolean(message.text.trim());
const showThinkingPanel = showActivity || Boolean(message.thinkingText?.trim());
useEntryEffect(() => {
const row = messageRow.current;
@@ -120,18 +97,15 @@ export function ChatMessageRow({
<div className="message-bubble">
{message.role === "assistant" ? (
<>
{showActivity && (
{showThinkingPanel && (
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
/>
)}
{message.thinkingText && (
<MessageThinkingTrace
text={message.thinkingText}
hasAnswer={Boolean(message.text.trim())}
thinkingText={message.thinkingText}
hasAnswer={hasAnswer}
showLive={showActivity}
/>
)}
{message.text && (
@@ -0,0 +1,28 @@
"use client";
export function ConversationFollowUps({
questions,
disabled = false,
onSelect,
}: Readonly<{
questions: readonly string[];
disabled?: boolean;
onSelect: (question: string) => void;
}>) {
if (questions.length === 0) return null;
return (
<div className="conversation-follow-ups" aria-label="可以继续问">
{questions.map((question) => (
<button
key={question}
className="conversation-follow-up"
type="button"
disabled={disabled}
onClick={() => onSelect(question)}
>
{question}
</button>
))}
</div>
);
}
@@ -6,9 +6,9 @@ import {
ArchiveRestore,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Share2,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { forwardRef } from "react";
@@ -18,7 +18,6 @@ import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
export type SidebarSession = {
readonly id: string;
readonly title: string;
readonly messageCount: number;
readonly pinned: boolean;
readonly archived: boolean;
};
@@ -88,10 +87,9 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
onClick={onSelect}
>
<span className="session-title">
{session.pinned ? <Pin aria-label="已置顶" /> : null}
{session.pinned ? <Star aria-label="已收藏" /> : null}
<span className="truncate">{session.title}</span>
</span>
{session.messageCount > 0 ? <small>{session.messageCount} </small> : null}
</SidebarMenuButton>
<Menu.Trigger
className="session-menu-trigger"
@@ -106,8 +104,8 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
<Menu.Positioner className="session-actions-positioner" side="bottom" align="end" sideOffset={4} collisionPadding={12}>
<Menu.Popup className="session-actions" aria-label={`${session.title} 操作`}>
<Menu.Item className="session-action-item" onClick={onTogglePinned}>
{session.pinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
<span>{session.pinned ? "取消置顶" : "置顶"}</span>
{session.pinned ? <StarOff aria-hidden="true" /> : <Star aria-hidden="true" />}
<span>{session.pinned ? "取消收藏" : "收藏"}</span>
</Menu.Item>
<Menu.Item className="session-action-item" onClick={onRename}><Pencil aria-hidden="true" /><span></span></Menu.Item>
<Menu.Item className="session-action-item" onClick={onShare}><Share2 aria-hidden="true" /><span></span></Menu.Item>