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 (
<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"
>
{result.installed && (
@@ -51,16 +51,16 @@ export function MarketSkillCard({
<CardTitle className="text-base font-semibold tracking-tight">
{result.display_name || result.slug}
</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}
</span>
{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")}
</span>
) : null}
</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.version ? (
<span className="text-muted-foreground/60">
@@ -78,7 +78,7 @@ export function MarketSkillCard({
href={result.url}
target="_blank"
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}
</a>
@@ -109,7 +109,7 @@ export function MarketSkillCard({
variant="outline"
size="xs"
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" />
{t("pages.agent.skills.marketplace_view_installed")}
@@ -2,7 +2,9 @@ import type { TFunction } from "i18next"
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 {
key: "search" | "install"
@@ -5,17 +5,17 @@ import {
useQueryClient,
} from "@tanstack/react-query"
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 { toast } from "sonner"
import {
type SkillRegistrySearchResult,
type SkillSearchResponse,
type SkillSupportItem,
getSkills,
installSkill,
searchSkills,
type SkillSearchResponse,
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { getTools } from "@/api/tools"
@@ -71,7 +71,7 @@ export function useHubMarketplace() {
Number(pageParam) || 0,
),
getNextPageParam: (lastPage: SkillSearchResponse) =>
lastPage.has_more ? lastPage.next_offset ?? undefined : undefined,
lastPage.has_more ? (lastPage.next_offset ?? undefined) : undefined,
enabled: isMarketSearchActive,
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
@@ -112,9 +112,7 @@ export function useHubMarketplace() {
!marketSearchData &&
(isMarketSearchPending || isMarketSearchFetching)
const isMarketSearchLoadingMore =
isMarketSearchActive &&
Boolean(marketSearchData) &&
isFetchingNextPage
isMarketSearchActive && Boolean(marketSearchData) && isFetchingNextPage
const installPendingKey =
installMutation.isPending && installMutation.variables
? `${installMutation.variables.registry}:${installMutation.variables.slug}`
@@ -179,7 +177,9 @@ export function useHubMarketplace() {
void fetchNextPage()
}
const getInstalledSkill = (installedName?: string): SkillSupportItem | null => {
const getInstalledSkill = (
installedName?: string,
): SkillSupportItem | null => {
if (!installedName) {
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 {
AlertDialog,
@@ -9,8 +12,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { IconLoader2, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
interface DeleteDialogProps {
open: boolean
@@ -23,10 +23,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { OriginBadge } from "./origin-badge"
import {
getOriginLabel,
getSkillOriginKind,
} from "./origin-utils"
import { getOriginLabel, getSkillOriginKind } from "./origin-utils"
import type { SkillDetailView } from "./types"
const DETAIL_VIEWS = [
@@ -86,7 +83,8 @@ export function DetailSheet({
</div>
<div className="min-w-0 flex-1 space-y-1 text-left">
<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>
<SheetDescription className="line-clamp-2">
{activeSkillDetail?.description ||
@@ -1,8 +1,4 @@
import {
IconLayoutGrid,
IconLayoutList,
IconSearch,
} from "@tabler/icons-react"
import { IconLayoutGrid, IconLayoutList, IconSearch } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
@@ -150,7 +150,10 @@ export function useSkillsPage() {
}, [allSkills, normalizedSearchQuery, sourceFilter])
const sortedSkills = useMemo(
() => [...filteredSkills].sort((left, right) => compareSkills(left, right, sortOrder)),
() =>
[...filteredSkills].sort((left, right) =>
compareSkills(left, right, sortOrder),
),
[filteredSkills, sortOrder],
)
@@ -4,6 +4,15 @@ import { useTranslation } from "react-i18next"
import { AnsiLogLine } from "@/components/logs/ansi-log-line"
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 = {
logs: string[]
wrapColumns: number
@@ -18,17 +27,57 @@ export function LogsPanel({
measureRef,
}: LogsPanelProps) {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const shouldStickToBottomRef = useRef(true)
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
const scrollArea = scrollAreaRef.current
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])
return (
<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
ref={contentRef}
className="relative p-4 font-mono text-sm leading-relaxed"
@@ -47,7 +96,6 @@ export function LogsPanel({
<AnsiLogLine key={index} line={log} wrapColumns={wrapColumns} />
))
)}
<div ref={scrollRef} />
</div>
</ScrollArea>
</div>
@@ -161,9 +161,7 @@ export function EditModelSheet({
{!isOAuth && (
<Field
label={t("models.field.apiKey")}
hint={
hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined
}
hint={hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined}
>
<KeyInput
value={form.apiKey}
@@ -53,7 +53,7 @@ export function ModelCard({
? "bg-green-500"
: status === "unreachable"
? "bg-amber-500"
: "bg-muted-foreground/25",
: "bg-muted-foreground/25",
].join(" ")}
title={statusLabel}
/>
@@ -7,14 +7,14 @@ import { useAtom } from "jotai"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
type TourStep,
tourAtom,
tourCurrentStepAtom,
tourIsActiveAtom,
type TourStep,
useTourActions,
} from "@/store/tour"
import { cn } from "@/lib/utils"
interface TourStepConfig {
title: string
@@ -177,7 +177,7 @@ export function TourGuide() {
{targetElement && (
<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={{
top: targetElement.getBoundingClientRect().top - 4,
left: targetElement.getBoundingClientRect().left - 4,
@@ -189,7 +189,7 @@ export function TourGuide() {
<div
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",
)}
style={position}
@@ -3,13 +3,13 @@ import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => {
return (
<ScrollAreaPrimitive.Root
ref={ref}
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
@@ -24,7 +24,9 @@ function ScrollArea({
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
})
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
function ScrollBar({
className,
+1 -5
View File
@@ -1,8 +1,4 @@
import {
Outlet,
createRootRoute,
useRouterState,
} from "@tanstack/react-router"
import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react"