mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be12cf134 | ||
|
|
1f50570600 | ||
|
|
4f6cf1fac2 | ||
|
|
28500fffd9 | ||
|
|
33e6aa329b | ||
|
|
c11ddbe491 | ||
|
|
2020645f18 | ||
|
|
e9d811e609 | ||
|
|
20d7defa03 |
@@ -23,6 +23,7 @@ from nanobot.cli.webui_support import (
|
||||
_gateway_health_bind_note,
|
||||
_gateway_health_url,
|
||||
_host_for_local_browser,
|
||||
_launch_browser,
|
||||
_prepare_webui_bundle_for_gateway,
|
||||
_print_foreground_port_conflict,
|
||||
_tcp_endpoint_reachable,
|
||||
@@ -864,7 +865,6 @@ def _run_gateway(
|
||||
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
||||
if not open_browser_url:
|
||||
return
|
||||
import webbrowser
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Channels start asynchronously. When the caller supplies a backend
|
||||
@@ -896,8 +896,10 @@ def _run_gateway(
|
||||
await asyncio.sleep(0.1)
|
||||
display_url = _webui_display_url(open_browser_url)
|
||||
try:
|
||||
webbrowser.open(open_browser_url)
|
||||
console.print(f"[green]✓[/green] Opened browser at {display_url}")
|
||||
if _launch_browser(open_browser_url):
|
||||
console.print(f"[green]✓[/green] Opened browser at {display_url}")
|
||||
else:
|
||||
console.print(f"[yellow]Could not open browser; visit {display_url}[/yellow]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]")
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Shared WebUI setup, URL, health, and browser helpers."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -40,6 +42,7 @@ __all__ = [
|
||||
"_gateway_instance_command",
|
||||
"_host_for_local_browser",
|
||||
"_load_webui_setup_config",
|
||||
"_launch_browser",
|
||||
"_open_webui_browser",
|
||||
"_prepare_webui_bundle_for_gateway",
|
||||
"_print_foreground_port_conflict",
|
||||
@@ -60,6 +63,20 @@ __all__ = [
|
||||
console = Console()
|
||||
|
||||
|
||||
def _launch_browser(url: str) -> bool:
|
||||
"""Open *url* and request a foreground browser window."""
|
||||
if sys.platform == "darwin":
|
||||
result = subprocess.run(
|
||||
["open", url],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
return bool(webbrowser.open(url, new=2, autoraise=True))
|
||||
|
||||
|
||||
def _confirm_webui_action(message: str, *, yes: bool) -> None:
|
||||
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
|
||||
if yes:
|
||||
@@ -419,14 +436,14 @@ def _print_foreground_port_conflict(
|
||||
|
||||
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
|
||||
"""Open the WebUI in the user's default browser, with a copyable fallback."""
|
||||
import webbrowser
|
||||
|
||||
if wait:
|
||||
_wait_for_webui(url)
|
||||
display_url = _webui_display_url(url)
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
|
||||
if _launch_browser(url):
|
||||
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
|
||||
else:
|
||||
console.print(f"[yellow]Could not open browser; visit {display_url}[/yellow]")
|
||||
except Exception as exc:
|
||||
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
|
||||
|
||||
|
||||
@@ -2534,7 +2534,11 @@ def test_webui_yes_still_refuses_invalid_custom_model_setup(
|
||||
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
|
||||
opened: list[str] = []
|
||||
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
||||
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
|
||||
monkeypatch.setattr(
|
||||
cli_webui_support,
|
||||
"_launch_browser",
|
||||
lambda value: opened.append(value) or True,
|
||||
)
|
||||
|
||||
cli_webui_support._open_webui_browser(url, wait=False)
|
||||
|
||||
@@ -2544,6 +2548,42 @@ def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> Non
|
||||
assert "super-secret" not in output
|
||||
|
||||
|
||||
def test_open_webui_browser_reports_launch_failure(monkeypatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli_webui_support, "_launch_browser", lambda _value: False)
|
||||
|
||||
cli_webui_support._open_webui_browser("http://127.0.0.1:8765/", wait=False)
|
||||
|
||||
assert "Could not open browser; visit http://127.0.0.1:8765/" in _strip_ansi(
|
||||
capsys.readouterr().out
|
||||
)
|
||||
|
||||
|
||||
def test_launch_browser_uses_macos_foreground_opener(monkeypatch) -> None:
|
||||
seen: list[list[str]] = []
|
||||
monkeypatch.setattr(cli_webui_support.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
cli_webui_support.subprocess,
|
||||
"run",
|
||||
lambda command, **_kwargs: seen.append(command) or SimpleNamespace(returncode=0),
|
||||
)
|
||||
|
||||
assert cli_webui_support._launch_browser("http://127.0.0.1:8765/") is True
|
||||
assert seen == [["open", "http://127.0.0.1:8765/"]]
|
||||
|
||||
|
||||
def test_launch_browser_uses_default_browser_off_macos(monkeypatch) -> None:
|
||||
opened: list[tuple[str, int, bool]] = []
|
||||
monkeypatch.setattr(cli_webui_support.sys, "platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
cli_webui_support.webbrowser,
|
||||
"open",
|
||||
lambda url, *, new, autoraise: opened.append((url, new, autoraise)) or True,
|
||||
)
|
||||
|
||||
assert cli_webui_support._launch_browser("http://127.0.0.1:8765/") is True
|
||||
assert opened == [("http://127.0.0.1:8765/", 2, True)]
|
||||
|
||||
|
||||
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
@@ -2816,6 +2816,7 @@ function Shell({
|
||||
hostChromeTitleInset={hostSidebarCollapsed}
|
||||
hideThemeButton={!context.active}
|
||||
hideHeaderTitle
|
||||
inlineHandle={workbenchPaneSessions.length > 1}
|
||||
headerActions={context.headerActions}
|
||||
headerPortalTarget={context.headerPortalTarget}
|
||||
headerActive={context.active}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
@@ -43,10 +52,11 @@ import {
|
||||
} from "@/lib/activity-timeline";
|
||||
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
||||
|
||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||
@@ -156,10 +166,14 @@ export function AgentActivityCluster({
|
||||
const fileEditDisplayMode = useFileEditDisplayMode();
|
||||
const pageVisible = usePageVisibility();
|
||||
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(activityMessages), isTurnStreaming),
|
||||
const fileEditsByMessage = useMemo(
|
||||
() => summarizeFileEditsByMessage(activityMessages, isTurnStreaming),
|
||||
[activityMessages, isTurnStreaming],
|
||||
);
|
||||
const fileEdits = useMemo(
|
||||
() => [...fileEditsByMessage.values()].flat(),
|
||||
[fileEditsByMessage],
|
||||
);
|
||||
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
|
||||
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
|
||||
const cliAppsByName = useMemo(
|
||||
@@ -348,15 +362,10 @@ export function AgentActivityCluster({
|
||||
active={isTurnStreaming}
|
||||
cliAppsByName={cliAppsByName}
|
||||
mcpPresetsByName={mcpPresetsByName}
|
||||
fileEditsByMessage={fileEditsByMessage}
|
||||
fileEditDisplayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
edits={fileEdits}
|
||||
displayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
</ThinkingReasoningShell>
|
||||
</div>
|
||||
);
|
||||
@@ -414,12 +423,16 @@ function ActivityMessageTimeline({
|
||||
active,
|
||||
cliAppsByName,
|
||||
mcpPresetsByName,
|
||||
fileEditsByMessage,
|
||||
fileEditDisplayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
active: boolean;
|
||||
cliAppsByName: Map<string, CliAppInfo>;
|
||||
mcpPresetsByName: Map<string, McpPresetInfo>;
|
||||
fileEditsByMessage: Map<string, FileEditSummary[]>;
|
||||
fileEditDisplayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const items: ReactNode[] = [];
|
||||
@@ -447,14 +460,21 @@ function ActivityMessageTimeline({
|
||||
return;
|
||||
}
|
||||
if (message.kind === "trace") {
|
||||
const fileEdits = fileEditsByMessage.get(message.id) ?? [];
|
||||
items.push(
|
||||
<ActivityTraceTimeline
|
||||
key={message.id}
|
||||
message={message}
|
||||
active={active && index === messages.length - 1}
|
||||
cliAppsByName={cliAppsByName}
|
||||
mcpPresetsByName={mcpPresetsByName}
|
||||
/>,
|
||||
<Fragment key={message.id}>
|
||||
<ActivityTraceTimeline
|
||||
message={message}
|
||||
active={active && index === messages.length - 1}
|
||||
cliAppsByName={cliAppsByName}
|
||||
mcpPresetsByName={mcpPresetsByName}
|
||||
/>
|
||||
<FileEditGroup
|
||||
edits={fileEdits}
|
||||
displayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</Fragment>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1061,16 +1081,6 @@ function fileEditCallKey(edit: UIFileEdit): string {
|
||||
return `${edit.tool}|${edit.path}`;
|
||||
}
|
||||
|
||||
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
|
||||
const edits: UIFileEdit[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.kind === "trace" && message.fileEdits?.length) {
|
||||
edits.push(...message.fileEdits);
|
||||
}
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
const order: string[] = [];
|
||||
const byKey = new Map<string, UIFileEdit>();
|
||||
@@ -1082,6 +1092,33 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
||||
}
|
||||
|
||||
/** Keep each edit at the point where its call first appeared. Later lifecycle
|
||||
* events update that row in place instead of moving completed edits to the end. */
|
||||
function summarizeFileEditsByMessage(
|
||||
messages: UIMessage[],
|
||||
active: boolean,
|
||||
): Map<string, FileEditSummary[]> {
|
||||
const messageByEdit = new Map<string, string>();
|
||||
const edits: UIFileEdit[] = [];
|
||||
for (const message of messages) {
|
||||
for (const edit of message.fileEdits ?? []) {
|
||||
const key = fileEditCallKey(edit);
|
||||
if (!messageByEdit.has(key)) messageByEdit.set(key, message.id);
|
||||
edits.push(edit);
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = new Map<string, FileEditSummary[]>();
|
||||
for (const edit of summarizeFileEdits(edits, active)) {
|
||||
const messageId = messageByEdit.get(edit.key);
|
||||
if (!messageId) continue;
|
||||
const group = grouped.get(messageId) ?? [];
|
||||
group.push(edit);
|
||||
grouped.set(messageId, group);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||
return latestFileEditEvents(edits).flatMap((edit) => {
|
||||
const editing = active && edit.status === "editing";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { Check, CircleHelp, Sparkles } from "lucide-react";
|
||||
import { Check, CircleHelp, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
@@ -85,6 +85,7 @@ interface ModelPresetBadgeProps {
|
||||
modelPreset?: string | null;
|
||||
modelPresets?: ModelPresetOption[];
|
||||
onPresetChange?: (name: string) => void;
|
||||
onManageModels?: () => void;
|
||||
onRequestComposerFocus?: () => void;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
@@ -100,6 +101,7 @@ export function ModelPresetBadge({
|
||||
modelPreset,
|
||||
modelPresets = [],
|
||||
onPresetChange,
|
||||
onManageModels,
|
||||
onRequestComposerFocus,
|
||||
provider,
|
||||
providerLabel,
|
||||
@@ -156,6 +158,11 @@ export function ModelPresetBadge({
|
||||
requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||
};
|
||||
|
||||
const openModelSettings = () => {
|
||||
setOpen(false);
|
||||
onManageModels?.();
|
||||
};
|
||||
|
||||
const clearGesture = () => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture?.timer) clearTimeout(gesture.timer);
|
||||
@@ -418,6 +425,22 @@ export function ModelPresetBadge({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onManageModels ? (
|
||||
<div className="mt-1 border-t border-border/55 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModelSettings}
|
||||
className={cn(
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
"flex min-h-9 w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="size-4 shrink-0" strokeWidth={1.75} />
|
||||
<span>{t("thread.composer.manageModels")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -300,6 +300,7 @@ interface ThreadComposerProps {
|
||||
modelNeedsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
onModelBadgeClick?: () => void;
|
||||
onManageModels?: () => void;
|
||||
contextUsage?: ComposerContextUsage | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
@@ -998,6 +999,7 @@ export function ThreadComposer({
|
||||
modelNeedsSetup = false,
|
||||
fallbackModelName = null,
|
||||
onModelBadgeClick,
|
||||
onManageModels,
|
||||
contextUsage = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
@@ -2539,6 +2541,7 @@ export function ThreadComposer({
|
||||
modelPreset={modelPreset}
|
||||
modelPresets={modelPresets}
|
||||
onPresetChange={onModelPresetChange}
|
||||
onManageModels={onManageModels}
|
||||
onRequestComposerFocus={() => textareaRef.current?.focus()}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ThreadHeader({
|
||||
<div
|
||||
data-testid="thread-header"
|
||||
className={cn(
|
||||
"relative z-30 flex items-center justify-between gap-3 px-3 py-2",
|
||||
"relative z-30 flex items-center justify-between gap-3 px-3 py-1",
|
||||
minimal && "h-11",
|
||||
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
||||
)}
|
||||
|
||||
@@ -136,6 +136,7 @@ export function ThreadMessages({
|
||||
return (
|
||||
<ThreadDisplayUnit
|
||||
key={unitKeys[index]}
|
||||
unitKey={unitKeys[index]}
|
||||
unit={unit}
|
||||
marginTop={marginTop}
|
||||
userPromptId={userPromptId}
|
||||
@@ -225,6 +226,7 @@ function pendingTurnProjection(
|
||||
}
|
||||
|
||||
interface ThreadDisplayUnitProps {
|
||||
unitKey: string;
|
||||
unit: DisplayUnit;
|
||||
marginTop: string;
|
||||
userPromptId?: string;
|
||||
@@ -243,6 +245,7 @@ interface ThreadDisplayUnitProps {
|
||||
}
|
||||
|
||||
const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
unitKey,
|
||||
unit,
|
||||
marginTop,
|
||||
userPromptId,
|
||||
@@ -273,6 +276,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
<>
|
||||
<div
|
||||
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
|
||||
data-thread-display-unit={unitKey}
|
||||
data-user-prompt-id={userPromptId}
|
||||
>
|
||||
{unit.type === "activity" ? (
|
||||
|
||||
@@ -346,6 +346,7 @@ interface ThreadShellProps {
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideHeaderTitle?: boolean;
|
||||
inlineHandle?: boolean;
|
||||
hideHeader?: boolean;
|
||||
headerActions?: ReactNode;
|
||||
headerPortalTarget?: HTMLElement | null;
|
||||
@@ -645,6 +646,7 @@ export function ThreadShell({
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideHeaderTitle = false,
|
||||
inlineHandle = false,
|
||||
hideHeader = false,
|
||||
headerActions,
|
||||
headerPortalTarget,
|
||||
@@ -1517,6 +1519,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
onManageModels={onOpenModelSettings}
|
||||
contextUsage={composerContextUsage}
|
||||
variant={composerVariant}
|
||||
slashCommands={availableSlashCommands}
|
||||
@@ -1565,6 +1568,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
onManageModels={onOpenModelSettings}
|
||||
contextUsage={composerContextUsage}
|
||||
variant="hero"
|
||||
slashCommands={availableSlashCommands}
|
||||
@@ -1614,7 +1618,7 @@ export function ThreadShell({
|
||||
const threadHeader = !hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
handle={temporary || hideHeaderTitle ? null : session?.handle}
|
||||
handle={temporary || (hideHeaderTitle && inlineHandle) ? null : session?.handle}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
@@ -1638,7 +1642,7 @@ export function ThreadShell({
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{hideHeaderTitle && !temporary && session?.handle ? (
|
||||
{hideHeaderTitle && inlineHandle && !temporary && session?.handle ? (
|
||||
<div
|
||||
aria-label={`Session @${session.handle.name}`}
|
||||
className="flex h-8 shrink-0 items-center px-3 text-[12px]"
|
||||
|
||||
@@ -61,7 +61,8 @@ interface ThreadViewportProps {
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const NEAR_TOP_PX = 96;
|
||||
const HISTORY_PREFETCH_MIN_PX = 160;
|
||||
const HISTORY_PREFETCH_MAX_PX = 480;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
@@ -72,6 +73,52 @@ const SESSION_HANDOFF_OPACITY = 0.82;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||
|
||||
interface HistoryScrollAnchor {
|
||||
key: string;
|
||||
offsetTop: number;
|
||||
}
|
||||
|
||||
const THREAD_DISPLAY_UNIT_SELECTOR = "[data-thread-display-unit]";
|
||||
|
||||
function historyPrefetchDistance(scroller: HTMLElement): number {
|
||||
return Math.min(
|
||||
HISTORY_PREFETCH_MAX_PX,
|
||||
Math.max(HISTORY_PREFETCH_MIN_PX, scroller.clientHeight / 2),
|
||||
);
|
||||
}
|
||||
|
||||
function visibleHistoryUnit(
|
||||
content: HTMLElement,
|
||||
viewport: DOMRect,
|
||||
): HTMLElement | null {
|
||||
// Scroll is a hot path. Hit-testing keeps the common case O(1) instead of
|
||||
// forcing layout for every mounted message while the trackpad is moving.
|
||||
if (typeof document.elementsFromPoint === "function" && viewport.height > 0) {
|
||||
const contentBounds = content.getBoundingClientRect();
|
||||
const left = Math.max(viewport.left, contentBounds.left);
|
||||
const right = Math.min(viewport.right, contentBounds.right);
|
||||
const x = left + Math.max(0, right - left) / 2;
|
||||
const offsets = [1, Math.min(32, viewport.height / 3), viewport.height / 2];
|
||||
for (const offset of offsets) {
|
||||
for (const target of document.elementsFromPoint(x, viewport.top + offset)) {
|
||||
const unit = target instanceof Element
|
||||
? target.closest<HTMLElement>(THREAD_DISPLAY_UNIT_SELECTOR)
|
||||
: null;
|
||||
if (unit && content.contains(unit)) return unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic fallback for pre-layout states, tests, and older browsers.
|
||||
const units = Array.from(
|
||||
content.querySelectorAll<HTMLElement>(THREAD_DISPLAY_UNIT_SELECTOR),
|
||||
);
|
||||
return units.find((unit) => {
|
||||
const bounds = unit.getBoundingClientRect();
|
||||
return bounds.bottom > viewport.top && bounds.top < viewport.bottom;
|
||||
}) ?? units[0] ?? null;
|
||||
}
|
||||
|
||||
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
||||
if (messages.length <= visibleCount) return messages;
|
||||
let start = Math.max(0, messages.length - visibleCount);
|
||||
@@ -210,6 +257,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||
const restoreScrollAfterPrependRef =
|
||||
useRef<{ height: number; top: number } | null>(null);
|
||||
const historyScrollAnchorRef = useRef<HistoryScrollAnchor | null>(null);
|
||||
const composerInputScrollTopRef = useRef<number | null>(null);
|
||||
const composerDockHeightRef = useRef(0);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
@@ -298,7 +346,53 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
threadMotionRef.current?.takeUserControl();
|
||||
}, []);
|
||||
|
||||
const captureHistoryScrollAnchor = useCallback(() => {
|
||||
const scroller = scrollRef.current;
|
||||
const content = messageContentRef.current;
|
||||
if (!scroller || !content) {
|
||||
historyScrollAnchorRef.current = null;
|
||||
return false;
|
||||
}
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const element = visibleHistoryUnit(content, viewport);
|
||||
const key = element?.dataset.threadDisplayUnit;
|
||||
if (!element || !key) {
|
||||
historyScrollAnchorRef.current = null;
|
||||
return false;
|
||||
}
|
||||
historyScrollAnchorRef.current = {
|
||||
key,
|
||||
offsetTop: element.getBoundingClientRect().top - viewport.top,
|
||||
};
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const reconcileHistoryScrollAnchor = useCallback(() => {
|
||||
const scroller = scrollRef.current;
|
||||
const content = messageContentRef.current;
|
||||
const anchor = historyScrollAnchorRef.current;
|
||||
if (!scroller || !content || !anchor) return false;
|
||||
const element = Array.from(
|
||||
content.querySelectorAll<HTMLElement>("[data-thread-display-unit]"),
|
||||
).find((candidate) => candidate.dataset.threadDisplayUnit === anchor.key);
|
||||
if (!element) {
|
||||
historyScrollAnchorRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextOffset =
|
||||
element.getBoundingClientRect().top
|
||||
- scroller.getBoundingClientRect().top;
|
||||
const delta = nextOffset - anchor.offsetTop;
|
||||
if (Math.abs(delta) < 0.5) return true;
|
||||
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
const nextTop = Math.min(maxScrollTop, Math.max(0, scroller.scrollTop + delta));
|
||||
threadMotionRef.current?.jumpTo(nextTop);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const scrollToBottomNow = useCallback((smooth = false) => {
|
||||
historyScrollAnchorRef.current = null;
|
||||
const el = scrollRef.current;
|
||||
const marker = bottomRef.current;
|
||||
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
||||
@@ -328,10 +422,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const loadEarlierMessages = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
restoreScrollAfterPrependRef.current = {
|
||||
height: el.scrollHeight,
|
||||
top: el.scrollTop,
|
||||
};
|
||||
if (captureHistoryScrollAnchor()) {
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
} else {
|
||||
restoreScrollAfterPrependRef.current = {
|
||||
height: el.scrollHeight,
|
||||
top: el.scrollTop,
|
||||
};
|
||||
}
|
||||
}
|
||||
threadMotionRef.current?.takeUserControl();
|
||||
setAtBottom(false);
|
||||
@@ -345,13 +443,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
|
||||
void onLoadOlder();
|
||||
}
|
||||
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
|
||||
}, [
|
||||
captureHistoryScrollAnchor,
|
||||
hasMoreBefore,
|
||||
hiddenMessageCount,
|
||||
loadingOlder,
|
||||
messages.length,
|
||||
onLoadOlder,
|
||||
]);
|
||||
|
||||
const maybeLoadEarlierFromScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
||||
if (!threadMotionRef.current?.isBrowsingHistory()) return;
|
||||
if (el.scrollTop > NEAR_TOP_PX) return;
|
||||
if (el.scrollTop > historyPrefetchDistance(el)) return;
|
||||
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
||||
loadEarlierMessages();
|
||||
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
||||
@@ -360,6 +465,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const scrollEl = scrollRef.current;
|
||||
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
|
||||
if (!scrollEl || !prompt) return false;
|
||||
historyScrollAnchorRef.current = null;
|
||||
setAtBottom(false);
|
||||
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
||||
threadMotionRef.current?.navigateHistoryTo(
|
||||
@@ -442,6 +548,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
conversationHandoffAnimationRef.current = null;
|
||||
conversationHandoffPendingRef.current = true;
|
||||
pendingConversationScrollRef.current = true;
|
||||
historyScrollAnchorRef.current = null;
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
threadMotionRef.current?.reset();
|
||||
setAtBottom(true);
|
||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||
@@ -505,17 +613,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pending = restoreScrollAfterPrependRef.current;
|
||||
if (!pending) return;
|
||||
const el = scrollRef.current;
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
if (!el) return;
|
||||
if (reconcileHistoryScrollAnchor()) return;
|
||||
if (!pending) return;
|
||||
const delta = el.scrollHeight - pending.height;
|
||||
const nextTop = Math.min(
|
||||
Math.max(0, el.scrollHeight - el.clientHeight),
|
||||
Math.max(0, pending.top + delta),
|
||||
);
|
||||
threadMotionRef.current?.jumpTo(nextTop);
|
||||
}, [visibleMessages.length, messages.length]);
|
||||
}, [reconcileHistoryScrollAnchor, visibleMessages.length, messages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const promptId = pendingPromptJumpRef.current;
|
||||
@@ -593,6 +702,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
threadMotionRef.current?.invalidateGeometry();
|
||||
};
|
||||
const reconcileObservedGeometry = () => {
|
||||
reconcileHistoryScrollAnchor();
|
||||
threadMotionRef.current?.reconcileObservedGeometry();
|
||||
};
|
||||
reconcileObservedGeometry();
|
||||
@@ -609,7 +719,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", invalidateGeometry);
|
||||
};
|
||||
}, [hasMessages]);
|
||||
}, [hasMessages, reconcileHistoryScrollAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -623,7 +733,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
setAtBottom((current) =>
|
||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||
);
|
||||
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
|
||||
if (owner === "user") {
|
||||
captureHistoryScrollAnchor();
|
||||
if (allowHistoryLoad) maybeLoadEarlierFromScroll();
|
||||
} else if (near) {
|
||||
historyScrollAnchorRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
onScroll(false);
|
||||
@@ -709,7 +824,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
el.removeEventListener("pointerdown", handlePointerDown);
|
||||
el.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [hasMessages, maybeLoadEarlierFromScroll, yieldCameraToUser]);
|
||||
}, [
|
||||
captureHistoryScrollAnchor,
|
||||
hasMessages,
|
||||
maybeLoadEarlierFromScroll,
|
||||
yieldCameraToUser,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
||||
@@ -744,8 +864,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
ref={messageRegionRef}
|
||||
data-testid="thread-message-region"
|
||||
className={cn(
|
||||
"thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col",
|
||||
"scroll-auto justify-start overflow-x-hidden px-3 pb-4 pt-4 sm:px-4",
|
||||
"thread-message-viewport thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col",
|
||||
"scroll-auto justify-start overflow-x-hidden px-3 pb-0 pt-3 sm:px-4",
|
||||
"[overflow-anchor:none] [scrollbar-width:none]",
|
||||
"[&::-webkit-scrollbar]:hidden",
|
||||
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
|
||||
@@ -768,6 +888,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
onQuoteSelection={onQuoteSelection}
|
||||
/>
|
||||
</div>
|
||||
<div aria-hidden className="thread-message-end-gap shrink-0" />
|
||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||
</div>
|
||||
) : (
|
||||
@@ -808,7 +929,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
}}
|
||||
className={cn(
|
||||
"row-start-2 z-10 w-full",
|
||||
hasMessages ? "relative bg-background" : "relative self-center",
|
||||
hasMessages ? "thread-composer-dock relative" : "relative self-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -840,7 +961,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-3 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
|
||||
{hasMessages ? (
|
||||
|
||||
@@ -377,6 +377,7 @@
|
||||
* bottom without a remount or a transform clone.
|
||||
*/
|
||||
.thread-layout {
|
||||
--thread-composer-fade-height: 2.25rem;
|
||||
grid-template-rows: minmax(min-content, 1fr) auto 0fr;
|
||||
transition: grid-template-rows 220ms ease-out;
|
||||
}
|
||||
@@ -392,6 +393,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the transcript and composer visually continuous. The scrollport owns
|
||||
* the fade because overflow clips its contents before an adjacent overlay
|
||||
* can soften the edge. The matching in-flow gap keeps the last line fully
|
||||
* visible once the user reaches the bottom.
|
||||
*/
|
||||
.thread-message-viewport {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0,
|
||||
#000 calc(100% - var(--thread-composer-fade-height)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0,
|
||||
#000 calc(100% - var(--thread-composer-fade-height)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
.thread-message-end-gap {
|
||||
height: var(--thread-composer-fade-height);
|
||||
}
|
||||
.thread-composer-dock {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
@keyframes composer-status-strip-enter {
|
||||
0% {
|
||||
max-height: 0;
|
||||
|
||||
@@ -1196,6 +1196,7 @@
|
||||
"modelNotConfigured": "Model not configured",
|
||||
"configureModel": "Configure model",
|
||||
"switchModel": "Switch model for this chat",
|
||||
"manageModels": "Manage models",
|
||||
"context": {
|
||||
"tooltip": "Context · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% used."
|
||||
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"modelNotConfigured": "Modelo no configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"switchModel": "Cambiar el modelo de este chat",
|
||||
"manageModels": "Gestionar modelos",
|
||||
"context": {
|
||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}} % usado."
|
||||
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"switchModel": "Changer le modèle de cette conversation",
|
||||
"manageModels": "Gérer les modèles",
|
||||
"context": {
|
||||
"tooltip": "Contexte · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}} % utilisé."
|
||||
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||
"configureModel": "Konfigurasi model",
|
||||
"switchModel": "Ganti model untuk percakapan ini",
|
||||
"manageModels": "Kelola model",
|
||||
"context": {
|
||||
"tooltip": "Konteks · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% digunakan."
|
||||
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"modelNotConfigured": "モデルが未設定です",
|
||||
"configureModel": "モデルを設定",
|
||||
"switchModel": "この会話で使うモデルを切り替える",
|
||||
"manageModels": "モデルを管理",
|
||||
"context": {
|
||||
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。{{percent}}% 使用中"
|
||||
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"modelNotConfigured": "모델이 설정되지 않음",
|
||||
"configureModel": "모델 설정",
|
||||
"switchModel": "이 대화에서 사용할 모델 전환",
|
||||
"manageModels": "모델 관리",
|
||||
"context": {
|
||||
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% 사용 중."
|
||||
|
||||
@@ -1196,6 +1196,7 @@
|
||||
"modelNotConfigured": "Modelo não configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"switchModel": "Alternar o modelo desta conversa",
|
||||
"manageModels": "Gerenciar modelos",
|
||||
"context": {
|
||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% usado."
|
||||
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"modelNotConfigured": "Chưa cấu hình mô hình",
|
||||
"configureModel": "Cấu hình mô hình",
|
||||
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
|
||||
"manageModels": "Quản lý mô hình",
|
||||
"context": {
|
||||
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. Đã dùng {{percent}}%."
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"modelNotConfigured": "模型未配置",
|
||||
"configureModel": "配置模型",
|
||||
"switchModel": "切换本次对话所用模型",
|
||||
"manageModels": "管理模型预设",
|
||||
"context": {
|
||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"modelNotConfigured": "尚未設定模型",
|
||||
"configureModel": "設定模型",
|
||||
"switchModel": "切換此對話使用的模型",
|
||||
"manageModels": "管理模型預設",
|
||||
"context": {
|
||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||
|
||||
@@ -561,6 +561,69 @@ describe("AgentActivityCluster", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a completed file edit at its original position in the turn", () => {
|
||||
const before: UIMessage = {
|
||||
id: "model-before-edit",
|
||||
role: "assistant",
|
||||
content: "Before the edit",
|
||||
activityKind: "model",
|
||||
createdAt: 1,
|
||||
};
|
||||
const after: UIMessage = {
|
||||
id: "model-after-edit",
|
||||
role: "assistant",
|
||||
content: "After the edit",
|
||||
activityKind: "model",
|
||||
createdAt: 3,
|
||||
};
|
||||
const fileEdit = (status: "editing" | "done"): UIMessage => ({
|
||||
id: "file-edit-in-place",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "edit_file()",
|
||||
traces: ["edit_file()"],
|
||||
fileEdits: [{
|
||||
call_id: "call-edit-in-place",
|
||||
tool: "edit_file",
|
||||
path: "src/app.tsx",
|
||||
phase: status === "editing" ? "start" : "end",
|
||||
added: status === "editing" ? 0 : 2,
|
||||
deleted: 0,
|
||||
approximate: false,
|
||||
status,
|
||||
}],
|
||||
createdAt: 2,
|
||||
});
|
||||
const assertBetween = (middle: HTMLElement) => {
|
||||
const beforeElement = screen.getByText("Before the edit");
|
||||
const afterElement = screen.getByText("After the edit");
|
||||
expect(beforeElement.compareDocumentPosition(middle) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
.toBeTruthy();
|
||||
expect(middle.compareDocumentPosition(afterElement) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
.toBeTruthy();
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={[before, fileEdit("editing"), after]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
assertBetween(screen.getByText("Editing"));
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={[before, fileEdit("done"), after]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
assertBetween(screen.getByText("Edited"));
|
||||
});
|
||||
|
||||
it("renders file edit diffs and responds to preference changes", () => {
|
||||
localStorage.setItem(
|
||||
"nanobot-webui.settings-preferences",
|
||||
|
||||
@@ -322,7 +322,10 @@ const MODEL_PRESETS = [
|
||||
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
|
||||
];
|
||||
|
||||
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
function renderPresetComposer(
|
||||
variant: "thread" | "hero" = "thread",
|
||||
onManageModels?: () => void,
|
||||
) {
|
||||
const onPresetChange = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
@@ -332,6 +335,7 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
modelProvider="moonshot"
|
||||
modelPresets={MODEL_PRESETS}
|
||||
onModelPresetChange={onPresetChange}
|
||||
onManageModels={onManageModels}
|
||||
placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."}
|
||||
variant={variant}
|
||||
/>,
|
||||
@@ -610,6 +614,18 @@ describe("ThreadComposer", () => {
|
||||
expect(badge).toHaveClass("w-fit");
|
||||
});
|
||||
|
||||
it("opens model settings from the picker footer", async () => {
|
||||
const onManageModels = vi.fn();
|
||||
const { badge } = renderPresetComposer("thread", onManageModels);
|
||||
|
||||
fireEvent.click(badge);
|
||||
const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
|
||||
fireEvent.click(within(picker).getByRole("button", { name: "Manage models" }));
|
||||
|
||||
expect(onManageModels).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("keeps long-press drag switching alongside the click picker", () => {
|
||||
vi.useFakeTimers();
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
|
||||
@@ -417,6 +417,52 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("moves the session handle into the pane only when the workbench is split", () => {
|
||||
const client = makeClient();
|
||||
const portal = document.createElement("div");
|
||||
document.body.append(portal);
|
||||
const activeSession = {
|
||||
...session("pane-handle"),
|
||||
handle: {
|
||||
id: "handle_11111111111111111111111111111111",
|
||||
name: "soro",
|
||||
},
|
||||
};
|
||||
|
||||
const { unmount } = render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title="Single pane"
|
||||
onToggleSidebar={() => {}}
|
||||
hideHeaderTitle
|
||||
headerPortalTarget={portal}
|
||||
/>,
|
||||
));
|
||||
|
||||
expect(within(portal).getByText("@soro")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Session @soro")).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
const splitView = render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title="Split pane"
|
||||
onToggleSidebar={() => {}}
|
||||
hideHeaderTitle
|
||||
inlineHandle
|
||||
headerPortalTarget={portal}
|
||||
/>,
|
||||
));
|
||||
|
||||
expect(screen.getByLabelText("Session @soro")).toHaveTextContent("@soro");
|
||||
expect(within(portal).queryByText("@soro")).not.toBeInTheDocument();
|
||||
|
||||
splitView.unmount();
|
||||
portal.remove();
|
||||
});
|
||||
|
||||
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
|
||||
await preloadMarkdownText();
|
||||
const client = makeClient();
|
||||
|
||||
@@ -123,6 +123,25 @@ function stubResizeObserver() {
|
||||
};
|
||||
}
|
||||
|
||||
function stubElementsFromPoint(resolve: () => Element[]) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(document, "elementsFromPoint");
|
||||
const mock = vi.fn(resolve);
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
configurable: true,
|
||||
value: mock,
|
||||
});
|
||||
return {
|
||||
mock,
|
||||
restore: () => {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(document, "elementsFromPoint", descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(document, "elementsFromPoint");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeLongMessages(count: number): UIMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
@@ -259,7 +278,9 @@ describe("ThreadViewport", () => {
|
||||
const messageRegion = screen.getByTestId("thread-message-region");
|
||||
expect(messageRegion).toHaveClass("justify-start");
|
||||
expect(messageRegion).not.toHaveClass("justify-end");
|
||||
expect(messageRegion).toHaveClass("pb-4");
|
||||
expect(messageRegion).toHaveClass("thread-message-viewport");
|
||||
expect(messageRegion).toHaveClass("pt-3");
|
||||
expect(messageRegion).toHaveClass("pb-0");
|
||||
expect(messageRegion.className).not.toContain("5rem");
|
||||
});
|
||||
|
||||
@@ -316,7 +337,9 @@ describe("ThreadViewport", () => {
|
||||
expect(scroller).not.toContainElement(composerDock);
|
||||
expect(scroller.parentElement).toContainElement(composerDock);
|
||||
expect(composerDock).toHaveClass("relative");
|
||||
expect(composerDock).toHaveClass("thread-composer-dock");
|
||||
expect(composerDock).not.toHaveClass("sticky");
|
||||
expect(scroller.querySelector(".thread-message-end-gap")).toBeInTheDocument();
|
||||
expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0");
|
||||
});
|
||||
|
||||
@@ -1477,6 +1500,108 @@ describe("ThreadViewport", () => {
|
||||
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("prefetches earlier history within half a viewport of the top", () => {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={makeLongMessages(300)}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = getScroller(container);
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 301 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
dispatchUserScroll(scroller);
|
||||
});
|
||||
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
||||
|
||||
scroller.scrollTop = 250;
|
||||
act(() => {
|
||||
dispatchUserScroll(scroller);
|
||||
});
|
||||
expect(screen.getByText("message 20")).toBeInTheDocument();
|
||||
expect(screen.queryByText("message 19")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the first visible history item fixed while deferred rows materialize", () => {
|
||||
const resizeObserver = stubResizeObserver();
|
||||
let hitTarget: Element | null = null;
|
||||
const hitTest = stubElementsFromPoint(() => hitTarget ? [hitTarget] : []);
|
||||
try {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={makeLongMessages(300)}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = getScroller(container);
|
||||
let scrollHeight = 2_400;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, get: () => scrollHeight },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 80 },
|
||||
getBoundingClientRect: {
|
||||
configurable: true,
|
||||
value: () => DOMRect.fromRect({ y: 0, width: 800, height: 600 }),
|
||||
},
|
||||
});
|
||||
|
||||
const anchor = screen.getByText("message 140")
|
||||
.closest<HTMLElement>("[data-thread-display-unit]");
|
||||
expect(anchor).not.toBeNull();
|
||||
hitTarget = anchor;
|
||||
let anchorDocumentTop = 200;
|
||||
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => DOMRect.fromRect({
|
||||
y: anchorDocumentTop - scroller.scrollTop,
|
||||
width: 800,
|
||||
height: 40,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
dispatchUserScroll(scroller);
|
||||
});
|
||||
expect(hitTest.mock).toHaveBeenCalled();
|
||||
|
||||
const replacement = anchor.cloneNode(true) as HTMLElement;
|
||||
anchor.replaceWith(replacement);
|
||||
anchorDocumentTop += 180;
|
||||
scrollHeight += 180;
|
||||
Object.defineProperty(replacement, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => DOMRect.fromRect({
|
||||
y: anchorDocumentTop - scroller.scrollTop,
|
||||
width: 800,
|
||||
height: 40,
|
||||
}),
|
||||
});
|
||||
const content = screen.getByTestId("thread-message-region").firstElementChild;
|
||||
const observer = resizeObserver.observers.find((candidate) =>
|
||||
content ? candidate.elements.includes(content) : false,
|
||||
);
|
||||
expect(observer).toBeDefined();
|
||||
act(() => {
|
||||
observer?.callback([], observer as unknown as ResizeObserver);
|
||||
});
|
||||
|
||||
expect(scroller.scrollTop).toBe(260);
|
||||
expect(replacement.getBoundingClientRect().top).toBe(120);
|
||||
} finally {
|
||||
hitTest.restore();
|
||||
resizeObserver.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("automatically requests older transcript pages near the top", () => {
|
||||
const onLoadOlder = vi.fn();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user