- {queries.length > 0 ? (
-
- {queries.map((query) => (
-
- {query}
-
- ))}
+
+ {hasDetails && expanded ? (
+
+
+
+ {queries.map((query) => (
+
+
+
+ {query}
+
+ ))}
+
+ {visibleSources.map((source) => (
+
+
+
+ {source.title || getSourceHost(source.url)}
+
+
+ {getSourceHost(source.url)}
+
+
+ ))}
+
+ {failedCount > 0 ? (
+
+ {locale === "en-US"
+ ? `${failedCount} ${failedCount === 1 ? "search" : "searches"} failed`
+ : `${failedCount} 次搜索失败`}
+
) : null}
- {sources.length > 0 ? (
-
-
- {t("chat.search.sources")}
-
-
-
+
+ {hiddenSourceCount > 0 ? (
+
setShowAll(true)}
+ type="button"
+ >
+ {locale === "en-US"
+ ? `Show ${hiddenSourceCount} more sources`
+ : `查看其余 ${hiddenSourceCount} 个来源`}
+
) : null}
- )}
-
+
+
) : null}
-
+
);
}
diff --git a/crates/agent-ui/src/components/chat/LazyCollapse.tsx b/crates/agent-ui/src/components/chat/LazyCollapse.tsx
index 5ea758f77..ac4fe3560 100644
--- a/crates/agent-ui/src/components/chat/LazyCollapse.tsx
+++ b/crates/agent-ui/src/components/chat/LazyCollapse.tsx
@@ -1,4 +1,4 @@
-import { type ReactNode, useState } from "react";
+import { type ReactNode, useRef } from "react";
import { cn } from "../../lib/shared/utils";
// 内容首次展开时才挂载;运行中的内容可在折叠后保留状态,结束后则立即释放。
@@ -9,11 +9,9 @@ export function LazyCollapse(props: {
children: () => ReactNode;
}) {
const { open, retainWhileClosed = false, className, children } = props;
- const [mounted, setMounted] = useState(open);
- if (open && !mounted) {
- setMounted(true);
- }
- const shouldRenderBody = open || (mounted && retainWhileClosed);
+ const hasMountedRef = useRef(open);
+ if (open) hasMountedRef.current = true;
+ const shouldRenderBody = open || (hasMountedRef.current && retainWhileClosed);
return (
{
const rect = inputSurface.getBoundingClientRect();
popup.style.left = `${rect.left}px`;
- popup.style.bottom = `${Math.max(8, window.innerHeight - rect.top + 8)}px`;
+ popup.style.bottom = `${Math.max(
+ MENTION_POPUP_VIEWPORT_MARGIN,
+ window.innerHeight - rect.top + MENTION_POPUP_GAP,
+ )}px`;
popup.style.width = `${rect.width}px`;
+ const list = listRef.current;
+ if (list) {
+ list.style.maxHeight = `${resolveMentionPopupListMaxHeight(rect.top)}px`;
+ }
};
update();
@@ -95,7 +119,7 @@ export function Popup({
{isLoading && (
Indexing files...
@@ -124,7 +148,7 @@ export function Popup({
// visual 34px row keeps the 4px gap while clicks in the gap
// still land on a row instead of a dead strip. shrink-0 stops
// the max-h flex column from compressing rows before it scrolls.
- "mention-popup-item group flex h-[38px] shrink-0 cursor-pointer items-center gap-3 rounded-lg border-y-2 border-transparent bg-clip-padding px-3 text-xs leading-5 transition-colors",
+ "mention-popup-item group flex h-[38px] shrink-0 cursor-pointer items-center gap-3 rounded-lg border-y-2 border-transparent bg-clip-padding px-3 text-left text-xs leading-5 transition-colors",
i === highlightIndex
? "bg-foreground/[0.07] text-foreground"
: "text-foreground/85 hover:bg-foreground/[0.05] dark:text-foreground/90",
diff --git a/crates/agent-ui/src/components/chat/TaskProgressBar.tsx b/crates/agent-ui/src/components/chat/TaskProgressBar.tsx
index 78ddf1345..f9d76f17a 100644
--- a/crates/agent-ui/src/components/chat/TaskProgressBar.tsx
+++ b/crates/agent-ui/src/components/chat/TaskProgressBar.tsx
@@ -18,6 +18,8 @@ export function createTaskProgressIndicatorLabels(
pending: translate("chat.taskProgress.pending"),
paused: translate("chat.taskProgress.paused"),
completed: translate("chat.taskProgress.completed"),
+ taskPaused: translate("chat.taskProgress.taskPaused"),
+ taskCompleted: translate("chat.taskProgress.taskCompleted"),
};
}
diff --git a/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx b/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx
index 6be8be6c1..a26ad52ca 100644
--- a/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx
+++ b/crates/agent-ui/src/components/chat/TaskProgressIndicator.tsx
@@ -1,18 +1,9 @@
-import { CheckCircle2, Circle, Loader2 } from "@liveagent/ui/components/IconSet";
-import {
- type FocusEvent as ReactFocusEvent,
- type KeyboardEvent as ReactKeyboardEvent,
- type PointerEvent as ReactPointerEvent,
- useEffect,
- useId,
- useRef,
- useState,
-} from "react";
+import { Check, ChevronDown } from "@liveagent/ui/components/IconSet";
+import { useId, useState } from "react";
+import type { TaskItem } from "../../contracts/task";
import type { TaskProgressSnapshot } from "../../lib/chat/taskProgress";
import { cn } from "../../lib/shared/utils";
-const POINTER_CLOSE_DELAY_MS = 140;
-
export type TaskProgressIndicatorLabels = {
title: string;
step: string;
@@ -21,8 +12,87 @@ export type TaskProgressIndicatorLabels = {
pending: string;
paused: string;
completed: string;
+ taskPaused: string;
+ taskCompleted: string;
};
+type DisplayState = "running" | "pending" | "paused" | "completed";
+
+function TaskStepRing({
+ active,
+ paused,
+ step,
+}: {
+ active: boolean;
+ paused: boolean;
+ step: number;
+}) {
+ const size = 22;
+ const strokeWidth = 2;
+ const radius = (size - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const accentClassName = paused
+ ? "stroke-amber-600 dark:stroke-amber-300"
+ : "stroke-[hsl(var(--tool-list-accent))]";
+
+ return (
+
+
+
+ {active ? (
+
+ ) : null}
+
+
+ {step}
+
+
+ );
+}
+
+function CompletedBadge() {
+ return (
+
+
+
+ );
+}
+
+function getTaskDisplayState(task: TaskItem, isConversationRunning: boolean): DisplayState {
+ if (task.status === "completed") return "completed";
+ if (task.status === "pending") return "pending";
+ if (!isConversationRunning) return "paused";
+ return "running";
+}
+
export function TaskProgressIndicator({
snapshot,
isConversationRunning,
@@ -32,216 +102,214 @@ export function TaskProgressIndicator({
isConversationRunning: boolean;
labels: TaskProgressIndicatorLabels;
}) {
- const panelId = useId();
- const rootRef = useRef
(null);
- const pointerCloseTimerRef = useRef(null);
- const focusCloseTimerRef = useRef(null);
- const lastPointerTypeRef = useRef(null);
- const [pointerOpen, setPointerOpen] = useState(false);
- const [focusOpen, setFocusOpen] = useState(false);
- const [touchOpen, setTouchOpen] = useState(false);
- const isOpen = pointerOpen || focusOpen || touchOpen;
- const displayState =
+ const instanceId = useId();
+ const [expansion, setExpansion] = useState<{
+ runId: string;
+ tasks: Record;
+ }>(() => ({ runId: snapshot.runId, tasks: {} }));
+ const [panelExpansionOverride, setPanelExpansionOverride] = useState<{
+ runId: string;
+ open: boolean;
+ } | null>(null);
+ const expansionOverrides = expansion.runId === snapshot.runId ? expansion.tasks : {};
+ const displayState: DisplayState =
snapshot.state === "completed"
? "completed"
- : isConversationRunning
- ? snapshot.state === "in_progress"
+ : !isConversationRunning
+ ? "paused"
+ : snapshot.state === "in_progress"
? "running"
- : "pending"
- : "paused";
- const stateText = labels[displayState];
- const summaryText = [labels.title, labels.step, labels.completedCount, stateText].join(" · ");
-
- useEffect(
- () => () => {
- if (pointerCloseTimerRef.current !== null) {
- window.clearTimeout(pointerCloseTimerRef.current);
- }
- if (focusCloseTimerRef.current !== null) {
- window.clearTimeout(focusCloseTimerRef.current);
- }
- },
- [],
+ : "pending";
+ const summaryText = [labels.title, labels.step, labels.completedCount, labels[displayState]].join(
+ " · ",
);
-
- useEffect(() => {
- if (!touchOpen) return;
- const closeOnOutsidePointer = (event: PointerEvent) => {
- if (!rootRef.current?.contains(event.target as Node)) setTouchOpen(false);
- };
- document.addEventListener("pointerdown", closeOnOutsidePointer, true);
- return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
- }, [touchOpen]);
-
- const clearCloseTimer = (timerRef: typeof pointerCloseTimerRef) => {
- if (timerRef.current === null) return;
- window.clearTimeout(timerRef.current);
- timerRef.current = null;
- };
- const handlePointerEnter = (event: ReactPointerEvent) => {
- if (event.pointerType === "touch") return;
- clearCloseTimer(pointerCloseTimerRef);
- setPointerOpen(true);
- };
- const handlePointerLeave = (event: ReactPointerEvent) => {
- if (event.pointerType === "touch") return;
- clearCloseTimer(pointerCloseTimerRef);
- pointerCloseTimerRef.current = window.setTimeout(() => {
- pointerCloseTimerRef.current = null;
- setPointerOpen(false);
- }, POINTER_CLOSE_DELAY_MS);
- };
- const handleFocusCapture = (event: ReactFocusEvent) => {
- clearCloseTimer(focusCloseTimerRef);
- setFocusOpen(event.target instanceof HTMLElement && event.target.matches(":focus-visible"));
- };
- const handleBlurCapture = (event: ReactFocusEvent) => {
- if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
- clearCloseTimer(focusCloseTimerRef);
- focusCloseTimerRef.current = window.setTimeout(() => {
- focusCloseTimerRef.current = null;
- setFocusOpen(false);
- }, POINTER_CLOSE_DELAY_MS);
- };
- const handleKeyDown = (event: ReactKeyboardEvent) => {
- lastPointerTypeRef.current = null;
- if (event.key !== "Escape") return;
- clearCloseTimer(pointerCloseTimerRef);
- clearCloseTimer(focusCloseTimerRef);
- setPointerOpen(false);
- setFocusOpen(false);
- setTouchOpen(false);
- };
- const statusClassName =
- displayState === "completed"
- ? "text-[hsl(var(--chat-success))]"
- : displayState === "running"
- ? "text-[hsl(var(--tool-list-accent))]"
- : displayState === "paused"
- ? "text-amber-600 dark:text-amber-300"
- : "text-muted-foreground";
+ const panelOpen =
+ panelExpansionOverride?.runId === snapshot.runId
+ ? panelExpansionOverride.open
+ : snapshot.state !== "completed";
+ const panelId = `${instanceId}-tasks`;
return (
+ {labels.title}
+
+
{
- lastPointerTypeRef.current = event.pointerType;
- }}
+ className="mb-2 flex h-10 w-full items-center gap-2 rounded-[20px] bg-background/92 px-3 text-left text-[12px] text-muted-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.07),0_1px_2px_-1px_rgba(0,0,0,0.08),0_8px_24px_-16px_rgba(15,23,42,0.38)] outline-none backdrop-blur-xl backdrop-saturate-150 transition-[background-color,box-shadow] hover:bg-background focus-visible:ring-2 focus-visible:ring-ring/55 dark:shadow-[0_0_0_1px_rgba(255,255,255,0.12),0_8px_24px_-16px_rgba(0,0,0,0.72)]"
+ data-task-progress-toggle=""
onClick={() => {
- if (lastPointerTypeRef.current === "touch") setTouchOpen((open) => !open);
+ setPanelExpansionOverride({ runId: snapshot.runId, open: !panelOpen });
}}
- className="flex h-10 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-background/88 px-3.5 text-[13px] text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_1px_2px_-1px_rgba(0,0,0,0.08),0_8px_24px_-14px_rgba(15,23,42,0.38)] backdrop-blur-xl backdrop-saturate-150 transition-[scale,box-shadow,background-color] duration-150 ease-out hover:bg-background/95 hover:shadow-[0_0_0_1px_rgba(0,0,0,0.10),0_2px_4px_-1px_rgba(0,0,0,0.10),0_10px_28px_-14px_rgba(15,23,42,0.46)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/55 active:scale-[0.96] motion-reduce:transition-none motion-reduce:active:scale-100 dark:shadow-[0_0_0_1px_rgba(255,255,255,0.12),0_8px_24px_-14px_rgba(0,0,0,0.72)]"
+ type="button"
>
- {displayState === "completed" ? (
-
- ) : displayState === "running" ? (
-
- ) : displayState === "paused" ? (
-
- ) : (
-
+ aria-hidden="true"
+ className={cn(
+ "size-2 shrink-0 rounded-full",
+ displayState === "completed" && "bg-[hsl(var(--chat-success))]",
+ displayState === "running" &&
+ "animate-pulse bg-[hsl(var(--tool-list-accent))] motion-reduce:animate-none",
+ displayState === "paused" && "bg-amber-500",
+ displayState === "pending" && "bg-muted-foreground/50",
)}
+ />
+
+ {labels.title}
- {labels.step}
-
- ·
-
-
- {labels.completedCount}
+ {labels.completedCount}
+
+ {labels[displayState]}
+
-
-
-
-
-
{labels.title}
-
- {labels.step} · {labels.completedCount}
-
-
-
- {stateText}
-
-
-
- {snapshot.tasks.map((task) => {
- return (
-
+ {snapshot.tasks.map((task, index) => {
+ const taskDisplayState = getTaskDisplayState(task, isConversationRunning);
+ const isOpen = expansionOverrides[task.id] ?? task.status === "in_progress";
+ const detailId = `${instanceId}-task-${task.id}`;
+ const statusText =
+ taskDisplayState === "completed"
+ ? labels.taskCompleted
+ : taskDisplayState === "paused"
+ ? labels.taskPaused
+ : labels[taskDisplayState];
+ const isActive = task.status === "in_progress";
+
+ return (
+
+
{
+ setExpansion((current) => ({
+ runId: snapshot.runId,
+ tasks: {
+ ...(current.runId === snapshot.runId ? current.tasks : {}),
+ [task.id]: !isOpen,
+ },
+ }));
+ }}
+ type="button"
>
-
+
{task.status === "completed" ? (
-
- ) : task.status === "in_progress" ? (
-
+
) : (
-
+
)}
{task.subject}
-
- );
- })}
-
+
+ {statusText}
+
+
+
+
+
+
+
+
+
+
+
+
+ {task.description}
+
+
+ {index + 1}/{snapshot.totalCount}
+
+
+
+
+
+
+ );
+ })}
-
+
);
}
diff --git a/crates/agent-ui/src/components/chat/ThinkingActivity.tsx b/crates/agent-ui/src/components/chat/ThinkingActivity.tsx
index 5572d227e..82eba8bc1 100644
--- a/crates/agent-ui/src/components/chat/ThinkingActivity.tsx
+++ b/crates/agent-ui/src/components/chat/ThinkingActivity.tsx
@@ -1,74 +1,36 @@
-import { ChevronRight, Lightbulb } from "@liveagent/ui/components/IconSet";
+import { Brain } from "@liveagent/ui/components/IconSet";
import { useLocale } from "@liveagent/ui/i18n/index";
-import { cn } from "@liveagent/ui/lib/shared/utils";
import { useEffect, useRef, useState } from "react";
-import type { ChatFileLink } from "../../lib/chat/chatFileLinks";
-import { Markdown } from "../Markdown";
-import { AssistantStatus } from "./AssistantStatus";
-import { LazyCollapse } from "./LazyCollapse";
-export function ThinkingActivity(props: {
- text: string;
- open?: boolean;
- isRunning?: boolean;
- renderMode: "streaming" | "static";
- workdir?: string;
- onOpenFileLink?: (link: ChatFileLink) => void;
-}) {
- const { text, open, isRunning = false, renderMode, workdir, onOpenFileLink } = props;
+export function ThinkingActivity() {
const { t } = useLocale();
- const [isOpen, setIsOpen] = useState(typeof open === "boolean" ? open : false);
- const userInteractedRef = useRef(false);
- const hasText = /\S/.test(text);
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ const startedAtRef = useRef(performance.now());
useEffect(() => {
- if (!userInteractedRef.current && typeof open === "boolean") {
- setIsOpen(open);
- }
- }, [open]);
+ const updateElapsed = () => {
+ setElapsedSeconds(
+ Math.max(0, Math.floor((performance.now() - startedAtRef.current) / 1_000)),
+ );
+ };
+ const timer = window.setInterval(updateElapsed, 1_000);
+ return () => window.clearInterval(timer);
+ }, []);
- if (!hasText) return null;
+ const label =
+ elapsedSeconds > 0
+ ? `${t("chat.thinking")} ${elapsedSeconds} ${t("chat.time.seconds")}`
+ : t("chat.thinking");
return (
-
-
{
- userInteractedRef.current = true;
- setIsOpen((previous) => !previous);
- }}
- className="thinking-block-toggle flex w-full cursor-pointer select-none items-center gap-2 py-1.5 text-left text-[calc(13px*var(--zone-font-scale,1))] font-normal text-muted-foreground/80 hover:text-foreground"
- >
- {isRunning ? (
- {t("chat.thinking")}
- ) : (
- <>
-
- {t("chat.thinkingProcess")}
- >
- )}
-
-
-
- {() => (
-
-
-
- )}
-
+
+
+ {label}
);
}
diff --git a/crates/agent-ui/src/components/chat/TranscriptMessageActions.tsx b/crates/agent-ui/src/components/chat/TranscriptMessageActions.tsx
index 6a7ff820d..fb6ff13f9 100644
--- a/crates/agent-ui/src/components/chat/TranscriptMessageActions.tsx
+++ b/crates/agent-ui/src/components/chat/TranscriptMessageActions.tsx
@@ -11,24 +11,14 @@ import { useLocale } from "../../i18n/index";
import { useCheckpointRewindAction } from "../../lib/chat/checkpointRewind";
import { cn } from "../../lib/shared/utils";
import { ConfirmActionPopover } from "../ui/confirm-action-popover";
+import { type UsageDetailEntry, UsageInfoPopover } from "./UsagePanel";
-export function formatTranscriptMessageTimestamp(timestamp: number | undefined, now = new Date()) {
+export function formatTranscriptMessageTimestamp(timestamp: number | undefined) {
if (!timestamp || !Number.isFinite(timestamp)) return "";
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return "";
const pad = (value: number) => String(value).padStart(2, "0");
- const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
- if (
- date.getFullYear() === now.getFullYear() &&
- date.getMonth() === now.getMonth() &&
- date.getDate() === now.getDate()
- ) {
- return time;
- }
- const monthDay = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
- return date.getFullYear() === now.getFullYear()
- ? `${monthDay} ${time}`
- : `${date.getFullYear()}-${monthDay} ${time}`;
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
type SharedActionProps = {
@@ -113,7 +103,12 @@ export function TranscriptUserMessageActions(
) : null}
) : null}
-
+
{formatTranscriptMessageTimestamp(timestamp)}
@@ -123,6 +118,8 @@ export function TranscriptUserMessageActions(
export function TranscriptAssistantMessageActions(
props: SharedActionProps & {
timestamp?: number;
+ usageEntries?: readonly UsageDetailEntry[];
+ usageContextWindow?: number;
retryDisabled: boolean;
retryTitle: string;
onRetry: () => void;
@@ -139,6 +136,8 @@ export function TranscriptAssistantMessageActions(
onCopy,
alwaysShowActions = false,
timestamp,
+ usageEntries,
+ usageContextWindow,
retryDisabled,
retryTitle,
onRetry,
@@ -152,23 +151,21 @@ export function TranscriptAssistantMessageActions(
const actions = (