fix(web): clean up restored chat transcripts and optimize chat UI (#2605)

Filter raw tool messages from session history and avoid duplicate summaries for visible message-tool output. Preserve final assistant replies after tool delivery and add coverage for visible transcript counts.

Also refine the chat UI with collapsible reasoning blocks, send shortcut hints, command-style user messages, stable scroll gutters, and updated i18n strings.
This commit is contained in:
wenjie
2026-04-21 11:52:58 +08:00
committed by GitHub
parent 329e68e017
commit dcb4b67e00
11 changed files with 233 additions and 133 deletions
@@ -1,4 +1,10 @@
import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
import {
IconBrain,
IconCheck,
IconChevronDown,
IconCopy,
} from "@tabler/icons-react"
import { useAtom } from "jotai"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
@@ -10,6 +16,7 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
import { cn } from "@/lib/utils"
import { showThoughtsAtom } from "@/store/chat"
interface AssistantMessageProps {
content: string
@@ -24,6 +31,7 @@ export function AssistantMessage({
}: AssistantMessageProps) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
@@ -36,64 +44,76 @@ export function AssistantMessage({
return (
<div className="group flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<div className="flex items-center gap-2">
<span>PicoClaw</span>
{isThought && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300/80 bg-amber-100/80 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200">
<IconBrain className="size-3" />
<span>{t("chat.reasoningLabel")}</span>
</span>
)}
{formattedTimestamp && (
<>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
{!isThought && (
<div className="text-muted-foreground/60 flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<div className="flex items-center gap-2">
<span>PicoClaw</span>
{formattedTimestamp && (
<>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
</div>
</div>
</div>
)}
<div
className={cn(
"relative overflow-hidden rounded-xl border",
isThought
? "border-amber-200/90 bg-amber-50/70 text-amber-950 dark:border-amber-500/35 dark:bg-amber-500/10 dark:text-amber-100"
: "bg-card text-card-foreground",
? "border-border/30 bg-muted/20 text-muted-foreground dark:border-border/20 dark:bg-muted/10"
: "bg-card text-card-foreground border-border/60",
)}
>
<div
className={cn(
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 dark:prose-pre:bg-zinc-950 max-w-none [overflow-wrap:anywhere] break-words",
isThought
? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90"
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
{isThought && (
<div
className="text-muted-foreground/60 hover:text-muted-foreground/80 flex cursor-pointer items-center justify-between px-3 py-2 text-[12px] font-medium transition-colors select-none"
onClick={() => setIsExpanded(!isExpanded)}
>
{content}
</ReactMarkdown>
</div>
<Button
variant="ghost"
size="icon"
className={cn(
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
isThought
? "bg-amber-100/70 hover:bg-amber-200/80 dark:bg-amber-500/20 dark:hover:bg-amber-400/30"
: "bg-background/50 hover:bg-background/80",
)}
onClick={handleCopy}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="text-muted-foreground h-4 w-4" />
)}
</Button>
<div className="flex items-center gap-1.5">
<IconBrain className="size-3.5" />
<span>{t("chat.reasoningLabel")}</span>
</div>
<IconChevronDown
className={cn(
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
isExpanded ? "rotate-180" : "",
)}
/>
</div>
)}
{(!isThought || isExpanded) && (
<div
className={cn(
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:text-zinc-900 dark:prose-pre:bg-zinc-950 dark:prose-pre:text-zinc-100 max-w-none [overflow-wrap:anywhere] break-words",
isThought
? "prose-p:my-1.5 px-3 pt-0 pb-3 text-[13px] leading-relaxed opacity-70"
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
>
{content}
</ReactMarkdown>
</div>
)}
{!isThought && (
<Button
variant="ghost"
size="icon"
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="text-muted-foreground h-4 w-4" />
)}
</Button>
)}
</div>
</div>
)
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat"
@@ -57,8 +62,8 @@ export function ChatComposer({
}
return (
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
{attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => (
@@ -93,17 +98,12 @@ export function ChatComposer({
disabled={!canInput}
title={disabledMessage || undefined}
className={cn(
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
)}
minRows={1}
maxRows={8}
/>
{!canInput && disabledMessage && (
<div className="text-muted-foreground px-3 py-1 text-xs">
{disabledMessage}
</div>
)}
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">
@@ -122,15 +122,28 @@ export function ChatComposer({
</div>
{canInput ? (
<Button
type="button"
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
disabled={!canSend}
>
<IconArrowUp className="size-4" />
</Button>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<span tabIndex={!canSend ? 0 : undefined}>
<Button
type="button"
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.sendMessage")}
>
<IconArrowUp className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
arrowClassName="bg-muted fill-muted"
>
{t("chat.sendHint")}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
@@ -153,7 +153,7 @@ export function ChatPage() {
})
const syncScrollState = (element: HTMLDivElement) => {
const { scrollTop, scrollHeight, clientHeight } = element
const { clientHeight, scrollHeight, scrollTop } = element
setHasScrolled(scrollTop > 0)
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
}
@@ -294,7 +294,7 @@ export function ChatPage() {
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48"
>
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && (
@@ -21,10 +21,7 @@ export function TypingIndicator() {
return (
<div className="flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center gap-2 px-1 text-xs opacity-70">
<span>PicoClaw</span>
</div>
<div className="bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
<div className="bg-card border-border/50 inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
<div className="flex items-center gap-1.5">
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />
@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat"
interface UserMessageProps {
@@ -7,6 +8,7 @@ interface UserMessageProps {
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
const hasText = content.trim().length > 0
const isCommand = content.trim().startsWith("/")
const imageAttachments = attachments.filter(
(attachment) => attachment.type === "image",
)
@@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
)}
{hasText && (
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed wrap-break-word whitespace-pre-wrap text-white shadow-sm">
{content}
<div
className={cn(
"max-w-[70%] wrap-break-word whitespace-pre-wrap",
isCommand
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
)}
>
{isCommand ? (
<div className="flex items-start gap-2.5">
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
</span>
<span className="mt-[1px]">{content}</span>
</div>
) : (
content
)}
</div>
)}
</div>
+10 -2
View File
@@ -30,10 +30,13 @@ function TooltipTrigger({
function TooltipContent({
className,
arrowClassName,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowClassName?: string
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
@@ -46,7 +49,12 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
<TooltipPrimitive.Arrow
className={cn(
"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground",
arrowClassName
)}
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)