mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user