mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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,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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user