Compare commits

...
26 changed files with 598 additions and 58 deletions
+5 -3
View File
@@ -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]")
+21 -4
View File
@@ -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]")
+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:
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("{}")
+1
View File
@@ -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}
+1 -1
View File
@@ -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" ? (
+6 -2
View File
@@ -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]"
+137 -16
View File
@@ -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 ? (
+28
View File
@@ -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;
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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é."
+1
View File
@@ -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."
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定",
"switchModel": "この会話で使うモデルを切り替える",
"manageModels": "モデルを管理",
"context": {
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。{{percent}}% 使用中"
+1
View File
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정",
"switchModel": "이 대화에서 사용할 모델 전환",
"manageModels": "모델 관리",
"context": {
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% 사용 중."
+1
View File
@@ -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."
+1
View File
@@ -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}}%."
+1
View File
@@ -1195,6 +1195,7 @@
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"switchModel": "切换本次对话所用模型",
"manageModels": "管理模型预设",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
+1
View File
@@ -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",
+17 -1
View File
@@ -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();
+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 () => {
await preloadMarkdownText();
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[] {
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();