Compare commits

...
26 changed files with 598 additions and 58 deletions
+4 -2
View File
@@ -23,6 +23,7 @@ from nanobot.cli.webui_support import (
_gateway_health_bind_note, _gateway_health_bind_note,
_gateway_health_url, _gateway_health_url,
_host_for_local_browser, _host_for_local_browser,
_launch_browser,
_prepare_webui_bundle_for_gateway, _prepare_webui_bundle_for_gateway,
_print_foreground_port_conflict, _print_foreground_port_conflict,
_tcp_endpoint_reachable, _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.""" """Wait for the gateway to bind, then point the user's browser at the webui."""
if not open_browser_url: if not open_browser_url:
return return
import webbrowser
from urllib.parse import urlparse from urllib.parse import urlparse
# Channels start asynchronously. When the caller supplies a backend # Channels start asynchronously. When the caller supplies a backend
@@ -896,8 +896,10 @@ def _run_gateway(
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
display_url = _webui_display_url(open_browser_url) display_url = _webui_display_url(open_browser_url)
try: try:
webbrowser.open(open_browser_url) if _launch_browser(open_browser_url):
console.print(f"[green]✓[/green] Opened browser at {display_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: except Exception as e:
console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]") console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]")
+20 -3
View File
@@ -1,7 +1,9 @@
"""Shared WebUI setup, URL, health, and browser helpers.""" """Shared WebUI setup, URL, health, and browser helpers."""
import subprocess
import sys import sys
import time import time
import webbrowser
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -40,6 +42,7 @@ __all__ = [
"_gateway_instance_command", "_gateway_instance_command",
"_host_for_local_browser", "_host_for_local_browser",
"_load_webui_setup_config", "_load_webui_setup_config",
"_launch_browser",
"_open_webui_browser", "_open_webui_browser",
"_prepare_webui_bundle_for_gateway", "_prepare_webui_bundle_for_gateway",
"_print_foreground_port_conflict", "_print_foreground_port_conflict",
@@ -60,6 +63,20 @@ __all__ = [
console = Console() 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: def _confirm_webui_action(message: str, *, yes: bool) -> None:
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells.""" """Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
if yes: if yes:
@@ -419,14 +436,14 @@ def _print_foreground_port_conflict(
def _open_webui_browser(url: str, *, wait: bool = True) -> None: def _open_webui_browser(url: str, *, wait: bool = True) -> None:
"""Open the WebUI in the user's default browser, with a copyable fallback.""" """Open the WebUI in the user's default browser, with a copyable fallback."""
import webbrowser
if wait: if wait:
_wait_for_webui(url) _wait_for_webui(url)
display_url = _webui_display_url(url) display_url = _webui_display_url(url)
try: try:
webbrowser.open(url) if _launch_browser(url):
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]") 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: except Exception as exc:
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]") console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
+41 -1
View File
@@ -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: def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
opened: list[str] = [] opened: list[str] = []
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret" 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) 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 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: def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json" config_file = tmp_path / "config.json"
config_file.write_text("{}") config_file.write_text("{}")
+1
View File
@@ -2816,6 +2816,7 @@ function Shell({
hostChromeTitleInset={hostSidebarCollapsed} hostChromeTitleInset={hostSidebarCollapsed}
hideThemeButton={!context.active} hideThemeButton={!context.active}
hideHeaderTitle hideHeaderTitle
inlineHandle={workbenchPaneSessions.length > 1}
headerActions={context.headerActions} headerActions={context.headerActions}
headerPortalTarget={context.headerPortalTarget} headerPortalTarget={context.headerPortalTarget}
headerActive={context.active} 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 { import {
CheckCircle2, CheckCircle2,
Clock3, Clock3,
@@ -43,10 +52,11 @@ import {
} from "@/lib/activity-timeline"; } from "@/lib/activity-timeline";
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode"; import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import { logoFallbackUrls } from "@/lib/provider-brand"; import { logoFallbackUrls } from "@/lib/provider-brand";
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces"; import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24; const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
@@ -156,10 +166,14 @@ export function AgentActivityCluster({
const fileEditDisplayMode = useFileEditDisplayMode(); const fileEditDisplayMode = useFileEditDisplayMode();
const pageVisible = usePageVisibility(); const pageVisible = usePageVisibility();
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]); const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
const fileEdits = useMemo( const fileEditsByMessage = useMemo(
() => summarizeFileEdits(collectFileEdits(activityMessages), isTurnStreaming), () => summarizeFileEditsByMessage(activityMessages, isTurnStreaming),
[activityMessages, isTurnStreaming], [activityMessages, isTurnStreaming],
); );
const fileEdits = useMemo(
() => [...fileEditsByMessage.values()].flat(),
[fileEditsByMessage],
);
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]); const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]); const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
const cliAppsByName = useMemo( const cliAppsByName = useMemo(
@@ -348,15 +362,10 @@ export function AgentActivityCluster({
active={isTurnStreaming} active={isTurnStreaming}
cliAppsByName={cliAppsByName} cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName} mcpPresetsByName={mcpPresetsByName}
fileEditsByMessage={fileEditsByMessage}
fileEditDisplayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
/> />
{fileEdits.length ? (
<FileEditGroup
edits={fileEdits}
displayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</ThinkingReasoningShell> </ThinkingReasoningShell>
</div> </div>
); );
@@ -414,12 +423,16 @@ function ActivityMessageTimeline({
active, active,
cliAppsByName, cliAppsByName,
mcpPresetsByName, mcpPresetsByName,
fileEditsByMessage,
fileEditDisplayMode,
onOpenFilePreview, onOpenFilePreview,
}: { }: {
messages: UIMessage[]; messages: UIMessage[];
active: boolean; active: boolean;
cliAppsByName: Map<string, CliAppInfo>; cliAppsByName: Map<string, CliAppInfo>;
mcpPresetsByName: Map<string, McpPresetInfo>; mcpPresetsByName: Map<string, McpPresetInfo>;
fileEditsByMessage: Map<string, FileEditSummary[]>;
fileEditDisplayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
}) { }) {
const items: ReactNode[] = []; const items: ReactNode[] = [];
@@ -447,14 +460,21 @@ function ActivityMessageTimeline({
return; return;
} }
if (message.kind === "trace") { if (message.kind === "trace") {
const fileEdits = fileEditsByMessage.get(message.id) ?? [];
items.push( items.push(
<Fragment key={message.id}>
<ActivityTraceTimeline <ActivityTraceTimeline
key={message.id}
message={message} message={message}
active={active && index === messages.length - 1} active={active && index === messages.length - 1}
cliAppsByName={cliAppsByName} cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName} mcpPresetsByName={mcpPresetsByName}
/>, />
<FileEditGroup
edits={fileEdits}
displayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview}
/>
</Fragment>,
); );
} }
}); });
@@ -1061,16 +1081,6 @@ function fileEditCallKey(edit: UIFileEdit): string {
return `${edit.tool}|${edit.path}`; 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[] { function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
const order: string[] = []; const order: string[] = [];
const byKey = new Map<string, UIFileEdit>(); 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[]; 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[] { function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
return latestFileEditEvents(edits).flatMap((edit) => { return latestFileEditEvents(edits).flatMap((edit) => {
const editing = active && edit.status === "editing"; const editing = active && edit.status === "editing";
@@ -6,7 +6,7 @@ import {
type KeyboardEvent, type KeyboardEvent,
type PointerEvent, type PointerEvent,
} from "react"; } from "react";
import { Check, CircleHelp, Sparkles } from "lucide-react"; import { Check, CircleHelp, SlidersHorizontal, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -85,6 +85,7 @@ interface ModelPresetBadgeProps {
modelPreset?: string | null; modelPreset?: string | null;
modelPresets?: ModelPresetOption[]; modelPresets?: ModelPresetOption[];
onPresetChange?: (name: string) => void; onPresetChange?: (name: string) => void;
onManageModels?: () => void;
onRequestComposerFocus?: () => void; onRequestComposerFocus?: () => void;
provider?: string | null; provider?: string | null;
providerLabel?: string | null; providerLabel?: string | null;
@@ -100,6 +101,7 @@ export function ModelPresetBadge({
modelPreset, modelPreset,
modelPresets = [], modelPresets = [],
onPresetChange, onPresetChange,
onManageModels,
onRequestComposerFocus, onRequestComposerFocus,
provider, provider,
providerLabel, providerLabel,
@@ -156,6 +158,11 @@ export function ModelPresetBadge({
requestAnimationFrame(() => onRequestComposerFocus?.()); requestAnimationFrame(() => onRequestComposerFocus?.());
}; };
const openModelSettings = () => {
setOpen(false);
onManageModels?.();
};
const clearGesture = () => { const clearGesture = () => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (gesture?.timer) clearTimeout(gesture.timer); if (gesture?.timer) clearTimeout(gesture.timer);
@@ -418,6 +425,22 @@ export function ModelPresetBadge({
/> />
))} ))}
</div> </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> </PopoverContent>
</Popover> </Popover>
); );
@@ -300,6 +300,7 @@ interface ThreadComposerProps {
modelNeedsSetup?: boolean; modelNeedsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
onModelBadgeClick?: () => void; onModelBadgeClick?: () => void;
onManageModels?: () => void;
contextUsage?: ComposerContextUsage | null; contextUsage?: ComposerContextUsage | null;
variant?: "thread" | "hero"; variant?: "thread" | "hero";
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
@@ -998,6 +999,7 @@ export function ThreadComposer({
modelNeedsSetup = false, modelNeedsSetup = false,
fallbackModelName = null, fallbackModelName = null,
onModelBadgeClick, onModelBadgeClick,
onManageModels,
contextUsage = null, contextUsage = null,
variant = "thread", variant = "thread",
slashCommands = [], slashCommands = [],
@@ -2539,6 +2541,7 @@ export function ThreadComposer({
modelPreset={modelPreset} modelPreset={modelPreset}
modelPresets={modelPresets} modelPresets={modelPresets}
onPresetChange={onModelPresetChange} onPresetChange={onModelPresetChange}
onManageModels={onManageModels}
onRequestComposerFocus={() => textareaRef.current?.focus()} onRequestComposerFocus={() => textareaRef.current?.focus()}
provider={modelProvider} provider={modelProvider}
providerLabel={modelProviderLabel} providerLabel={modelProviderLabel}
+1 -1
View File
@@ -58,7 +58,7 @@ export function ThreadHeader({
<div <div
data-testid="thread-header" data-testid="thread-header"
className={cn( 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 && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]", !minimal && hostChromeTitleInset && "lg:pl-[128px]",
)} )}
@@ -136,6 +136,7 @@ export function ThreadMessages({
return ( return (
<ThreadDisplayUnit <ThreadDisplayUnit
key={unitKeys[index]} key={unitKeys[index]}
unitKey={unitKeys[index]}
unit={unit} unit={unit}
marginTop={marginTop} marginTop={marginTop}
userPromptId={userPromptId} userPromptId={userPromptId}
@@ -225,6 +226,7 @@ function pendingTurnProjection(
} }
interface ThreadDisplayUnitProps { interface ThreadDisplayUnitProps {
unitKey: string;
unit: DisplayUnit; unit: DisplayUnit;
marginTop: string; marginTop: string;
userPromptId?: string; userPromptId?: string;
@@ -243,6 +245,7 @@ interface ThreadDisplayUnitProps {
} }
const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
unitKey,
unit, unit,
marginTop, marginTop,
userPromptId, userPromptId,
@@ -273,6 +276,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
<> <>
<div <div
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`} className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
data-thread-display-unit={unitKey}
data-user-prompt-id={userPromptId} data-user-prompt-id={userPromptId}
> >
{unit.type === "activity" ? ( {unit.type === "activity" ? (
+6 -2
View File
@@ -346,6 +346,7 @@ interface ThreadShellProps {
hostChromeTitleInset?: boolean; hostChromeTitleInset?: boolean;
hideThemeButton?: boolean; hideThemeButton?: boolean;
hideHeaderTitle?: boolean; hideHeaderTitle?: boolean;
inlineHandle?: boolean;
hideHeader?: boolean; hideHeader?: boolean;
headerActions?: ReactNode; headerActions?: ReactNode;
headerPortalTarget?: HTMLElement | null; headerPortalTarget?: HTMLElement | null;
@@ -645,6 +646,7 @@ export function ThreadShell({
hostChromeTitleInset = false, hostChromeTitleInset = false,
hideThemeButton = false, hideThemeButton = false,
hideHeaderTitle = false, hideHeaderTitle = false,
inlineHandle = false,
hideHeader = false, hideHeader = false,
headerActions, headerActions,
headerPortalTarget, headerPortalTarget,
@@ -1517,6 +1519,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup} modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
onManageModels={onOpenModelSettings}
contextUsage={composerContextUsage} contextUsage={composerContextUsage}
variant={composerVariant} variant={composerVariant}
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
@@ -1565,6 +1568,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup} modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
onManageModels={onOpenModelSettings}
contextUsage={composerContextUsage} contextUsage={composerContextUsage}
variant="hero" variant="hero"
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
@@ -1614,7 +1618,7 @@ export function ThreadShell({
const threadHeader = !hideHeader ? ( const threadHeader = !hideHeader ? (
<ThreadHeader <ThreadHeader
title={title} title={title}
handle={temporary || hideHeaderTitle ? null : session?.handle} handle={temporary || (hideHeaderTitle && inlineHandle) ? null : session?.handle}
onToggleSidebar={onToggleSidebar} onToggleSidebar={onToggleSidebar}
theme={theme} theme={theme}
onToggleTheme={onToggleTheme} onToggleTheme={onToggleTheme}
@@ -1638,7 +1642,7 @@ export function ThreadShell({
return ( return (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden"> <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"> <div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{hideHeaderTitle && !temporary && session?.handle ? ( {hideHeaderTitle && inlineHandle && !temporary && session?.handle ? (
<div <div
aria-label={`Session @${session.handle.name}`} aria-label={`Session @${session.handle.name}`}
className="flex h-8 shrink-0 items-center px-3 text-[12px]" className="flex h-8 shrink-0 items-center px-3 text-[12px]"
+133 -12
View File
@@ -61,7 +61,8 @@ interface ThreadViewportProps {
} }
const NEAR_BOTTOM_PX = 48; 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 DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16; const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
const SCROLL_BUTTON_COMPOSER_GAP_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 INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120; 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[] { export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
if (messages.length <= visibleCount) return messages; if (messages.length <= visibleCount) return messages;
let start = Math.max(0, messages.length - visibleCount); 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 pendingPromptJumpRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef = const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null); useRef<{ height: number; top: number } | null>(null);
const historyScrollAnchorRef = useRef<HistoryScrollAnchor | null>(null);
const composerInputScrollTopRef = useRef<number | null>(null); const composerInputScrollTopRef = useRef<number | null>(null);
const composerDockHeightRef = useRef(0); const composerDockHeightRef = useRef(0);
const [atBottom, setAtBottom] = useState(true); const [atBottom, setAtBottom] = useState(true);
@@ -298,7 +346,53 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
threadMotionRef.current?.takeUserControl(); 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) => { const scrollToBottomNow = useCallback((smooth = false) => {
historyScrollAnchorRef.current = null;
const el = scrollRef.current; const el = scrollRef.current;
const marker = bottomRef.current; const marker = bottomRef.current;
const behavior: ScrollBehavior = smooth ? "smooth" : "auto"; const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
@@ -328,11 +422,15 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const loadEarlierMessages = useCallback(() => { const loadEarlierMessages = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (el) { if (el) {
if (captureHistoryScrollAnchor()) {
restoreScrollAfterPrependRef.current = null;
} else {
restoreScrollAfterPrependRef.current = { restoreScrollAfterPrependRef.current = {
height: el.scrollHeight, height: el.scrollHeight,
top: el.scrollTop, top: el.scrollTop,
}; };
} }
}
threadMotionRef.current?.takeUserControl(); threadMotionRef.current?.takeUserControl();
setAtBottom(false); setAtBottom(false);
if (hiddenMessageCount > 0) { if (hiddenMessageCount > 0) {
@@ -345,13 +443,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT); setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
void onLoadOlder(); void onLoadOlder();
} }
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]); }, [
captureHistoryScrollAnchor,
hasMoreBefore,
hiddenMessageCount,
loadingOlder,
messages.length,
onLoadOlder,
]);
const maybeLoadEarlierFromScroll = useCallback(() => { const maybeLoadEarlierFromScroll = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el || !hasMessages || pendingConversationScrollRef.current) return; if (!el || !hasMessages || pendingConversationScrollRef.current) return;
if (!threadMotionRef.current?.isBrowsingHistory()) return; if (!threadMotionRef.current?.isBrowsingHistory()) return;
if (el.scrollTop > NEAR_TOP_PX) return; if (el.scrollTop > historyPrefetchDistance(el)) return;
if (hiddenMessageCount <= 0 && !hasMoreBefore) return; if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
loadEarlierMessages(); loadEarlierMessages();
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]); }, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
@@ -360,6 +465,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null; const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
if (!scrollEl || !prompt) return false; if (!scrollEl || !prompt) return false;
historyScrollAnchorRef.current = null;
setAtBottom(false); setAtBottom(false);
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight); const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
threadMotionRef.current?.navigateHistoryTo( threadMotionRef.current?.navigateHistoryTo(
@@ -442,6 +548,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
conversationHandoffAnimationRef.current = null; conversationHandoffAnimationRef.current = null;
conversationHandoffPendingRef.current = true; conversationHandoffPendingRef.current = true;
pendingConversationScrollRef.current = true; pendingConversationScrollRef.current = true;
historyScrollAnchorRef.current = null;
restoreScrollAfterPrependRef.current = null;
threadMotionRef.current?.reset(); threadMotionRef.current?.reset();
setAtBottom(true); setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW); setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
@@ -505,17 +613,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => { useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current; const pending = restoreScrollAfterPrependRef.current;
if (!pending) return;
const el = scrollRef.current; const el = scrollRef.current;
restoreScrollAfterPrependRef.current = null; restoreScrollAfterPrependRef.current = null;
if (!el) return; if (!el) return;
if (reconcileHistoryScrollAnchor()) return;
if (!pending) return;
const delta = el.scrollHeight - pending.height; const delta = el.scrollHeight - pending.height;
const nextTop = Math.min( const nextTop = Math.min(
Math.max(0, el.scrollHeight - el.clientHeight), Math.max(0, el.scrollHeight - el.clientHeight),
Math.max(0, pending.top + delta), Math.max(0, pending.top + delta),
); );
threadMotionRef.current?.jumpTo(nextTop); threadMotionRef.current?.jumpTo(nextTop);
}, [visibleMessages.length, messages.length]); }, [reconcileHistoryScrollAnchor, visibleMessages.length, messages.length]);
useLayoutEffect(() => { useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current; const promptId = pendingPromptJumpRef.current;
@@ -593,6 +702,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
threadMotionRef.current?.invalidateGeometry(); threadMotionRef.current?.invalidateGeometry();
}; };
const reconcileObservedGeometry = () => { const reconcileObservedGeometry = () => {
reconcileHistoryScrollAnchor();
threadMotionRef.current?.reconcileObservedGeometry(); threadMotionRef.current?.reconcileObservedGeometry();
}; };
reconcileObservedGeometry(); reconcileObservedGeometry();
@@ -609,7 +719,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
observer?.disconnect(); observer?.disconnect();
window.removeEventListener("resize", invalidateGeometry); window.removeEventListener("resize", invalidateGeometry);
}; };
}, [hasMessages]); }, [hasMessages, reconcileHistoryScrollAnchor]);
useEffect(() => { useEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
@@ -623,7 +733,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
setAtBottom((current) => setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom, current === logicallyAtBottom ? current : logicallyAtBottom,
); );
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll(); if (owner === "user") {
captureHistoryScrollAnchor();
if (allowHistoryLoad) maybeLoadEarlierFromScroll();
} else if (near) {
historyScrollAnchorRef.current = null;
}
}; };
onScroll(false); onScroll(false);
@@ -709,7 +824,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
el.removeEventListener("pointerdown", handlePointerDown); el.removeEventListener("pointerdown", handlePointerDown);
el.removeEventListener("keydown", handleKeyDown); el.removeEventListener("keydown", handleKeyDown);
}; };
}, [hasMessages, maybeLoadEarlierFromScroll, yieldCameraToUser]); }, [
captureHistoryScrollAnchor,
hasMessages,
maybeLoadEarlierFromScroll,
yieldCameraToUser,
]);
return ( return (
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden"> <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} ref={messageRegionRef}
data-testid="thread-message-region" data-testid="thread-message-region"
className={cn( className={cn(
"thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col", "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-4 pt-4 sm:px-4", "scroll-auto justify-start overflow-x-hidden px-3 pb-0 pt-3 sm:px-4",
"[overflow-anchor:none] [scrollbar-width:none]", "[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden", "[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden", hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
@@ -768,6 +888,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
onQuoteSelection={onQuoteSelection} onQuoteSelection={onQuoteSelection}
/> />
</div> </div>
<div aria-hidden className="thread-message-end-gap shrink-0" />
<div ref={bottomRef} aria-hidden className="h-px shrink-0" /> <div ref={bottomRef} aria-hidden className="h-px shrink-0" />
</div> </div>
) : ( ) : (
@@ -808,7 +929,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}} }}
className={cn( className={cn(
"row-start-2 z-10 w-full", "row-start-2 z-10 w-full",
hasMessages ? "relative bg-background" : "relative self-center", hasMessages ? "thread-composer-dock relative" : "relative self-center",
)} )}
> >
<div <div
@@ -840,7 +961,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div <div
aria-hidden 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 ? ( {hasMessages ? (
+28
View File
@@ -377,6 +377,7 @@
* bottom without a remount or a transform clone. * bottom without a remount or a transform clone.
*/ */
.thread-layout { .thread-layout {
--thread-composer-fade-height: 2.25rem;
grid-template-rows: minmax(min-content, 1fr) auto 0fr; grid-template-rows: minmax(min-content, 1fr) auto 0fr;
transition: grid-template-rows 220ms ease-out; 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 { @keyframes composer-status-strip-enter {
0% { 0% {
max-height: 0; max-height: 0;
+1
View File
@@ -1196,6 +1196,7 @@
"modelNotConfigured": "Model not configured", "modelNotConfigured": "Model not configured",
"configureModel": "Configure model", "configureModel": "Configure model",
"switchModel": "Switch model for this chat", "switchModel": "Switch model for this chat",
"manageModels": "Manage models",
"context": { "context": {
"tooltip": "Context · {{tokens}}{{capacity}}", "tooltip": "Context · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% used." "meterDescription": "{{context}}. {{percent}}% used."
+1
View File
@@ -1183,6 +1183,7 @@
"modelNotConfigured": "Modelo no configurado", "modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo", "configureModel": "Configurar modelo",
"switchModel": "Cambiar el modelo de este chat", "switchModel": "Cambiar el modelo de este chat",
"manageModels": "Gestionar modelos",
"context": { "context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}", "tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % usado." "meterDescription": "{{context}}. {{percent}} % usado."
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "Modèle non configuré", "modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle", "configureModel": "Configurer le modèle",
"switchModel": "Changer le modèle de cette conversation", "switchModel": "Changer le modèle de cette conversation",
"manageModels": "Gérer les modèles",
"context": { "context": {
"tooltip": "Contexte · {{tokens}}{{capacity}}", "tooltip": "Contexte · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % utilisé." "meterDescription": "{{context}}. {{percent}} % utilisé."
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "Model belum dikonfigurasi", "modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model", "configureModel": "Konfigurasi model",
"switchModel": "Ganti model untuk percakapan ini", "switchModel": "Ganti model untuk percakapan ini",
"manageModels": "Kelola model",
"context": { "context": {
"tooltip": "Konteks · {{tokens}}{{capacity}}", "tooltip": "Konteks · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% digunakan." "meterDescription": "{{context}}. {{percent}}% digunakan."
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "モデルが未設定です", "modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定", "configureModel": "モデルを設定",
"switchModel": "この会話で使うモデルを切り替える", "switchModel": "この会話で使うモデルを切り替える",
"manageModels": "モデルを管理",
"context": { "context": {
"tooltip": "コンテキスト · {{tokens}}{{capacity}}", "tooltip": "コンテキスト · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。{{percent}}% 使用中" "meterDescription": "{{context}}。{{percent}}% 使用中"
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "모델이 설정되지 않음", "modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정", "configureModel": "모델 설정",
"switchModel": "이 대화에서 사용할 모델 전환", "switchModel": "이 대화에서 사용할 모델 전환",
"manageModels": "모델 관리",
"context": { "context": {
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}", "tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% 사용 중." "meterDescription": "{{context}}. {{percent}}% 사용 중."
+1
View File
@@ -1196,6 +1196,7 @@
"modelNotConfigured": "Modelo não configurado", "modelNotConfigured": "Modelo não configurado",
"configureModel": "Configurar modelo", "configureModel": "Configurar modelo",
"switchModel": "Alternar o modelo desta conversa", "switchModel": "Alternar o modelo desta conversa",
"manageModels": "Gerenciar modelos",
"context": { "context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}", "tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% usado." "meterDescription": "{{context}}. {{percent}}% usado."
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "Chưa cấu hình mô hình", "modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "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", "switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
"manageModels": "Quản lý mô hình",
"context": { "context": {
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}", "tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. Đã dùng {{percent}}%." "meterDescription": "{{context}}. Đã dùng {{percent}}%."
+1
View File
@@ -1195,6 +1195,7 @@
"modelNotConfigured": "模型未配置", "modelNotConfigured": "模型未配置",
"configureModel": "配置模型", "configureModel": "配置模型",
"switchModel": "切换本次对话所用模型", "switchModel": "切换本次对话所用模型",
"manageModels": "管理模型预设",
"context": { "context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}", "tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。" "meterDescription": "{{context}}。已使用 {{percent}}%。"
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "尚未設定模型", "modelNotConfigured": "尚未設定模型",
"configureModel": "設定模型", "configureModel": "設定模型",
"switchModel": "切換此對話使用的模型", "switchModel": "切換此對話使用的模型",
"manageModels": "管理模型預設",
"context": { "context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}", "tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。" "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", () => { it("renders file edit diffs and responds to preference changes", () => {
localStorage.setItem( localStorage.setItem(
"nanobot-webui.settings-preferences", "nanobot-webui.settings-preferences",
+17 -1
View File
@@ -322,7 +322,10 @@ const MODEL_PRESETS = [
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" }, { 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(); const onPresetChange = vi.fn();
render( render(
<ThreadComposer <ThreadComposer
@@ -332,6 +335,7 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
modelProvider="moonshot" modelProvider="moonshot"
modelPresets={MODEL_PRESETS} modelPresets={MODEL_PRESETS}
onModelPresetChange={onPresetChange} onModelPresetChange={onPresetChange}
onManageModels={onManageModels}
placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."} placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."}
variant={variant} variant={variant}
/>, />,
@@ -610,6 +614,18 @@ describe("ThreadComposer", () => {
expect(badge).toHaveClass("w-fit"); 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", () => { it("keeps long-press drag switching alongside the click picker", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer(); const { badge, onPresetChange } = renderPresetComposer();
+46
View File
@@ -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 () => { it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
await preloadMarkdownText(); await preloadMarkdownText();
const client = makeClient(); const client = makeClient();
+126 -1
View File
@@ -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[] { function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({ return Array.from({ length: count }, (_, index) => ({
id: `m${index}`, id: `m${index}`,
@@ -259,7 +278,9 @@ describe("ThreadViewport", () => {
const messageRegion = screen.getByTestId("thread-message-region"); const messageRegion = screen.getByTestId("thread-message-region");
expect(messageRegion).toHaveClass("justify-start"); expect(messageRegion).toHaveClass("justify-start");
expect(messageRegion).not.toHaveClass("justify-end"); 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"); expect(messageRegion.className).not.toContain("5rem");
}); });
@@ -316,7 +337,9 @@ describe("ThreadViewport", () => {
expect(scroller).not.toContainElement(composerDock); expect(scroller).not.toContainElement(composerDock);
expect(scroller.parentElement).toContainElement(composerDock); expect(scroller.parentElement).toContainElement(composerDock);
expect(composerDock).toHaveClass("relative"); expect(composerDock).toHaveClass("relative");
expect(composerDock).toHaveClass("thread-composer-dock");
expect(composerDock).not.toHaveClass("sticky"); expect(composerDock).not.toHaveClass("sticky");
expect(scroller.querySelector(".thread-message-end-gap")).toBeInTheDocument();
expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0"); expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0");
}); });
@@ -1477,6 +1500,108 @@ describe("ThreadViewport", () => {
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0); 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", () => { it("automatically requests older transcript pages near the top", () => {
const onLoadOlder = vi.fn(); const onLoadOlder = vi.fn();