feat: add semantic motion system (#1320)

* feat: add semantic motion system

* feat: animate session workspace transitions

* feat: refine motion accessibility and transfer feedback
This commit is contained in:
ZacharyZcR
2026-08-24 19:36:10 +08:00
committed by GitHub
parent fafd428072
commit 69002e6416
15 changed files with 569 additions and 90 deletions
+3 -2
View File
@@ -2295,6 +2295,7 @@ export function AppShell({
node.style.pointerEvents = activeInline ? "auto" : "none";
node.style.zIndex = activeInline ? "1" : "0";
} else {
node.classList.toggle("motion-workspace-enter", activeInline);
node.style.visibility = "";
node.style.pointerEvents = "";
node.style.zIndex = activeInline ? "2" : "";
@@ -2824,7 +2825,7 @@ export function AppShell({
<div
inert={settingsFullscreen ? true : undefined}
aria-hidden={settingsFullscreen || undefined}
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
>
{!isMobile && !sidebarOpen && (
<button
@@ -2864,7 +2865,7 @@ export function AppShell({
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
{!isMobile && (
<div
className="absolute inset-0"
className="motion-workspace-layout absolute inset-0"
style={{
display: isSplit ? "flex" : "none",
flexDirection: "column",
+1 -1
View File
@@ -6,7 +6,7 @@ import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-[color,background-color,border-color,opacity,box-shadow,transform] outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -49,7 +49,13 @@ export function ConnectionScreen({
return (
<div
className={cn("absolute inset-0 z-[100] flex flex-col", className)}
role="status"
aria-live="polite"
data-status={status}
className={cn(
"motion-context-enter absolute inset-0 z-[100] flex flex-col",
className,
)}
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
>
<div className="flex-1 min-h-0 flex items-center justify-center">
@@ -57,7 +63,15 @@ export function ConnectionScreen({
emptyState
) : (
<div className="flex flex-col items-center gap-4 px-6 text-center">
{showSpinner && <div className="simple-spinner" />}
{showSpinner && (
<div
className="connection-signal"
data-active="true"
aria-hidden="true"
>
<span className="connection-signal-core" />
</div>
)}
{message && (
<p className="text-sm text-foreground-secondary font-medium">
{message}
@@ -105,23 +119,6 @@ export function ConnectionScreen({
position={logPosition}
/>
)}
<style>
{`
@keyframes connection-screen-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.simple-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border-base);
border-top-color: var(--foreground);
border-radius: 50%;
animation: connection-screen-spin 0.8s linear infinite;
}
`}
</style>
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-[background-color,border-color,box-shadow,opacity] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
@@ -1,5 +1,7 @@
import { formatTransferMbPerSec } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface DownloadProgressToastProps {
fileName: string;
@@ -22,26 +24,6 @@ function formatBytes(bytes: number): string {
return `${formattedSize} ${units[unitIndex]}`;
}
function IndeterminateProgressBar() {
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
</div>
);
}
function DeterminateProgressBar({ value }: { value: number }) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
export function DownloadProgressToast({
fileName,
loaded,
@@ -58,14 +40,16 @@ export function DownloadProgressToast({
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<p className="text-sm font-medium leading-tight truncate">
{t("fileManager.downloadingFile", { name: fileName })}
<p className="flex items-center gap-2 text-sm font-medium leading-tight">
<ArrowDownToLine className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">
{t("fileManager.downloadingFile", { name: fileName })}
</span>
</p>
{percent === undefined ? (
<IndeterminateProgressBar />
) : (
<DeterminateProgressBar value={percent} />
)}
<TransferProgressBar
value={percent}
label={t("fileManager.downloadingFile", { name: fileName })}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{total !== undefined
@@ -0,0 +1,42 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
export function TransferProgressBar({
value,
label,
stalled = false,
}: {
value?: number;
label: string;
stalled?: boolean;
}) {
const clamped = Math.min(100, Math.max(0, value ?? 0));
const indeterminate = value === undefined;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : clamped}
aria-busy={indeterminate}
data-stalled={stalled || undefined}
className="transfer-progress-track"
>
<div
className={cn(
"transfer-progress-fill",
indeterminate && "transfer-progress-indeterminate",
stalled && "transfer-progress-stalled",
)}
style={
indeterminate
? undefined
: ({ "--transfer-progress": clamped / 100 } as CSSProperties)
}
/>
</div>
);
}
@@ -5,6 +5,15 @@ import {
type TransferProgressResponse,
} from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import {
Archive,
ArchiveRestore,
ArrowRight,
Gauge,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface TransferProgressToastProps {
status: TransferProgressResponse;
@@ -15,26 +24,6 @@ interface TransferProgressToastProps {
cancelling?: boolean;
}
function IndeterminateProgressBar() {
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
</div>
);
}
function DeterminateProgressBar({ value }: { value: number }) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
export function TransferProgressToast({
status,
liveMbPerSec,
@@ -103,11 +92,29 @@ export function TransferProgressToast({
const showIndeterminate =
status.phase === "reconnecting" || percent === undefined;
const PhaseIcon =
status.phase === "compressing"
? Archive
: status.phase === "benchmarking"
? Gauge
: status.phase === "verifying"
? ShieldCheck
: status.phase === "extracting"
? ArchiveRestore
: status.phase === "reconnecting"
? RefreshCw
: ArrowRight;
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-tight">{title}</p>
<p
key={status.phase}
className="motion-transfer-phase flex min-w-0 items-center gap-2 text-sm font-medium leading-tight"
>
<PhaseIcon className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">{title}</span>
</p>
{onCancel && status.status === "running" && status.transferId && (
<Button
type="button"
@@ -123,11 +130,11 @@ export function TransferProgressToast({
</Button>
)}
</div>
{showIndeterminate ? (
<IndeterminateProgressBar />
) : (
<DeterminateProgressBar value={percent} />
)}
<TransferProgressBar
value={showIndeterminate ? undefined : percent}
label={title}
stalled={stalled}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
<span
+265
View File
@@ -101,6 +101,11 @@
:root {
--accent-brand: #f59145;
--motion-duration-micro: 110ms;
--motion-duration-panel: 220ms;
--motion-duration-context: 340ms;
--motion-ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
--motion-ease-exit: cubic-bezier(0.4, 0, 1, 1);
--background: oklch(1 0 0);
--foreground: oklch(0.1802 0.0043 128.73);
--card: oklch(1 0 0);
@@ -537,3 +542,263 @@ html.fs-xl {
.terminal-toolbar-collapsed-shell {
position: relative;
}
/* Motion communicates state and hierarchy. Keep it off the terminal glyph layer. */
.motion-interactive {
transition-duration: var(--motion-duration-micro);
transition-timing-function: var(--motion-ease-enter);
}
.motion-meter {
transition: width var(--motion-duration-panel) var(--motion-ease-enter);
}
.motion-panel-enter {
animation: motion-panel-enter var(--motion-duration-panel)
var(--motion-ease-enter) both;
}
.motion-context-enter {
animation: motion-context-enter var(--motion-duration-context)
var(--motion-ease-enter) both;
}
.motion-workspace-enter {
animation: motion-workspace-enter var(--motion-duration-panel)
var(--motion-ease-enter) both;
}
.motion-workspace-layout {
animation: motion-workspace-layout var(--motion-duration-context)
var(--motion-ease-enter) both;
}
.motion-transfer-phase {
animation: motion-transfer-phase var(--motion-duration-micro)
var(--motion-ease-enter) both;
}
.transfer-progress-track {
position: relative;
height: 5px;
overflow: hidden;
background: color-mix(in oklab, var(--primary) 16%, transparent);
}
.transfer-progress-fill {
width: 100%;
height: 100%;
background: var(--accent-brand);
transform: scaleX(var(--transfer-progress, 0));
transform-origin: left center;
transition: transform var(--motion-duration-panel) var(--motion-ease-enter);
}
.transfer-progress-indeterminate {
width: 32%;
transform: translateX(-110%);
animation: transfer-progress-flow 1.1s var(--motion-ease-enter) infinite;
}
.transfer-progress-stalled {
background: var(--destructive);
animation: none;
}
.transfer-progress-indeterminate.transfer-progress-stalled {
transform: translateX(105%);
}
.connection-signal {
position: relative;
display: grid;
place-items: center;
width: 48px;
height: 24px;
color: var(--accent-brand);
}
.connection-signal::before,
.connection-signal::after {
content: "";
position: absolute;
top: 50%;
width: 16px;
height: 1px;
background: currentColor;
opacity: 0.22;
}
.connection-signal::before {
right: 50%;
margin-right: 5px;
transform-origin: right;
}
.connection-signal::after {
left: 50%;
margin-left: 5px;
transform-origin: left;
}
.connection-signal-core {
width: 7px;
height: 7px;
border: 1px solid currentColor;
background: color-mix(in oklab, currentColor 18%, transparent);
transform: rotate(45deg);
}
.connection-signal[data-active="true"]::before,
.connection-signal[data-active="true"]::after {
animation: connection-signal-flow 900ms var(--motion-ease-enter) infinite;
}
.connection-signal[data-active="true"]::after {
animation-delay: 120ms;
}
.host-status-stripe {
position: relative;
isolation: isolate;
}
.host-status-stripe[data-locking="true"]::after {
content: "";
position: absolute;
inset: 0 -3px;
z-index: -1;
background: inherit;
opacity: 0;
transform: scaleX(1);
animation: host-signal-lock var(--motion-duration-context)
var(--motion-ease-enter) both;
}
@keyframes motion-panel-enter {
from {
opacity: 0;
transform: translateY(6px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes motion-context-enter {
from {
opacity: 0;
transform: scale(0.995);
filter: saturate(0.65);
}
to {
opacity: 1;
transform: scale(1);
filter: saturate(1);
}
}
@keyframes motion-workspace-enter {
from {
opacity: 0;
transform: translateY(3px) scale(0.998);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes motion-workspace-layout {
from {
opacity: 0.72;
transform: scale(0.996);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes motion-transfer-phase {
from {
opacity: 0.45;
transform: translateX(-3px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes transfer-progress-flow {
to {
transform: translateX(420%);
}
}
@keyframes connection-signal-flow {
0% {
opacity: 0.12;
transform: scaleX(0.2);
}
45% {
opacity: 0.72;
}
100% {
opacity: 0.22;
transform: scaleX(1);
}
}
@keyframes host-signal-lock {
0% {
opacity: 0;
transform: scaleX(3.2);
}
38% {
opacity: 0.48;
}
100% {
opacity: 0;
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration-micro: 1ms;
--motion-duration-panel: 1ms;
--motion-duration-context: 1ms;
}
.motion-panel-enter,
.motion-context-enter,
.motion-workspace-enter,
.motion-workspace-layout,
.motion-transfer-phase,
.transfer-progress-indeterminate,
.connection-signal[data-active="true"]::before,
.connection-signal[data-active="true"]::after,
.host-status-stripe[data-locking="true"]::after {
animation: none;
}
.motion-meter {
transition: none;
}
.transfer-progress-indeterminate {
transform: translateX(105%);
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
}
}
+22 -3
View File
@@ -1,5 +1,6 @@
import { useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { motion, useReducedMotion } from "motion/react";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import {
@@ -77,6 +78,7 @@ export function TabBar({
onToggleRightDock?: () => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const [open, setOpen] = useState(true);
const [dragTabId, setDragTabId] = useState<string | null>(null);
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
@@ -214,7 +216,7 @@ export function TabBar({
return (
<div className="flex flex-col shrink-0 min-w-0">
<div
className={`flex items-end bg-sidebar min-w-0 transition-all duration-200 ${open ? "h-12.5 border-b border-border" : "h-0 overflow-hidden"}`}
className={`flex items-end bg-sidebar min-w-0 transition-[height,border-color] duration-200 ${open ? "h-12.5 border-b border-border" : "h-0 overflow-hidden"}`}
>
<div
ref={tabBarRef}
@@ -318,10 +320,27 @@ export function TabBar({
${index === 0 && tab.type !== "dashboard" ? "border-l border-border" : ""}
${
tab.type === "dashboard"
? `px-2.5 md:px-3.5 ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
: `px-2.5 md:px-4 font-medium ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
? `px-2.5 md:px-3.5 ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
: `px-2.5 md:px-4 font-medium ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
}`}
>
{active && (
<motion.span
layoutId="active-workspace-tab"
data-workspace-indicator={tab.id}
className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-accent-brand z-10"
transition={
reduceMotion
? { duration: 0 }
: {
type: "spring",
stiffness: 520,
damping: 42,
mass: 0.55,
}
}
/>
)}
{/* Focused-pane indicator: brand accent bottom border overlay */}
{showFocusIndicator && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-brand/70 z-10" />
@@ -200,10 +200,10 @@ export function CredentialItem({
const trayCollapsed = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-1"}`;
const trayVisibilityClass =
alwaysShowTray || actionsOnly
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}`
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}`
: shouldUseClickTray
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}`
: `overflow-hidden transition-all duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`;
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}`
: `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`;
return (
<div
+21 -7
View File
@@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useState, type MouseEvent } from "react";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import {
Box,
@@ -319,6 +319,19 @@ export function HostItem({
? "online"
: "offline";
const isOnline = availability === "online";
const previousAvailability = useRef(availability);
const [statusLocking, setStatusLocking] = useState(false);
useEffect(() => {
const justCameOnline =
previousAvailability.current !== "online" && availability === "online";
previousAvailability.current = availability;
if (!justCameOnline) return;
setStatusLocking(true);
const timeout = window.setTimeout(() => setStatusLocking(false), 400);
return () => window.clearTimeout(timeout);
}, [availability]);
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const alwaysShowTray = trayTrigger === "always";
@@ -947,9 +960,9 @@ export function HostItem({
const trayCollapsedClass = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-[3.5px]"}`;
const trayVisibilityClass =
alwaysShowTray || actionsOnly
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : trayCollapsedClass}`
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : trayCollapsedClass}`
: shouldUseClickTray
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[130px] opacity-100" : trayCollapsedClass}`
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[130px] opacity-100" : trayCollapsedClass}`
: // No transition in hover mode: the row's height is set by the
// virtualizer and snaps in a single frame, so animating the tray
// against it leaves the open tray overflowing its shortened row for
@@ -1053,7 +1066,7 @@ export function HostItem({
setContextMenuPosition({ x: event.clientX, y: event.clientY });
onMenuOpenChange?.(true);
}}
className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${
className={`group relative flex items-stretch select-none transition-colors motion-interactive hover:bg-muted/50 ${
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
} ${
selected
@@ -1096,7 +1109,8 @@ export function HostItem({
{/* Status stripe */}
{showStatusStripes && (
<div
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(availability, statusScheme, "stripe", statusLoading)}`}
data-locking={statusLocking}
className={`host-status-stripe w-[3px] shrink-0 transition-colors motion-interactive ${getStatusClasses(availability, statusScheme, "stripe", statusLoading)}`}
/>
)}
@@ -1262,7 +1276,7 @@ export function HostItem({
<Cpu className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
style={{ width: `${host.cpu}%` }}
/>
</div>
@@ -1276,7 +1290,7 @@ export function HostItem({
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
style={{ width: `${host.ram}%` }}
/>
</div>
+1 -1
View File
@@ -1101,7 +1101,7 @@ export function SidebarTree({
{/* Floating selection bar */}
{selectionMode && (
<div className="absolute bottom-4 inset-x-3 z-50">
<div className="motion-panel-enter absolute bottom-4 inset-x-3 z-50">
<div className="bg-popover border border-border shadow-xl px-2.5 py-2 flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold tabular-nums shrink-0">
{t("hosts.nSelected", { count: selectedHostIds.size })}
@@ -19,6 +19,9 @@ describe("ConnectionScreen", () => {
).not.toThrow();
expect(screen.getByText("common.loading")).toBeTruthy();
const status = screen.getByRole("status");
expect(status.getAttribute("data-status")).toBe("connecting");
expect(status.querySelector('[data-active="true"]')).toBeTruthy();
});
it("renders the disconnected state without a provider", () => {
@@ -32,6 +35,9 @@ describe("ConnectionScreen", () => {
).not.toThrow();
expect(screen.getByText("guacamole.hostNotFound")).toBeTruthy();
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
"disconnected",
);
});
it("still shows the connection log when a provider is present", () => {
@@ -0,0 +1,61 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TransferProgressBar } from "../../../features/file-manager/components/TransferProgressBar";
afterEach(cleanup);
describe("TransferProgressBar", () => {
it("exposes determinate progress and drives the fill with a transform", () => {
const { container } = render(
<TransferProgressBar value={42} label="Uploading archive" />,
);
const progress = screen.getByRole("progressbar", {
name: "Uploading archive",
});
expect(progress.getAttribute("aria-valuenow")).toBe("42");
expect(progress.getAttribute("aria-busy")).toBe("false");
expect(
container
.querySelector<HTMLElement>(".transfer-progress-fill")
?.style.getPropertyValue("--transfer-progress"),
).toBe("0.42");
});
it("clamps invalid percentages before exposing them", () => {
const { rerender } = render(
<TransferProgressBar value={140} label="Uploading archive" />,
);
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"100",
);
rerender(<TransferProgressBar value={-20} label="Uploading archive" />);
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"0",
);
});
it("marks unknown and stalled progress without inventing a percentage", () => {
const { container } = render(
<TransferProgressBar label="Reconnecting" stalled />,
);
const progress = screen.getByRole("progressbar");
expect(progress.hasAttribute("aria-valuenow")).toBe(false);
expect(progress.getAttribute("aria-busy")).toBe("true");
expect(progress.getAttribute("data-stalled")).toBe("true");
expect(
container
.querySelector(".transfer-progress-fill")
?.classList.contains("transfer-progress-indeterminate"),
).toBe(true);
expect(
container
.querySelector(".transfer-progress-fill")
?.classList.contains("transfer-progress-stalled"),
).toBe(true);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import type { Tab } from "@/types/ui-types";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
import { TabBar } from "@/shell/TabBar";
afterEach(cleanup);
const tabs = [
{ id: "dashboard", type: "dashboard", label: "Dashboard" },
{ id: "terminal-1", type: "terminal", label: "web-01" },
] as Tab[];
function renderTabBar(activeTabId: string, onSetActiveTab = vi.fn()) {
return render(
<TabBar
tabs={tabs}
activeTabId={activeTabId}
splitMode="none"
paneTabIds={[]}
focusedPaneIndex={null}
onSetActiveTab={onSetActiveTab}
onCloseTab={() => {}}
onRefreshTab={() => {}}
onReorderTabs={() => {}}
onSplitTab={() => {}}
onAddToSplit={() => {}}
onRemoveFromSplit={() => {}}
isAppFullscreen={false}
onToggleAppFullscreen={() => {}}
/>,
);
}
describe("TabBar workspace continuity", () => {
it("keeps one shared indicator on the active workspace", () => {
const { container, rerender } = renderTabBar("dashboard");
expect(
container.querySelector('[data-workspace-indicator="dashboard"]'),
).toBeTruthy();
rerender(
<TabBar
tabs={tabs}
activeTabId="terminal-1"
splitMode="none"
paneTabIds={[]}
focusedPaneIndex={null}
onSetActiveTab={() => {}}
onCloseTab={() => {}}
onRefreshTab={() => {}}
onReorderTabs={() => {}}
onSplitTab={() => {}}
onAddToSplit={() => {}}
onRemoveFromSplit={() => {}}
isAppFullscreen={false}
onToggleAppFullscreen={() => {}}
/>,
);
expect(
container.querySelectorAll("[data-workspace-indicator]"),
).toHaveLength(1);
expect(
container.querySelector('[data-workspace-indicator="terminal-1"]'),
).toBeTruthy();
});
it("activates a workspace immediately when its tab is clicked", () => {
const onSetActiveTab = vi.fn();
const { getByText } = renderTabBar("dashboard", onSetActiveTab);
fireEvent.click(getByText("web-01"));
expect(onSetActiveTab).toHaveBeenCalledWith("terminal-1");
});
});