fix(web): improve logs panel scroll handling (#2305)

- forward refs through ScrollArea so logs can access the viewport
- keep logs pinned to the bottom only when the user is already near it
- apply import and className ordering cleanup across frontend components
This commit is contained in:
wenjie
2026-04-03 15:37:23 +08:00
committed by GitHub
parent 7f7b4c430b
commit bd56e10bb8
13 changed files with 97 additions and 53 deletions
@@ -38,7 +38,7 @@ export function MarketSkillCard({
return ( return (
<Card <Card
className="group relative overflow-hidden border-border/40 bg-card/40 transition-all hover:border-border/80 hover:bg-card hover:shadow-md" className="group border-border/40 bg-card/40 hover:border-border/80 hover:bg-card relative overflow-hidden transition-all hover:shadow-md"
size="sm" size="sm"
> >
{result.installed && ( {result.installed && (
@@ -51,16 +51,16 @@ export function MarketSkillCard({
<CardTitle className="text-base font-semibold tracking-tight"> <CardTitle className="text-base font-semibold tracking-tight">
{result.display_name || result.slug} {result.display_name || result.slug}
</CardTitle> </CardTitle>
<span className="inline-flex items-center rounded-md bg-muted/60 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase ring-1 ring-inset ring-border/50"> <span className="bg-muted/60 text-muted-foreground ring-border/50 inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold tracking-wider uppercase ring-1 ring-inset">
{result.registry_name} {result.registry_name}
</span> </span>
{result.installed ? ( {result.installed ? (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20"> <span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-emerald-500/20 ring-inset">
{t("pages.agent.skills.marketplace_installed")} {t("pages.agent.skills.marketplace_installed")}
</span> </span>
) : null} ) : null}
</div> </div>
<div className="font-mono text-xs text-muted-foreground opacity-80"> <div className="text-muted-foreground font-mono text-xs opacity-80">
{result.slug} {result.slug}
{result.version ? ( {result.version ? (
<span className="text-muted-foreground/60"> <span className="text-muted-foreground/60">
@@ -78,7 +78,7 @@ export function MarketSkillCard({
href={result.url} href={result.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex text-xs text-primary/80 transition-colors hover:text-primary hover:underline hover:underline-offset-4" className="text-primary/80 hover:text-primary inline-flex text-xs transition-colors hover:underline hover:underline-offset-4"
> >
{result.url} {result.url}
</a> </a>
@@ -109,7 +109,7 @@ export function MarketSkillCard({
variant="outline" variant="outline"
size="xs" size="xs"
onClick={onViewInstalled} onClick={onViewInstalled}
className="w-full shadow-sm hover:bg-muted" className="hover:bg-muted w-full shadow-sm"
> >
<IconFileInfo className="mr-1 size-3.5" /> <IconFileInfo className="mr-1 size-3.5" />
{t("pages.agent.skills.marketplace_view_installed")} {t("pages.agent.skills.marketplace_view_installed")}
@@ -2,7 +2,9 @@ import type { TFunction } from "i18next"
import type { ToolSupportItem } from "@/api/tools" import type { ToolSupportItem } from "@/api/tools"
type MarketplaceTool = Pick<ToolSupportItem, "status" | "reason_code"> | undefined type MarketplaceTool =
| Pick<ToolSupportItem, "status" | "reason_code">
| undefined
export interface UnavailableToolMessage { export interface UnavailableToolMessage {
key: "search" | "install" key: "search" | "install"
@@ -5,17 +5,17 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { useEffect, useRef, useState, type UIEvent } from "react" import { type UIEvent, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
type SkillRegistrySearchResult,
type SkillSearchResponse,
type SkillSupportItem,
getSkills, getSkills,
installSkill, installSkill,
searchSkills, searchSkills,
type SkillSearchResponse,
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills" } from "@/api/skills"
import { getTools } from "@/api/tools" import { getTools } from "@/api/tools"
@@ -71,7 +71,7 @@ export function useHubMarketplace() {
Number(pageParam) || 0, Number(pageParam) || 0,
), ),
getNextPageParam: (lastPage: SkillSearchResponse) => getNextPageParam: (lastPage: SkillSearchResponse) =>
lastPage.has_more ? lastPage.next_offset ?? undefined : undefined, lastPage.has_more ? (lastPage.next_offset ?? undefined) : undefined,
enabled: isMarketSearchActive, enabled: isMarketSearchActive,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
refetchOnMount: false, refetchOnMount: false,
@@ -112,9 +112,7 @@ export function useHubMarketplace() {
!marketSearchData && !marketSearchData &&
(isMarketSearchPending || isMarketSearchFetching) (isMarketSearchPending || isMarketSearchFetching)
const isMarketSearchLoadingMore = const isMarketSearchLoadingMore =
isMarketSearchActive && isMarketSearchActive && Boolean(marketSearchData) && isFetchingNextPage
Boolean(marketSearchData) &&
isFetchingNextPage
const installPendingKey = const installPendingKey =
installMutation.isPending && installMutation.variables installMutation.isPending && installMutation.variables
? `${installMutation.variables.registry}:${installMutation.variables.slug}` ? `${installMutation.variables.registry}:${installMutation.variables.slug}`
@@ -179,7 +177,9 @@ export function useHubMarketplace() {
void fetchNextPage() void fetchNextPage()
} }
const getInstalledSkill = (installedName?: string): SkillSupportItem | null => { const getInstalledSkill = (
installedName?: string,
): SkillSupportItem | null => {
if (!installedName) { if (!installedName) {
return null return null
} }
@@ -1,3 +1,6 @@
import { IconLoader2, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { SkillSupportItem } from "@/api/skills" import type { SkillSupportItem } from "@/api/skills"
import { import {
AlertDialog, AlertDialog,
@@ -9,8 +12,6 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { IconLoader2, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
interface DeleteDialogProps { interface DeleteDialogProps {
open: boolean open: boolean
@@ -23,10 +23,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { OriginBadge } from "./origin-badge" import { OriginBadge } from "./origin-badge"
import { import { getOriginLabel, getSkillOriginKind } from "./origin-utils"
getOriginLabel,
getSkillOriginKind,
} from "./origin-utils"
import type { SkillDetailView } from "./types" import type { SkillDetailView } from "./types"
const DETAIL_VIEWS = [ const DETAIL_VIEWS = [
@@ -86,7 +83,8 @@ export function DetailSheet({
</div> </div>
<div className="min-w-0 flex-1 space-y-1 text-left"> <div className="min-w-0 flex-1 space-y-1 text-left">
<SheetTitle className="truncate text-xl font-bold tracking-tight"> <SheetTitle className="truncate text-xl font-bold tracking-tight">
{activeSkillDetail?.name || t("pages.agent.skills.viewer_title")} {activeSkillDetail?.name ||
t("pages.agent.skills.viewer_title")}
</SheetTitle> </SheetTitle>
<SheetDescription className="line-clamp-2"> <SheetDescription className="line-clamp-2">
{activeSkillDetail?.description || {activeSkillDetail?.description ||
@@ -1,8 +1,4 @@
import { import { IconLayoutGrid, IconLayoutList, IconSearch } from "@tabler/icons-react"
IconLayoutGrid,
IconLayoutList,
IconSearch,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -150,7 +150,10 @@ export function useSkillsPage() {
}, [allSkills, normalizedSearchQuery, sourceFilter]) }, [allSkills, normalizedSearchQuery, sourceFilter])
const sortedSkills = useMemo( const sortedSkills = useMemo(
() => [...filteredSkills].sort((left, right) => compareSkills(left, right, sortOrder)), () =>
[...filteredSkills].sort((left, right) =>
compareSkills(left, right, sortOrder),
),
[filteredSkills, sortOrder], [filteredSkills, sortOrder],
) )
@@ -4,6 +4,15 @@ import { useTranslation } from "react-i18next"
import { AnsiLogLine } from "@/components/logs/ansi-log-line" import { AnsiLogLine } from "@/components/logs/ansi-log-line"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
const AUTO_SCROLL_THRESHOLD_PX = 24
function isNearBottom(viewport: HTMLDivElement) {
const distanceToBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
return distanceToBottom <= AUTO_SCROLL_THRESHOLD_PX
}
type LogsPanelProps = { type LogsPanelProps = {
logs: string[] logs: string[]
wrapColumns: number wrapColumns: number
@@ -18,17 +27,57 @@ export function LogsPanel({
measureRef, measureRef,
}: LogsPanelProps) { }: LogsPanelProps) {
const { t } = useTranslation() const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const shouldStickToBottomRef = useRef(true)
useEffect(() => { useEffect(() => {
if (scrollRef.current) { const scrollArea = scrollAreaRef.current
scrollRef.current.scrollIntoView({ behavior: "smooth" }) const viewport = scrollArea?.querySelector<HTMLDivElement>(
'[data-slot="scroll-area-viewport"]',
)
if (!viewport) {
return
}
viewportRef.current = viewport
const updateStickToBottom = () => {
shouldStickToBottomRef.current = isNearBottom(viewport)
}
updateStickToBottom()
viewport.addEventListener("scroll", updateStickToBottom)
return () => {
viewport.removeEventListener("scroll", updateStickToBottom)
if (viewportRef.current === viewport) {
viewportRef.current = null
}
}
}, [])
useEffect(() => {
const viewport = viewportRef.current
if (!viewport) {
return
}
// Clearing logs or switching runs can replace the buffer with much shorter
// content, so a previously stale "not sticky" state needs to be rechecked.
if (!shouldStickToBottomRef.current) {
shouldStickToBottomRef.current = isNearBottom(viewport)
}
if (shouldStickToBottomRef.current) {
viewport.scrollTop = viewport.scrollHeight
} }
}, [logs]) }, [logs])
return ( return (
<div className="relative flex-1 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-100"> <div className="relative flex-1 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-100">
<ScrollArea className="h-full"> <ScrollArea ref={scrollAreaRef} className="h-full">
<div <div
ref={contentRef} ref={contentRef}
className="relative p-4 font-mono text-sm leading-relaxed" className="relative p-4 font-mono text-sm leading-relaxed"
@@ -47,7 +96,6 @@ export function LogsPanel({
<AnsiLogLine key={index} line={log} wrapColumns={wrapColumns} /> <AnsiLogLine key={index} line={log} wrapColumns={wrapColumns} />
)) ))
)} )}
<div ref={scrollRef} />
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
@@ -161,9 +161,7 @@ export function EditModelSheet({
{!isOAuth && ( {!isOAuth && (
<Field <Field
label={t("models.field.apiKey")} label={t("models.field.apiKey")}
hint={ hint={hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined}
hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined
}
> >
<KeyInput <KeyInput
value={form.apiKey} value={form.apiKey}
@@ -53,7 +53,7 @@ export function ModelCard({
? "bg-green-500" ? "bg-green-500"
: status === "unreachable" : status === "unreachable"
? "bg-amber-500" ? "bg-amber-500"
: "bg-muted-foreground/25", : "bg-muted-foreground/25",
].join(" ")} ].join(" ")}
title={statusLabel} title={statusLabel}
/> />
@@ -7,14 +7,14 @@ import { useAtom } from "jotai"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { import {
type TourStep,
tourAtom, tourAtom,
tourCurrentStepAtom, tourCurrentStepAtom,
tourIsActiveAtom, tourIsActiveAtom,
type TourStep,
useTourActions, useTourActions,
} from "@/store/tour" } from "@/store/tour"
import { cn } from "@/lib/utils"
interface TourStepConfig { interface TourStepConfig {
title: string title: string
@@ -177,7 +177,7 @@ export function TourGuide() {
{targetElement && ( {targetElement && (
<div <div
className="pointer-events-none fixed z-[101] rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background transition-all duration-300" className="ring-primary ring-offset-background pointer-events-none fixed z-[101] rounded-lg ring-2 ring-offset-2 transition-all duration-300"
style={{ style={{
top: targetElement.getBoundingClientRect().top - 4, top: targetElement.getBoundingClientRect().top - 4,
left: targetElement.getBoundingClientRect().left - 4, left: targetElement.getBoundingClientRect().left - 4,
@@ -189,7 +189,7 @@ export function TourGuide() {
<div <div
className={cn( className={cn(
"fixed z-[102] w-80 rounded-xl border bg-background p-4 shadow-2xl", "bg-background fixed z-[102] w-80 rounded-xl border p-4 shadow-2xl",
isCentered && "max-w-md", isCentered && "max-w-md",
)} )}
style={position} style={position}
@@ -3,13 +3,13 @@ import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function ScrollArea({ const ScrollArea = React.forwardRef<
className, React.ElementRef<typeof ScrollAreaPrimitive.Root>,
children, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
...props >(({ className, children, ...props }, ref) => {
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
ref={ref}
data-slot="scroll-area" data-slot="scroll-area"
className={cn("relative", className)} className={cn("relative", className)}
{...props} {...props}
@@ -24,7 +24,9 @@ function ScrollArea({
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
) )
} })
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
function ScrollBar({ function ScrollBar({
className, className,
+1 -5
View File
@@ -1,8 +1,4 @@
import { import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
Outlet,
createRootRoute,
useRouterState,
} from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react" import { useEffect } from "react"