fix: redesign session log player controls, fix tooltip overlap and drop dot separators

This commit is contained in:
LukeGus committed 2026-10-01 00:09:32 -05:00
1 parent b3c8f37c0d
commit 9015d5dccb
35 files changed
+434 -283

No files matched your search

+9 -2
View File
@@ -44,10 +44,17 @@
"copied": "Copied!",
"loadError": "Failed to load session logs",
"deleteError": "Failed to delete session log",
"filterByHost": "Filter by host..."
"filterByHost": "Filter by host...",
"back": "Back",
"empty": "(empty)",
"noResults": "No results for \"{{query}}\"",
"hostFallback": "Host {{id}}",
"sessionFallback": "Session {{id}}"
},
"player": {
"timeline": "Recording timeline",
"speed": "Playback speed"
"speed": "Playback speed",
"play": "Play recording",
"pause": "Pause recording"
}
}
@@ -1,4 +1,11 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
useTranslation,
useToast,
@@ -146,6 +153,63 @@ function SectionHeader({ label, count }: { label: string; count: number }) {
);
}
function LogMeta({
items,
className,
}: {
items: (string | null | undefined)[];
className?: string;
}) {
return (
<span
className={`flex min-w-0 items-center gap-x-2.5 overflow-hidden whitespace-nowrap text-[10px] ${className ?? ""}`}
>
{items.filter(Boolean).map((item, i) => (
<span key={i} className="truncate last:shrink-0">
{item}
</span>
))}
</span>
);
}
const ACTION_BUTTON =
"size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors";
function ActionButton({
label,
onClick,
destructive,
children,
}: {
label: string;
onClick: () => void;
destructive?: boolean;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
aria-label={label}
className={
destructive
? "size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors"
: ACTION_BUTTON
}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
);
}
function LogRow({
log,
onView,
@@ -160,7 +224,10 @@ function LogRow({
onDelete: () => void;
}) {
const { t } = useTranslation();
const hostLabel = log.hostName ?? log.hostIp ?? `Host ${log.hostId}`;
const hostLabel =
log.hostName ??
log.hostIp ??
t("sessionLogs.hostFallback", { id: log.hostId });
return (
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0 hover:bg-muted/40 transition-colors">
@@ -172,68 +239,44 @@ function LogRow({
<span className="text-xs font-semibold truncate text-foreground">
{hostLabel}
</span>
<span className="text-[10px] text-muted-foreground/60 truncate">
{formatDate(log.startedAt)}
{" · "}
{(log.protocol ?? "ssh").toUpperCase()}
{log.username ? ` · ${log.username}` : ""}
{" · "}
{formatDuration(log.duration)}
{" · "}
{formatBytes(log.sizeBytes)}
</span>
<LogMeta
className="text-muted-foreground/60"
items={[
formatDate(log.startedAt),
(log.protocol ?? "ssh").toUpperCase(),
log.username,
formatDuration(log.duration),
formatBytes(log.sizeBytes),
]}
/>
</div>
<TooltipProvider>
<TooltipProvider disableHoverableContent>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onView}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Eye className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">View log</TooltipContent>
</Tooltip>
<ActionButton label={t("sessionLogs.viewLog")} onClick={onView}>
<Eye className="size-3" />
</ActionButton>
{log.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDownloadText}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
<ActionButton
label={t("sessionLogs.downloadAsText")}
onClick={onDownloadText}
>
<FileText className="size-3" />
</ActionButton>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDownload}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Download className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Download</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDelete}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Delete</TooltipContent>
</Tooltip>
<ActionButton
label={t("sessionLogs.downloadLog")}
onClick={onDownload}
>
<Download className="size-3" />
</ActionButton>
<ActionButton
label={t("sessionLogs.deleteLog")}
onClick={onDelete}
destructive
>
<X className="size-3" />
</ActionButton>
</div>
</TooltipProvider>
</div>
@@ -406,14 +449,18 @@ export function SessionLogsPanel() {
// Inline log viewer
if (viewLog) {
const hostLabel =
viewLog.hostName ?? viewLog.hostIp ?? `Host ${viewLog.hostId}`;
viewLog.hostName ??
viewLog.hostIp ??
t("sessionLogs.hostFallback", { id: viewLog.hostId });
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Viewer header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-border/60 bg-muted/20 shrink-0">
<button
type="button"
onClick={() => setViewLog(null)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
aria-label={t("sessionLogs.back")}
className={ACTION_BUTTON}
>
<ArrowLeft className="size-3.5" />
</button>
@@ -421,67 +468,50 @@ export function SessionLogsPanel() {
<span className="text-xs font-semibold truncate text-foreground">
{hostLabel}
</span>
<span className="text-[10px] text-muted-foreground/50">
{formatDate(viewLog.startedAt)}
{` · ${viewLog.protocol.toUpperCase()}`}
{viewLog.duration != null
? ` · ${formatDuration(viewLog.duration)}`
: ""}
{" · "}
{formatBytes(viewLog.sizeBytes)}
</span>
<LogMeta
className="text-muted-foreground/50"
items={[
formatDate(viewLog.startedAt),
viewLog.protocol.toUpperCase(),
viewLog.duration != null
? formatDuration(viewLog.duration)
: null,
formatBytes(viewLog.sizeBytes),
]}
/>
</div>
<TooltipProvider>
<TooltipProvider disableHoverableContent>
<div className="flex items-center gap-0.5 shrink-0">
{viewText != null && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopy}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{copied
<ActionButton
label={
copied
? t("sessionLogs.copied")
: t("sessionLogs.copyContent")}
</TooltipContent>
</Tooltip>
: t("sessionLogs.copyContent")
}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</ActionButton>
)}
{viewLog.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleDownloadText(viewLog)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
<ActionButton
label={t("sessionLogs.downloadAsText")}
onClick={() => handleDownloadText(viewLog)}
>
<FileText className="size-3" />
</ActionButton>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleDownload(viewLog)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Download className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadLog")}
</TooltipContent>
</Tooltip>
<ActionButton
label={t("sessionLogs.downloadLog")}
onClick={() => handleDownload(viewLog)}
>
<Download className="size-3" />
</ActionButton>
</div>
</TooltipProvider>
</div>
@@ -496,7 +526,7 @@ export function SessionLogsPanel() {
<SessionRecordingPlayer log={viewLog} blob={viewBlob} />
) : (
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all text-foreground/80 leading-relaxed">
{viewContent || "(empty)"}
{viewContent || t("sessionLogs.empty")}
</pre>
)}
</div>
@@ -536,7 +566,7 @@ export function SessionLogsPanel() {
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="text-xs text-muted-foreground/50">
No results for &quot;{filter}&quot;
{t("sessionLogs.noResults", { query: filter })}
</span>
</div>
) : (
@@ -568,13 +598,14 @@ export function SessionLogsPanel() {
<p className="text-sm text-foreground">
{t("sessionLogs.confirmDelete")}
</p>
<p className="text-xs text-muted-foreground">
{deleteTarget.hostName ??
deleteTarget.hostIp ??
`Session ${deleteTarget.id}`}
{" · "}
{formatDate(deleteTarget.startedAt)}
</p>
<div className="flex flex-col text-xs text-muted-foreground">
<span className="font-medium text-foreground/80">
{deleteTarget.hostName ??
deleteTarget.hostIp ??
t("sessionLogs.sessionFallback", { id: deleteTarget.id })}
</span>
<span>{formatDate(deleteTarget.startedAt)}</span>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteTarget(null)}
@@ -4,7 +4,6 @@ import { Pause, Play } from "lucide-react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import Guacamole from "guacamole-common-js";
import { Select2 } from "@termix/plugin-sdk/ui";
import "@xterm/xterm/css/xterm.css";
import { parseAsciicast, type Asciicast } from "./asciicast";
import type { SessionLogRecord } from "./session-recording-api";
@@ -20,6 +19,65 @@ function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function SeekBar({
position,
duration,
onSeek,
}: {
position: number;
duration: number;
onSeek: (position: number) => void;
}) {
const { t } = useTranslation();
const barRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const progress = duration > 0 ? Math.min(position / duration, 1) : 0;
const seekFromPointer = (clientX: number) => {
const rect = barRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0 || duration <= 0) return;
const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
onSeek(ratio * duration);
};
return (
<div
ref={barRef}
role="slider"
tabIndex={0}
aria-label={t("player.timeline")}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={position}
className="group/seek relative h-3 w-full cursor-pointer touch-none outline-none"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
seekFromPointer(event.clientX);
}}
onPointerMove={(event) => {
if (dragging) seekFromPointer(event.clientX);
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
}}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") onSeek(Math.max(position - 5, 0));
if (event.key === "ArrowRight")
onSeek(Math.min(position + 5, duration));
}}
>
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 bg-border transition-[height] group-hover/seek:h-1 group-focus-visible/seek:h-1">
<div
className="h-full bg-primary"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
);
}
function PlaybackControls({
playing,
position,
@@ -39,47 +97,47 @@ function PlaybackControls({
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2 border-t border-border/60 bg-muted/20 px-3 py-2">
<button
type="button"
onClick={onToggle}
className="flex size-7 items-center justify-center hover:bg-muted"
aria-label={playing ? "Pause recording" : "Play recording"}
>
{playing ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
</button>
<span className="w-10 text-[10px] tabular-nums text-muted-foreground">
{formatPosition(position)}
</span>
<input
type="range"
min={0}
max={Math.max(duration, 0.01)}
step={0.01}
value={Math.min(position, duration)}
onChange={(event) => onSeek(Number(event.target.value))}
className="min-w-0 flex-1 accent-primary"
aria-label={t("player.timeline")}
/>
<span className="w-10 text-right text-[10px] tabular-nums text-muted-foreground">
{formatPosition(duration)}
</span>
<Select2
value={speed}
onChange={(event) => onSpeed(Number(event.target.value))}
className="h-7 border border-border bg-background px-1 text-[10px]"
aria-label={t("player.speed")}
>
{SPEEDS.map((value) => (
<option key={value} value={value}>
{value}×
</option>
))}
</Select2>
<div className="shrink-0 border-t border-border/60 bg-muted/20">
<SeekBar position={position} duration={duration} onSeek={onSeek} />
<div className="flex items-center gap-2 px-1 pb-1">
<button
type="button"
onClick={onToggle}
className="flex size-6 items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={playing ? t("player.pause") : t("player.play")}
>
{playing ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
</button>
<span className="flex-1 text-[10px] tabular-nums text-muted-foreground">
{formatPosition(Math.min(position, duration))} /{" "}
{formatPosition(duration)}
</span>
<div
className="flex items-center"
role="group"
aria-label={t("player.speed")}
>
{SPEEDS.map((value) => (
<button
key={value}
type="button"
onClick={() => onSpeed(value)}
aria-pressed={speed === value}
className={
speed === value
? "h-5 px-1.5 text-[10px] tabular-nums bg-muted text-foreground"
: "h-5 px-1.5 text-[10px] tabular-nums text-muted-foreground/60 hover:text-foreground"
}
>
{value}x
</button>
))}
</div>
</div>
</div>
);
}