feat(goal): /goal command & long-running tasks (long_task)
* feat(long-task): add LongTaskTool for multi-step agent tasks
Implements a meta-ReAct loop where long-running tasks are broken into
sequential subagent steps, each starting fresh with the original goal
and progress from the previous step. This prevents context drift when
agents work on complex, multi-step tasks.
- Extract build_tool_registry() from SubagentManager for reuse
- Add run_step() for synchronous subagent execution (no bus announcement)
- Add HandoffTool and CompleteTool as signal mechanisms via shared dict
- Add LongTaskTool orchestrator with simplified prompt (8 iterations/step)
- Register LongTaskTool in main agent loop
- Add _extract_handoff_from_messages fallback for robustness
* fix(long-task): add debug logging for step-level observability
* feat(long-task): major overhaul with structured handoffs, validation, and observability
- Structured HandoffState: HandoffTool now accepts files_created,
files_modified, next_step_hint, and verification fields instead of
a plain string. Progress is passed between steps as structured data.
- Completion validation round: After complete() is called, a dedicated
validator step runs to verify the claim against the original goal.
If validation fails, the task continues rather than returning
a false completion.
- Dynamic prompt system: 3 Jinja2 templates (step_start, step_middle,
step_final) selected based on step number. Final steps get tighter
budget and stronger "wrap up" guidance.
- Automatic file change tracking: Extracts write_file/edit_file events
from tool_events and injects them into the next step's context if
the subagent forgot to report them explicitly.
- Budget tracking & adaptive strategy: Cumulative token usage is tracked
across steps. Per-step tool budget drops from 8 to 4 in the last
two steps to force handoff/completion.
- Crash retry with graceful degradation: A step that crashes is retried
once. Persistent crashes terminate the task and return partial progress.
- Full observability hooks for future WebUI integration:
- set_hooks() with on_step_start, on_step_complete, on_handoff,
on_validation_started, on_validation_passed, on_validation_failed,
on_task_complete, on_task_error, and catch-all on_event.
- Readable state properties: current_step, total_steps, status,
last_handoff, cumulative_usage, goal.
- inject_correction() allows external code to send user corrections
that are injected into the next step's prompt.
- run_step() accepts optional max_iterations for dynamic budget control.
All 27 long-task tests and 11 subagent tests pass.
* test(long-task): add boundary tests and fix race conditions
- Add 7 edge-case tests: validation crash resilience, hook exception safety, mid-run correction injection, FIFO correction ordering, explicit file changes overriding auto-detection, final budget for max_steps=1, and dynamic budget switching boundaries
- Fix assertion in test_long_task_completes_after_multiple_handoffs to match exact prompt format
- Remove asyncio timing hack from test_state_exposure
- Add asyncio.sleep(0) yield in test_inject_correction_during_execution to prevent race between signal injection and step continuation
- All 34 tests passing
* fix(long-task): address code review findings
- Declare _scopes = {"core"} explicitly to prevent recursive nesting in subagent scope
- Document fragile coupling in _extract_file_changes: path extraction depends on
write_file/edit_file detail format; add debug log for unexpected formats
- Align final-template threshold (max_steps - 2) with budget switch threshold
- Eliminate hasattr(self, "_state") in _reset_state by initializing in __init__
* fix(long-task): honor final signal and file tracking
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(long-task): improve prompt structure and agent contract
- Expand LongTaskTool.description to instruct parent agent on goal
construction, return value semantics, and how to handle results.
- Expand CompleteTool.description to emphasize that the summary IS the
final answer returned to the parent agent.
- Prefix validated return value with an explicit "final answer" directive
to stop parent agent from re-running work.
- Redesign step_start.md: Step 1 is now explicitly for exploration,
planning, and skeleton-building. complete() is discouraged.
- Remove bulky payload debug logging from _emit(); add targeted
info/warning/error logs at key state transitions instead.
- Add signal_type to HandoffState for cleaner signal detection.
* test(long-task): expect wrapped completion message after validation
Align assertions with LongTaskTool final return shape on main.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(webui): turn timing strip, latency, and session-switch restore
- Agent loop: publish goal_status run/idle for WebSocket turns; attach
wall-clock latency_ms on turn_end and persisted assistant metadata.
- WebSocket channel: forward goal_status and latency fields to clients.
- NanobotClient: track goal_status started_at per chat without requiring
onChat; useNanobotStream restores run strip when returning to a chat.
- Thread UI: composer/shell viewport hooks for run duration and latency;
format helpers and i18n strings.
- MessageBubble: drop trailing StreamCursor (layout artifact vs block markdown).
- Builtin / tests: model command coverage, websocket and loop tests.
Covers multi-session UX and round-trip timing visibility for the WebUI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: keep message-tool file attachments after canonical history hydrate
- MessageTool records per-turn media paths delivered to the active chat.
- nanobot.utils.session_attachments stages out-of-media-root files and
merges into the last assistant message before save (loop stays a thin call).
- WebUI MediaCell: use a signed URL as a real download link when present.
Fixes attachments flashing then vanishing on turn_end when paths lived
outside get_media_dir (e.g. workspace files).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(webui): agent activity cluster, stable keys, LTR sheen labels
- Group reasoning and tool traces in AgentActivityCluster with i18n summaries
- Stabilize React list keys for activity clusters (first message id anchor)
- Replace background-clip shimmer with overlay sheen for streaming labels
- ThreadMessages/MessageList integration and locale strings
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webui): render assistant reasoning with Markdown + deferred stream
- Use MarkdownText for ReasoningBubble body (same GFM/KaTeX path as replies)
- Apply muted/italic prose tokens so thinking stays visually subordinate
- useDeferredValue while reasoningStreaming to ease parser work during deltas
- Preload markdown chunk when trace opens; add regression test with preloaded renderer
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webui): default-collapse agent activity cluster while Working
Outer fold no longer auto-expands during isTurnStreaming; user opens to see traces.
Header sheen and live summary unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(long_task): cumulative run history, file union, and prompt tuning
Inject cross-step summaries and merged file paths into middle/final step
templates so chains do not lose early context. Strip the last run-history
block when it duplicates Previous Progress to save tokens. Add optional
cumulative_prompt_max_chars and cumulative_step_body_max_chars parameters
with clamped defaults.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webui): session switch keeps in-flight thread and replays buffered WS
Save the prior chat message list to the per-chat cache in a layout effect
when chatId changes (before stale writes could corrupt another chat).
Skip one post-switch layout cache tick so we do not snapshot the wrong tab.
Buffer inbound events per chat_id when no onChat subscriber is registered
(e.g. user focused another session) and drain on resubscribe up to a cap,
so streaming deltas are not lost while off-tab.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webui): snap thread scroll to bottom on session open (no smooth glide)
Use scroll-behavior auto on the viewport, instant programmatic scroll when
following new messages and on scrollToBottomSignal. Keep smooth only for
the explicit scroll-to-bottom button.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webui): respect manual scroll-up after opening a session
Track when the user leaves the bottom with a ref and skip ResizeObserver
and deferred bottom snaps until they return or the conversation is reset.
Remove the time-based force-bottom window that overrode atBottom.
Multi-frame scrollToBottom honours the same guard unless force (scroll button).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Publish long_task UI snapshots on outbound metadata
- Add OUTBOUND_META_AGENT_UI (_agent_ui) for channel-agnostic structured state
- LongTaskTool publishes {kind: long_task, data: snapshot} on the bus with _progress
- WebSocket send forwards metadata as agent_ui for WebUI clients
- Tests for bus payload, WS frame, and progress assertions
- Fix loop progress tests: ignore _goal_status in streaming final filter and
avoid brittle outbound[-1] ordering after goal status idle messages
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: WebUI long_task activity card and resilient history merge
Add optional ui_summary to the long_task tool for one-line UI labels. Stream
long_task agent_ui into a dedicated message row with timeline, markdown peek,
and a right sheet for details. Merge canonical history after turn_end while
re-inserting long_task rows before the final assistant reply. Collapse
duplicate task_start/step_start steps in the timeline and extend i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: align long_task with thread_goal and drop orchestrator UI
- Persist sustained objectives via session metadata (long_task / complete_goal); no subagent wiring or tool-driven agent_ui payloads.\n- Remove WebUI long-task activity UI, types, and translations; history merge preserves trace replay only, with legacy long_task rows normalized to traces.\n- Drop long_task prompt templates and get_long_task_run_dir; add webui thread disk helper for gateway persistence tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(agent): thread goal runtime context, tools, and skill
- Add thread_goal_state helper and mirror active objectives into Runtime Context
- Wire loop/context/memory/events as needed for goal metadata in turns
- Expand long_task / complete_goal semantics (pivot/cancel/honest recap)
- Add always-on thread-goal SKILL.md; align /goal command prompt
- Tests for context builder and thread goal state
- Remove unused webui ChatPane component
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(thread-goal): add websocket snapshot helper and publish goal updates from long_task
Introduce thread_goal_ws_blob for bounded JSON snapshots, attach snapshots to
websocket turn_end metadata in AgentLoop, and let long_task fan-out dedicated
thread_goal frames on the websocket channel after persisting session metadata.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(channels): websocket thread_goal frames, turn_end replay, and session API scrub for subagent inject
Emit thread_goal events and optional thread_goal on turn_end; scrub persisted
subagent announce blobs on GET /api/sessions/.../messages and shorten session
list previews so WebUI does not surface full Task/Summarize scaffolding.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(webui): merge ephemeral traces per user turn when reconciling canonical history
Preserve disk/live trace rows inside the matching user–assistant segment instead
of stacking every trace before the final assistant reply (fixes inflated tool
counts after refresh or session switch).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(webui): show assistant reply copy only on the last slice before the next user turn
Avoid duplicate copy affordances on intermediate assistant bubbles that precede
more agent activity in the same turn (tools or further assistant text).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(webui): thread_goal stream plumbing, composer goal strip, sky glow, and client-side subagent scrub projection
Track thread_goal and turn_goal snapshots in NanobotClient, hydrate React state
from thread_goal frames and turn_end, surface objective/elapsed in the composer,
add breathing sky halo CSS while goals are active, mirror server scrub logic on
history hydration and webui_thread snapshots, and extend tests/client mocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(channels): add Slack Socket Mode connect timeout with actionable timeout errors
Abort hung websockets.connect handshakes after a bounded wait, log REST-vs-WSS
guidance, surface RuntimeError to channel startup, and log successful WSS setup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* webui: expand thread goal in composer bottom sheet
Add ChevronUp control on the run/goal strip that opens a bottom Sheet
with full ui_summary and objective. Inline preview logic in RunElapsedStrip,
add i18n strings across locales, and a composer unit test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webui): widen dedupeToolCallsForUi input for session API typing
fetchSessionMessages types tool_calls as unknown; accept unknown so tsc
build passes when passing message.tool_calls through.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(agent): extract WebSocket turn run status to webui_turn_helpers
* refactor(skills): rename thread-goal to long-task and document idempotent goals
* feat(skills): rename sustained-goal skill to long-goal and tighten long_task guidance
* chore: remove unused subagent/context/router helpers
* feat(session): rename sustained goal to goal_state and align WS/WebUI
- Move helpers from agent/thread_goal_state to session/goal_state:
GOAL_STATE_KEY, goal_state_runtime_lines, goal_state_ws_blob, parse_goal_state.
- Session metadata now uses "goal_state"; still read legacy "thread_goal";
long_task writes drop the legacy key after save.
- WebSocket: event/field goal_state, _goal_state_sync; turn_end carries goal_state;
accept legacy _thread_goal_sync/thread_goal inbound metadata for dispatch.
- WebUI: GoalStateWsPayload, goalState hook/client props, i18n keys goalState*.
- Runtime Context copy uses "Goal (active):" instead of "Thread goal".
* feat(agent): stream Anthropic thinking deltas and fix stream idle timeout
* refactor(webui): transcript jsonl as sole timeline source
* fix(agent): reject mismatched WS message chat_id and stream reasoning deltas
* feat(webui): hydrate sustained goal and run timer after websocket subscribe
* chore(webui,websocket): remove unused fetch helpers and legacy thread_goal WS paths
* Raise default max_tokens and context window in agent schema.
Align AgentDefaults and ModelPresetConfig with typical Claude-scale usage
(32k completion budget, 256k context window) and update migration tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(gateway): bootstrap prefers in-memory model; clarify websocket naming
* fix(websocket): websocket _handle_message passes is_dm; refresh /status test expectations
---------
Co-authored-by: chengyongru <2755839590@qq.com>
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
chengyongru
chengyongru
parent
2d17a095dc
commit
1c2ea1aad2
@@ -7,7 +7,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
@@ -20,12 +19,6 @@ interface ChatListProps {
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
function titleFor(s: ChatSummary, fallbackTitle: string): string {
|
||||
const p = (s.title || s.preview)?.trim();
|
||||
if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p;
|
||||
return fallbackTitle;
|
||||
}
|
||||
|
||||
export function ChatList({
|
||||
sessions,
|
||||
activeKey,
|
||||
@@ -58,8 +51,8 @@ export function ChatList({
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-3 px-2 py-1.5">
|
||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||
<div className="min-w-0 space-y-3 px-2 py-1.5">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label} aria-label={group.label}>
|
||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
||||
@@ -68,15 +61,16 @@ export function ChatList({
|
||||
<ul className="space-y-0.5">
|
||||
{group.sessions.map((s) => {
|
||||
const active = s.key === activeKey;
|
||||
const title = titleFor(
|
||||
s,
|
||||
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
|
||||
);
|
||||
const fallbackTitle = t("chat.fallbackTitle", {
|
||||
id: s.chatId.slice(0, 6),
|
||||
});
|
||||
const rawLabel = (s.title || s.preview)?.trim();
|
||||
const title = rawLabel || fallbackTitle;
|
||||
return (
|
||||
<li key={s.key}>
|
||||
<li key={s.key} className="min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex min-h-8 items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
|
||||
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||
@@ -85,14 +79,15 @@ export function ChatList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
className="min-w-0 flex-1 py-1.5 text-left"
|
||||
title={rawLabel || fallbackTitle}
|
||||
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
|
||||
>
|
||||
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
||||
</button>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity",
|
||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
||||
"focus-visible:opacity-100",
|
||||
active && "opacity-100",
|
||||
@@ -124,7 +119,7 @@ export function ChatList({
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Composer } from "@/components/Composer";
|
||||
import { MessageList } from "@/components/MessageList";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
interface ChatPaneProps {
|
||||
session: ChatSummary | null;
|
||||
/** Provision a new chat and mark it active. Returns the new chat_id or null. */
|
||||
onNewChat: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat surface: persisted history on top, live stream below, composer
|
||||
* pinned at the bottom. When no session is active we render a centered
|
||||
* welcome card with a fully-functional composer — typing a first message
|
||||
* quietly provisions a new chat and routes the message through.
|
||||
*/
|
||||
export function ChatPane({ session, onNewChat }: ChatPaneProps) {
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
|
||||
const { client } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const pendingFirstRef = useRef<string | null>(null);
|
||||
|
||||
const initial = useMemo(() => historical, [historical]);
|
||||
const { messages, isStreaming, send, setMessages } = useNanobotStream(
|
||||
chatId,
|
||||
initial,
|
||||
hasPendingToolCalls,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && chatId) setMessages(historical);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical]);
|
||||
|
||||
// Once a session becomes active, flush any first-message stashed from the
|
||||
// welcome composer so the user's keystroke "just sends".
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
const pending = pendingFirstRef.current;
|
||||
if (!pending) return;
|
||||
pendingFirstRef.current = null;
|
||||
client.sendMessage(chatId, pending);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: pending,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
setBooting(false);
|
||||
}, [chatId, client, setMessages]);
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string) => {
|
||||
if (booting) return;
|
||||
setBooting(true);
|
||||
pendingFirstRef.current = content;
|
||||
const newId = await onNewChat();
|
||||
if (!newId) {
|
||||
// Creation failed — release the lock so the user can retry.
|
||||
pendingFirstRef.current = null;
|
||||
setBooting(false);
|
||||
}
|
||||
},
|
||||
[booting, onNewChat],
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
|
||||
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
<h1 className="text-xl font-medium tracking-tight text-foreground/90">
|
||||
What can I do for you?
|
||||
</h1>
|
||||
<p className="max-w-md text-center text-sm text-muted-foreground">
|
||||
Your conversations are persisted locally under the nanobot
|
||||
workspace. Start typing and I'll open a new chat.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
<Composer
|
||||
compact
|
||||
disabled={booting}
|
||||
onSend={handleWelcomeSend}
|
||||
placeholder={
|
||||
booting ? "Opening a new chat…" : "Ask anything..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-0 flex-1 flex-col">
|
||||
<MessageList messages={messages} isStreaming={isStreaming} />
|
||||
<Composer
|
||||
onSend={send}
|
||||
disabled={!chatId}
|
||||
placeholder="Type your message…"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTurnLatency } from "@/lib/format";
|
||||
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: UIMessage;
|
||||
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
|
||||
showAssistantCopyAction?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +30,10 @@ interface MessageBubbleProps {
|
||||
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
|
||||
* collapsible group so intermediate steps never masquerade as replies.
|
||||
*/
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
export function MessageBubble({
|
||||
message,
|
||||
showAssistantCopyAction = true,
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<number | null>(null);
|
||||
@@ -89,6 +102,14 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
||||
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||
const showCopyButton = showAssistantCopyAction && showAssistantActions;
|
||||
const latencyMs = message.latencyMs;
|
||||
const showLatencyFooter =
|
||||
message.role === "assistant"
|
||||
&& latencyMs != null
|
||||
&& !message.isStreaming
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
const showAssistantFooterRow = showCopyButton || showLatencyFooter;
|
||||
return (
|
||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{hasReasoning ? (
|
||||
@@ -99,27 +120,36 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
<MarkdownText>{message.content}</MarkdownText>
|
||||
{message.isStreaming && <StreamCursor />}
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantActions ? (
|
||||
<div className="mt-2 flex items-center gap-1 text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyAssistantReply}
|
||||
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||
title={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
{showAssistantFooterRow ? (
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
{showCopyButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyAssistantReply}
|
||||
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||
title={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{showLatencyFooter ? (
|
||||
<span
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
title={t("message.turnLatencyTitle")}
|
||||
>
|
||||
{formatTurnLatency(latencyMs)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -187,14 +217,34 @@ function MediaCell({ media }: { media: UIMediaAttachment }) {
|
||||
: t("message.fileAttachment", { defaultValue: "File attachment" });
|
||||
const Icon = media.kind === "video" ? PlaySquare : FileIcon;
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Icon className="h-4 w-4 flex-none" aria-hidden />
|
||||
<span className="truncate">{media.name ?? label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (hasUrl) {
|
||||
return (
|
||||
<a
|
||||
href={media.url}
|
||||
download={media.name ?? label}
|
||||
title={media.name ?? undefined}
|
||||
aria-label={label}
|
||||
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
||||
title={media.name ?? undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-none" aria-hidden />
|
||||
<span className="truncate">{media.name ?? label}</span>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -338,20 +388,6 @@ function UserImageCell({
|
||||
);
|
||||
}
|
||||
|
||||
/** Blinking cursor appended at the end of streaming text. */
|
||||
function StreamCursor() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t("message.streaming")}
|
||||
className={cn(
|
||||
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
|
||||
"rounded-sm bg-foreground/70 animate-pulse",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-token-arrival placeholder: three bouncing dots. */
|
||||
function TypingDots() {
|
||||
const { t } = useTranslation();
|
||||
@@ -379,6 +415,139 @@ function Dot({ delay }: { delay: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
|
||||
export function StreamingLabelSheen({
|
||||
children,
|
||||
active,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("relative block min-w-0 py-px", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-0 block font-medium leading-normal text-muted-foreground",
|
||||
!active && "truncate",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{active ? (
|
||||
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
|
||||
<span className="reasoning-sheen-stripe" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReasoningBubbleProps {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
|
||||
embeddedInCluster?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subordinate "thinking" trace shown above an assistant turn.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
|
||||
* the bubble defaults to open and the header shows a sheen + pulse so
|
||||
* the user sees the model "thinking out loud" in real time.
|
||||
* - Expanded reasoning uses the same Markdown pipeline as assistant replies
|
||||
* (deferred while streaming to reduce parser thrash), so headings and
|
||||
* emphasis render instead of leaking raw ``###`` / ``**``.
|
||||
* - On ``reasoning_end`` the bubble auto-collapses for prose density —
|
||||
* the user can re-expand to inspect the chain of thought. The local
|
||||
* toggle persists once the user interacts.
|
||||
*/
|
||||
export function ReasoningBubble({
|
||||
text,
|
||||
streaming,
|
||||
hasBodyBelow,
|
||||
embeddedInCluster = false,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const deferredText = useDeferredValue(text);
|
||||
const markdownSource = streaming ? deferredText : text;
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [openLocal, setOpenLocal] = useState(true);
|
||||
const open = userToggled ? openLocal : streaming;
|
||||
const onToggle = () => {
|
||||
setUserToggled(true);
|
||||
setOpenLocal((v) => (userToggled ? !v : !open));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (open && text.length > 0) {
|
||||
preloadMarkdownText();
|
||||
}
|
||||
}, [open, text.length]);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
hasBodyBelow && "mb-2",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-live={streaming ? "polite" : undefined}
|
||||
>
|
||||
<Sparkles
|
||||
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
|
||||
aria-hidden
|
||||
/>
|
||||
<StreamingLabelSheen active={streaming} className="min-w-0 flex-1 text-left">
|
||||
{streaming
|
||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||
: t("message.reasoning", { defaultValue: "Thinking" })}
|
||||
</StreamingLabelSheen>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && text.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 min-w-0 border-l border-muted-foreground/20 pl-3",
|
||||
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
)}
|
||||
>
|
||||
<MarkdownText
|
||||
className={cn(
|
||||
"text-[12.5px] italic text-muted-foreground/88",
|
||||
"prose-p:my-1.5 prose-li:my-0.5",
|
||||
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
||||
"prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground",
|
||||
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
||||
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
{markdownSource}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TraceGroupProps {
|
||||
message: UIMessage;
|
||||
animClass: string;
|
||||
@@ -389,7 +558,7 @@ interface TraceGroupProps {
|
||||
* collapsed because tool traces are supporting evidence, not the answer.
|
||||
* A single click expands the exact calls when the user wants details.
|
||||
*/
|
||||
function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
export function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const lines = message.traces ?? [message.content];
|
||||
const count = lines.length;
|
||||
@@ -439,79 +608,3 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReasoningBubbleProps {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subordinate "thinking" trace shown above an assistant turn.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
|
||||
* the bubble defaults to open and the header runs a shimmer + pulse so
|
||||
* the user sees the model "thinking out loud" in real time.
|
||||
* - On ``reasoning_end`` the bubble auto-collapses for prose density —
|
||||
* the user can re-expand to inspect the chain of thought. The local
|
||||
* toggle persists once the user interacts.
|
||||
*/
|
||||
function ReasoningBubble({ text, streaming, hasBodyBelow }: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [openLocal, setOpenLocal] = useState(true);
|
||||
const open = userToggled ? openLocal : streaming;
|
||||
const onToggle = () => {
|
||||
setUserToggled(true);
|
||||
setOpenLocal((v) => (userToggled ? !v : !open));
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
hasBodyBelow && "mb-2",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||
streaming && "reasoning-shimmer",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-live={streaming ? "polite" : undefined}
|
||||
>
|
||||
<Sparkles
|
||||
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{streaming
|
||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||
: t("message.reasoning", { defaultValue: "Thinking" })}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && text.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 space-y-0.5 whitespace-pre-wrap break-words border-l border-muted-foreground/20 pl-3",
|
||||
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
"text-[12.5px] italic leading-relaxed text-muted-foreground/85",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("sidebar.navigation")}
|
||||
className="flex h-full w-full flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
|
||||
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
|
||||
<picture className="block min-w-0">
|
||||
@@ -104,7 +104,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
{t("sidebar.newChat")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatList
|
||||
sessions={filteredSessions}
|
||||
activeKey={props.activeKey}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Layers } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
|
||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
||||
if (m.content.trim().length > 0) return false;
|
||||
return !!(m.reasoning?.length || m.reasoningStreaming || m.isStreaming);
|
||||
}
|
||||
|
||||
export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||
}
|
||||
|
||||
function countToolCalls(messages: UIMessage[]): number {
|
||||
let n = 0;
|
||||
for (const m of messages) {
|
||||
if (m.kind !== "trace") continue;
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
n += Math.max(lines, 1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
interface AgentActivityClusterProps {
|
||||
messages: UIMessage[];
|
||||
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
||||
isTurnStreaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows.
|
||||
* Fixed max height with inner scroll; each block keeps its own small collapsible (reasoning / tools).
|
||||
*/
|
||||
export function AgentActivityCluster({
|
||||
messages,
|
||||
isTurnStreaming,
|
||||
hasBodyBelow,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
|
||||
const toolCalls = countToolCalls(messages);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
|
||||
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||
|
||||
const headerBusy = isTurnStreaming;
|
||||
|
||||
const summary =
|
||||
isTurnStreaming
|
||||
? reasoningSteps > 0
|
||||
? t("message.agentActivityLiveSummary", {
|
||||
reasoning: reasoningSteps,
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: t("message.agentActivityLiveToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "Working… · {{tools}} tool calls",
|
||||
})
|
||||
: reasoningSteps > 0
|
||||
? t("message.agentActivitySummary", {
|
||||
reasoning: reasoningSteps,
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
|
||||
})
|
||||
: t("message.agentActivityToolsOnly", {
|
||||
tools: toolCalls,
|
||||
defaultValue: "{{tools}} tool calls",
|
||||
});
|
||||
|
||||
const toggleOuter = () => {
|
||||
setUserToggledOuter(true);
|
||||
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOuter}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||
)}
|
||||
aria-expanded={outerExpanded}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<StreamingLabelSheen
|
||||
active={headerBusy}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
{summary}
|
||||
</StreamingLabelSheen>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
|
||||
outerExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{outerExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 overflow-hidden rounded-md border border-border/50 bg-muted/25",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
CLUSTER_SCROLL_MAX_CLASS,
|
||||
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{messages.map((m) => {
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
return (
|
||||
<ReasoningBubble
|
||||
key={m.id}
|
||||
text={m.reasoning ?? ""}
|
||||
streaming={!!m.reasoningStreaming}
|
||||
hasBodyBelow={false}
|
||||
embeddedInCluster
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
return <TraceGroup key={m.id} message={m} animClass="" />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleHelp,
|
||||
History,
|
||||
ImageIcon,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
Target,
|
||||
Undo2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -29,6 +31,12 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
useAttachedImages,
|
||||
type AttachedImage,
|
||||
@@ -37,7 +45,7 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import type { SlashCommand } from "@/lib/types";
|
||||
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
@@ -61,6 +69,10 @@ interface ThreadComposerProps {
|
||||
imageMode?: boolean;
|
||||
onImageModeChange?: (enabled: boolean) => void;
|
||||
onStop?: () => void;
|
||||
/** Unix seconds from server; turn elapsed timer above input while set. */
|
||||
runStartedAt?: number | null;
|
||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||
goalState?: GoalStateWsPayload;
|
||||
}
|
||||
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -126,6 +138,133 @@ function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
|
||||
return { top, bottom };
|
||||
}
|
||||
|
||||
function goalStateStripPreview(
|
||||
goal: GoalStateWsPayload | undefined,
|
||||
t: (key: string) => string,
|
||||
): string | null {
|
||||
if (!goal?.active) return null;
|
||||
const summary = goal.ui_summary?.trim();
|
||||
if (summary) return summary;
|
||||
const obj = goal.objective?.trim();
|
||||
if (obj) return obj.length > 72 ? `${obj.slice(0, 72)}…` : obj;
|
||||
return t("thread.composer.goalStateFallback");
|
||||
}
|
||||
|
||||
function RunElapsedStrip({
|
||||
startedAt,
|
||||
goalState,
|
||||
}: {
|
||||
startedAt: number | null;
|
||||
goalState?: GoalStateWsPayload;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [goalSheetOpen, setGoalSheetOpen] = useState(false);
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (startedAt == null) return;
|
||||
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [startedAt]);
|
||||
const showTimer = startedAt != null;
|
||||
const stripLabel = goalStateStripPreview(goalState, t);
|
||||
const showGoal = !!stripLabel?.trim();
|
||||
if (!showTimer && !showGoal) return null;
|
||||
|
||||
const objectiveFull = goalState?.objective?.trim() ?? "";
|
||||
const summaryFull = goalState?.ui_summary?.trim() ?? "";
|
||||
const canExpandGoal = !!(goalState?.active && (objectiveFull || summaryFull));
|
||||
|
||||
const elapsed =
|
||||
startedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - startedAt)) : 0;
|
||||
const m = Math.floor(elapsed / 60);
|
||||
const s = elapsed % 60;
|
||||
const shortElapsed = m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`;
|
||||
const timerTitle = showTimer
|
||||
? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed })
|
||||
: null;
|
||||
|
||||
const ariaParts = [timerTitle, showGoal ? stripLabel : null].filter(Boolean);
|
||||
const ariaLabel = ariaParts.join(" · ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex min-h-[36px] items-center gap-2 border-b border-black/[0.04] px-3 py-2 dark:border-white/[0.06]"
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{showTimer ? (
|
||||
<Activity className="h-4 w-4 shrink-0 text-primary/80" aria-hidden />
|
||||
) : (
|
||||
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
|
||||
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
|
||||
{timerTitle && showGoal ? (
|
||||
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
{showGoal ? (
|
||||
<span className="truncate">
|
||||
{t("thread.composer.goalStateStrip", { label: stripLabel })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{canExpandGoal ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={t("thread.composer.goalStateExpandAria")}
|
||||
title={t("thread.composer.goalStateExpandAria")}
|
||||
onClick={() => setGoalSheetOpen(true)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Sheet open={goalSheetOpen} onOpenChange={setGoalSheetOpen}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
"max-h-[min(85vh,560px)] rounded-t-2xl border-t px-4 pb-6 pt-4",
|
||||
"gap-3 sm:max-w-lg sm:rounded-t-2xl",
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="space-y-1 text-left">
|
||||
<SheetTitle>{t("thread.composer.goalStateSheetTitle")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex max-h-[min(58vh,420px)] flex-col gap-4 overflow-y-auto pr-0.5 text-[14px] leading-relaxed">
|
||||
{summaryFull ? (
|
||||
<section>
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("thread.composer.goalStateSummaryHeading")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-foreground/90">{summaryFull}</p>
|
||||
</section>
|
||||
) : null}
|
||||
{objectiveFull ? (
|
||||
<section>
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("thread.composer.goalStateObjectiveHeading")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-foreground/90">{objectiveFull}</p>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
@@ -137,6 +276,8 @@ export function ThreadComposer({
|
||||
imageMode: controlledImageMode,
|
||||
onImageModeChange,
|
||||
onStop,
|
||||
runStartedAt = null,
|
||||
goalState,
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
@@ -513,6 +654,8 @@ export function ThreadComposer({
|
||||
"focus-within:ring-1 focus-within:ring-foreground/8",
|
||||
disabled && "opacity-60",
|
||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||
goalState?.active &&
|
||||
"thread-goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
||||
)}
|
||||
>
|
||||
{images.length > 0 ? (
|
||||
@@ -543,6 +686,9 @@ export function ThreadComposer({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{runStartedAt != null || goalState?.active ? (
|
||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
||||
@@ -1,23 +1,90 @@
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AgentActivityCluster,
|
||||
isAgentActivityMember,
|
||||
} from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function ThreadMessages({ messages }: ThreadMessagesProps) {
|
||||
export type DisplayUnit =
|
||||
| { type: "cluster"; messages: UIMessage[] }
|
||||
| { type: "single"; message: UIMessage };
|
||||
|
||||
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
|
||||
export function isFinalAssistantSliceBeforeNextUser(
|
||||
units: DisplayUnit[],
|
||||
index: number,
|
||||
): boolean {
|
||||
const u = units[index];
|
||||
if (u.type !== "single" || u.message.role !== "assistant") return true;
|
||||
for (let j = index + 1; j < units.length; j++) {
|
||||
const v = units[j];
|
||||
if (v.type === "single" && v.message.role === "user") break;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
const out: DisplayUnit[] = [];
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
const m = messages[i];
|
||||
if (isAgentActivityMember(m)) {
|
||||
const cluster: UIMessage[] = [];
|
||||
while (i < messages.length && isAgentActivityMember(messages[i])) {
|
||||
cluster.push(messages[i]);
|
||||
i += 1;
|
||||
}
|
||||
out.push({ type: "cluster", messages: cluster });
|
||||
continue;
|
||||
}
|
||||
out.push({ type: "single", message: m });
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{messages.map((message, index) => {
|
||||
const prev = messages[index - 1];
|
||||
const compact = isAuxiliaryRow(message) && prev && isAuxiliaryRow(prev);
|
||||
{units.map((unit, index) => {
|
||||
const prev = units[index - 1];
|
||||
const marginTop =
|
||||
index > 0
|
||||
? marginAfterPrevUnit(prev)
|
||||
: "";
|
||||
const next = units[index + 1];
|
||||
const hasBodyBelow =
|
||||
unit.type === "cluster"
|
||||
&& next?.type === "single"
|
||||
&& next.message.role === "assistant";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(index > 0 && (compact ? "mt-2" : "mt-5"))}
|
||||
>
|
||||
<MessageBubble message={message} />
|
||||
<div key={unitKey(unit, index)} className={marginTop}>
|
||||
{unit.type === "cluster" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={isStreaming}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
message={unit.message}
|
||||
showAssistantCopyAction={
|
||||
unit.message.role === "assistant"
|
||||
? isFinalAssistantSliceBeforeNextUser(units, index)
|
||||
: true
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -25,13 +92,28 @@ export function ThreadMessages({ messages }: ThreadMessagesProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function isAuxiliaryRow(message: UIMessage): boolean {
|
||||
return (
|
||||
message.kind === "trace"
|
||||
function unitKey(unit: DisplayUnit, index: number): string {
|
||||
if (unit.type === "cluster") {
|
||||
const anchor = unit.messages[0]?.id;
|
||||
return anchor != null ? `cluster-${anchor}` : `cluster-idx-${index}`;
|
||||
}
|
||||
return unit.message.id;
|
||||
}
|
||||
|
||||
function marginAfterPrevUnit(prev: DisplayUnit): string {
|
||||
if (prev.type === "cluster") {
|
||||
return "mt-4";
|
||||
}
|
||||
const p = prev.message;
|
||||
const denseP =
|
||||
p.kind === "trace"
|
||||
|| (
|
||||
message.role === "assistant"
|
||||
&& message.content.trim().length === 0
|
||||
&& (!!message.reasoning || !!message.reasoningStreaming)
|
||||
)
|
||||
);
|
||||
p.role === "assistant"
|
||||
&& p.content.trim().length === 0
|
||||
&& (!!p.reasoning || !!p.reasoningStreaming)
|
||||
);
|
||||
if (denseP) {
|
||||
return "mt-2";
|
||||
}
|
||||
return "mt-5";
|
||||
}
|
||||
|
||||
@@ -21,8 +21,14 @@ import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useN
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import { listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
||||
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
||||
}
|
||||
|
||||
interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
title: string;
|
||||
@@ -95,9 +101,13 @@ export function ThreadShell({
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const lastCachedChatIdRef = useRef<string | null>(null);
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
const skipLayoutCacheRef = useRef(false);
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
@@ -111,12 +121,21 @@ export function ThreadShell({
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
runStartedAt,
|
||||
goalState,
|
||||
send,
|
||||
stop,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
||||
}, [chatId, historyKey]);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,13 +153,16 @@ export function ThreadShell({
|
||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
messageCacheRef.current.set(chatId, historical);
|
||||
return historical;
|
||||
const normalized = projectWebuiThreadMessages(historical);
|
||||
messageCacheRef.current.set(chatId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
if (cached && cached.length > 0) return cached;
|
||||
if (historical.length === 0 && prev.length > 0) return prev;
|
||||
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
||||
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
return historical;
|
||||
const next = projectWebuiThreadMessages(historical);
|
||||
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
|
||||
return next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical, historyVersion]);
|
||||
@@ -161,26 +183,44 @@ export function ThreadShell({
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) return;
|
||||
setMessages(historical);
|
||||
setMessages(projectWebuiThreadMessages(historical));
|
||||
}, [chatId, historical, setMessages]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) {
|
||||
lastCachedChatIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (loading) return;
|
||||
// Skip the first cache write after a chat switch. During that render,
|
||||
// `messages` can still belong to the previous chat until the stream hook
|
||||
// resets its local state for the new session.
|
||||
if (lastCachedChatIdRef.current !== chatId) {
|
||||
lastCachedChatIdRef.current = chatId;
|
||||
if (messages.length > 0) {
|
||||
messageCacheRef.current.set(chatId, messages);
|
||||
if (chatId) {
|
||||
const prev = prevChatIdForCacheRef.current;
|
||||
if (prev && prev !== chatId) {
|
||||
messageCacheRef.current.set(prev, projectWebuiThreadMessages(messages));
|
||||
skipLayoutCacheRef.current = true;
|
||||
}
|
||||
prevChatIdForCacheRef.current = chatId;
|
||||
} else {
|
||||
if (prevChatIdForCacheRef.current) {
|
||||
messageCacheRef.current.set(
|
||||
prevChatIdForCacheRef.current,
|
||||
projectWebuiThreadMessages(messages),
|
||||
);
|
||||
skipLayoutCacheRef.current = true;
|
||||
}
|
||||
prevChatIdForCacheRef.current = null;
|
||||
}
|
||||
}, [chatId, messages]);
|
||||
|
||||
// Persist thread to in-memory cache after paint so ``useNanobotStream``'s chat switch
|
||||
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
||||
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
||||
useEffect(() => {
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
messageCacheRef.current.set(chatId, messages);
|
||||
if (skipLayoutCacheRef.current) {
|
||||
skipLayoutCacheRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages));
|
||||
}, [chatId, loading, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -296,6 +336,8 @@ export function ThreadShell({
|
||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||
onStop={stop}
|
||||
runStartedAt={runStartedAt}
|
||||
goalState={goalState}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
@@ -312,6 +354,8 @@ export function ThreadShell({
|
||||
slashCommands={slashCommands}
|
||||
imageMode={heroImageMode}
|
||||
onImageModeChange={setHeroImageMode}
|
||||
runStartedAt={runStartedAt}
|
||||
goalState={goalState}
|
||||
/>
|
||||
)}
|
||||
{showHeroComposer ? quickActions : null}
|
||||
@@ -341,7 +385,7 @@ export function ThreadShell({
|
||||
minimal={!session && !loading}
|
||||
/>
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
messages={displayMessages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
|
||||
@@ -33,7 +33,8 @@ export function ThreadViewport({
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
const forceBottomUntilRef = useRef(0);
|
||||
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||
const userReadingHistoryRef = useRef(false);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
@@ -56,31 +57,44 @@ export function ThreadViewport({
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false, frames = 1) => {
|
||||
cancelScheduledBottomScroll();
|
||||
scrollToBottomNow(smooth);
|
||||
for (let i = 1; i < frames; i += 1) {
|
||||
const id = window.requestAnimationFrame(() => scrollToBottomNow(smooth));
|
||||
scrollFrameIdsRef.current.push(id);
|
||||
}
|
||||
}, [cancelScheduledBottomScroll, scrollToBottomNow]);
|
||||
const scrollToBottom = useCallback(
|
||||
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
||||
const force = options?.force ?? false;
|
||||
cancelScheduledBottomScroll();
|
||||
const run = () => {
|
||||
if (!force && userReadingHistoryRef.current) return;
|
||||
scrollToBottomNow(smooth);
|
||||
};
|
||||
run();
|
||||
for (let i = 1; i < frames; i += 1) {
|
||||
const id = window.requestAnimationFrame(() => {
|
||||
if (!force && userReadingHistoryRef.current) return;
|
||||
scrollToBottomNow(smooth);
|
||||
});
|
||||
scrollFrameIdsRef.current.push(id);
|
||||
}
|
||||
},
|
||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!atBottom) return;
|
||||
scrollToBottom(!isStreaming);
|
||||
}, [messages, isStreaming, atBottom, scrollToBottom]);
|
||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||
// browsers; session switches and history hydration should never slide from top.
|
||||
scrollToBottom(false);
|
||||
}, [messages, atBottom, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToBottomSignal <= 0) return;
|
||||
forceBottomUntilRef.current = Date.now() + 2_000;
|
||||
scrollToBottom(true, 8);
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, 8);
|
||||
}, [scrollToBottomSignal, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastConversationKeyRef.current === conversationKey) return;
|
||||
lastConversationKeyRef.current = conversationKey;
|
||||
pendingConversationScrollRef.current = true;
|
||||
forceBottomUntilRef.current = Date.now() + 2_000;
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(true);
|
||||
}, [conversationKey]);
|
||||
|
||||
@@ -102,12 +116,12 @@ export function ThreadViewport({
|
||||
const target = contentRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!atBottom && Date.now() > forceBottomUntilRef.current) return;
|
||||
if (userReadingHistoryRef.current) return;
|
||||
scrollToBottom(false, 4);
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [atBottom, hasMessages, scrollToBottom]);
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -115,7 +129,9 @@ export function ThreadViewport({
|
||||
|
||||
const onScroll = () => {
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setAtBottom(distance < NEAR_BOTTOM_PX);
|
||||
const near = distance < NEAR_BOTTOM_PX;
|
||||
setAtBottom(near);
|
||||
userReadingHistoryRef.current = !near;
|
||||
};
|
||||
|
||||
onScroll();
|
||||
@@ -128,7 +144,7 @@ export function ThreadViewport({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-auto scroll-smooth scrollbar-thin",
|
||||
"absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin",
|
||||
"[&::-webkit-scrollbar]:w-1.5",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
||||
@@ -139,7 +155,7 @@ export function ThreadViewport({
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div className="flex-1 px-4 pb-20 pt-4">
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages messages={messages} />
|
||||
<ThreadMessages messages={messages} isStreaming={isStreaming} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,9 +187,10 @@ export function ThreadViewport({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => scrollToBottom(true)}
|
||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||
className={cn(
|
||||
"absolute bottom-28 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
||||
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"bg-background/90 backdrop-blur",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ const ScrollArea = React.forwardRef<
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full min-w-0 rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
||||
Reference in New Issue
Block a user