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 (
|
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,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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user