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