mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-02 07:11:49 +00:00
feat(web): mobile dashboard UX polish (#28127)
* feat(web): mobile dashboard UX polish Bottom sheets for sidebar theme/language pickers on narrow viewports with enter/exit animation and drag-to-close; inline header badges beside titles; bottom padding on the route outlet for scroll clearance; profiles loading uses a unicode braille spinner; align profile/cron card actions to the top; viewport-fit cover and supporting layout tweaks across dashboard pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Nix web npm hash and mobile sheet accessibility. Align fetchNpmDeps in nix/web.nix with web/package-lock.json for CI. Improve BottomPickSheet backdrop labeling, avoid aria-hidden on the dialog during exit animation, and wire theme/language sheets with listbox semantics and localized dismiss labels. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
52e3bfc2f4
commit
6fa1701bd3
24 changed files with 779 additions and 295 deletions
|
|
@ -439,7 +439,7 @@ export default function AnalyticsPage() {
|
|||
);
|
||||
setEnd(
|
||||
showTokens === false ? null : (
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -417,14 +417,14 @@ export default function ConfigPage() {
|
|||
<PluginSlot name="config:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:flex-1">
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||
{configPath ?? t.config.configPath}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ export default function CronPage() {
|
|||
|
||||
return (
|
||||
<Card key={job.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
|
|
|
|||
|
|
@ -537,13 +537,16 @@ export default function EnvPage() {
|
|||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
setAfterTitle(
|
||||
<nav className="flex items-center gap-1" aria-label="Jump to section">
|
||||
<nav
|
||||
className="flex shrink-0 flex-nowrap items-center gap-1"
|
||||
aria-label="Jump to section"
|
||||
>
|
||||
{sections.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => scrollTo(s.id)}
|
||||
className="cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||
className="shrink-0 cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ const LINE_COLORS: Record<string, string> = {
|
|||
const toOptions = <T extends string>(values: readonly T[]) =>
|
||||
values.map((v) => ({ value: v, label: v }));
|
||||
|
||||
const filterGroupClass =
|
||||
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
|
||||
|
||||
const segmentedClass =
|
||||
"w-fit max-w-full flex-wrap justify-start self-start";
|
||||
|
||||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||
|
|
@ -87,7 +93,7 @@ export default function LogsPage() {
|
|||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
|
|
@ -145,39 +151,43 @@ export default function LogsPage() {
|
|||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||
<PluginSlot name="logs:top" />
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t.logs.title}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
className="flex min-w-0 max-w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-3"
|
||||
>
|
||||
<FilterGroup label={t.logs.file}>
|
||||
<FilterGroup label={t.logs.file} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
options={toOptions(FILES)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.level}>
|
||||
<FilterGroup label={t.logs.level} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
options={toOptions(LEVELS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.component}>
|
||||
<FilterGroup label={t.logs.component} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={component}
|
||||
onChange={setComponent}
|
||||
options={toOptions(COMPONENTS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.lines}>
|
||||
<FilterGroup label={t.logs.lines} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={String(lineCount)}
|
||||
onChange={(v) =>
|
||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||
|
|
@ -190,7 +200,7 @@ export default function LogsPage() {
|
|||
</FilterGroup>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
|
|
@ -206,7 +216,7 @@ export default function LogsPage() {
|
|||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]"
|
||||
className="max-w-full min-h-[400px] max-h-[calc(100vh-220px)] overflow-auto p-4 font-mono-ui text-xs leading-5 break-words"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
|
|
|
|||
|
|
@ -336,7 +336,9 @@ function ModelCard({
|
|||
)?.task ?? null;
|
||||
|
||||
return (
|
||||
<Card className={isMain ? "ring-1 ring-primary/40" : undefined}>
|
||||
<Card
|
||||
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -666,22 +668,20 @@ function ModelSettingsPanel({
|
|||
).length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">Model Settings</CardTitle>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
applies to new sessions
|
||||
</span>
|
||||
</div>
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="min-w-0 pb-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">Model Settings</CardTitle>
|
||||
<span className="max-w-full min-w-0 text-[10px] text-muted-foreground [overflow-wrap:anywhere]">
|
||||
applies to new sessions
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<CardContent className="min-w-0 space-y-3 pt-3">
|
||||
{/* Main row */}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Star className="h-3 w-3 text-primary" />
|
||||
|
|
@ -698,14 +698,14 @@ function ModelSettingsPanel({
|
|||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPicker({ kind: "main" })}
|
||||
className="text-xs"
|
||||
className="shrink-0 self-start text-xs sm:self-center"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auxiliary tasks summary + open modal */}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Cpu className="h-3 w-3 text-muted-foreground" />
|
||||
|
|
@ -723,7 +723,7 @@ function ModelSettingsPanel({
|
|||
size="sm"
|
||||
outlined
|
||||
onClick={() => setAuxModalOpen(true)}
|
||||
className="text-xs"
|
||||
className="shrink-0 self-start text-xs sm:self-center"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
|
|
@ -827,7 +827,7 @@ export default function ModelsPage() {
|
|||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
|
|
@ -864,10 +864,10 @@ export default function ModelsPage() {
|
|||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
||||
<PluginSlot name="models:top" />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="grid min-w-0 gap-6 lg:grid-cols-2">
|
||||
<ModelSettingsPanel
|
||||
aux={aux}
|
||||
refreshKey={saveKey}
|
||||
|
|
@ -875,10 +875,12 @@ export default function ModelsPage() {
|
|||
/>
|
||||
|
||||
{data && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<Stats
|
||||
items={
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardContent className="min-w-0 py-6">
|
||||
<div className="min-w-0 max-w-full [&_div.grid]:grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||
<Stats
|
||||
className="min-w-0"
|
||||
items={
|
||||
showTokens
|
||||
? [
|
||||
{
|
||||
|
|
@ -920,6 +922,7 @@ export default function ModelsPage() {
|
|||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!showTokens && (
|
||||
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||
Token & cost analytics are hidden because the local counts
|
||||
|
|
@ -953,7 +956,7 @@ export default function ModelsPage() {
|
|||
{data && (
|
||||
<>
|
||||
{data.models.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.models.map((m, i) => (
|
||||
<ModelCard
|
||||
key={`${m.model}:${m.provider}`}
|
||||
|
|
|
|||
|
|
@ -60,16 +60,18 @@ export default function PluginsPage() {
|
|||
|
||||
useEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="shrink-0 gap-2"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.refreshDashboard}
|
||||
</Button>,
|
||||
<div className="flex w-full min-w-0 justify-start">
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="w-max max-w-full shrink-0 gap-2"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.refreshDashboard}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
|
||||
|
|
@ -413,32 +415,20 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-semibold">{row.name}</span>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge tone="outline">
|
||||
{t.pluginsPage.sourceBadge}: {row.source}
|
||||
</Badge>
|
||||
|
||||
<span className="truncate font-semibold">{row.name}</span>
|
||||
<Badge tone="outline">v{row.version || "—"}</Badge>
|
||||
|
||||
<Badge tone="outline">
|
||||
{t.pluginsPage.sourceBadge}: {row.source}
|
||||
</Badge>
|
||||
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
||||
|
||||
|
||||
<Badge tone="outline">v{row.version || "—"}</Badge>
|
||||
|
||||
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
||||
|
||||
{row.auth_required ? (
|
||||
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{row.description ? (
|
||||
|
||||
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
|
||||
{row.description}
|
||||
</p>
|
||||
{row.auth_required ? (
|
||||
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -544,6 +534,12 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{row.description ? (
|
||||
<p className="min-w-0 w-full text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case break-words">
|
||||
{row.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{dm?.slots?.length ? (
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
||||
import spinners from "unicode-animations";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ProfileInfo } from "@/lib/api";
|
||||
|
|
@ -21,6 +22,35 @@ import { usePageHeader } from "@/contexts/usePageHeader";
|
|||
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
/** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */
|
||||
function ProfilesLoadingSpinner() {
|
||||
const { frames, interval } = spinners.braille;
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(
|
||||
() => setFrameIndex((i) => (i + 1) % frames.length),
|
||||
interval,
|
||||
);
|
||||
return () => window.clearInterval(id);
|
||||
}, [frames.length, interval]);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
|
||||
>
|
||||
{frames[frameIndex]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilesPage() {
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -199,8 +229,14 @@ export default function ProfilesPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="flex items-center justify-center py-24"
|
||||
>
|
||||
<span className="sr-only">{t.common.loading}</span>
|
||||
|
||||
<ProfilesLoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -318,7 +354,7 @@ export default function ProfilesPage() {
|
|||
const isEditingSoul = editingSoulFor === p.name;
|
||||
return (
|
||||
<Card key={p.name}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
{isRenaming ? (
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||
parts.push(snippet.slice(last));
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
|
||||
<p className="mt-0.5 min-w-0 max-w-full truncate text-xs text-muted-foreground/80">
|
||||
{parts}
|
||||
</p>
|
||||
);
|
||||
|
|
@ -296,24 +296,24 @@ function SessionRow({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`border overflow-hidden transition-colors ${
|
||||
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
|
||||
session.is_active
|
||||
? "border-success/30 bg-success/[0.03]"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
|
||||
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`shrink-0 ${sourceInfo.color}`}>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
className={`min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
>
|
||||
{hasTitle
|
||||
? session.title
|
||||
|
|
@ -322,71 +322,70 @@ function SessionRow({
|
|||
: t.sessions.untitledSession}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<Badge tone="success" className="text-[10px] shrink-0">
|
||||
<Badge tone="success" className="shrink-0 text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[120px] sm:max-w-[180px]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
|
||||
{(session.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>
|
||||
<span className="shrink-0">
|
||||
{session.message_count} {t.common.msgs}
|
||||
</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>
|
||||
<span className="shrink-0">
|
||||
{session.tool_call_count} {t.common.tools}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span>{timeAgo(session.last_active)}</span>
|
||||
<span className="shrink-0">{timeAgo(session.last_active)}</span>
|
||||
</div>
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge tone="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
{resumeInChatEnabled && (
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
{resumeInChatEnabled && (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
aria-label={t.sessions.deleteSession}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
aria-label={t.sessions.deleteSession}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-background/50 p-4">
|
||||
<div className="min-w-0 border-t border-border bg-background/50 p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-xl text-primary" />
|
||||
|
|
@ -624,7 +623,7 @@ export default function SessionsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
|
||||
<PluginSlot name="sessions:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
|
|
@ -732,28 +731,28 @@ export default function SessionsPage() {
|
|||
)}
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="min-w-0 truncate text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
<CardContent className="grid min-w-0 gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<span className="font-medium text-sm truncate">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="min-w-0 break-words text-xs text-muted-foreground">
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
|
|
@ -762,15 +761,15 @@ export default function SessionsPage() {
|
|||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<span className="text-xs text-muted-foreground/70 truncate">
|
||||
<p className="min-w-0 max-w-full text-xs leading-snug text-muted-foreground/70 [overflow-wrap:anywhere]">
|
||||
{s.preview}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
tone="outline"
|
||||
className="text-[10px] shrink-0 self-start sm:self-center"
|
||||
className="shrink-0 self-start text-[10px] sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
|
|
@ -795,7 +794,7 @@ export default function SessionsPage() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export default function SkillsPage() {
|
|||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 pr-7 text-xs"
|
||||
className="h-8 rounded-none pl-8 pr-7 text-xs"
|
||||
placeholder={t.common.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
|
|
@ -256,12 +256,7 @@ export default function SkillsPage() {
|
|||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-0">
|
||||
<div
|
||||
className={`
|
||||
flex flex-col
|
||||
border border-border bg-muted/20
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col rounded-none border border-border bg-muted/20">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
|
|
@ -309,7 +304,7 @@ export default function SkillsPage() {
|
|||
onClick={() =>
|
||||
setActiveCategory(isActive ? null : key)
|
||||
}
|
||||
className="rounded-sm px-2 py-1 text-[11px]"
|
||||
className="rounded-none px-2 py-1 text-[11px]"
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
|
|
@ -333,7 +328,7 @@ export default function SkillsPage() {
|
|||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isSearching ? (
|
||||
<Card>
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
|
|
@ -372,7 +367,7 @@ export default function SkillsPage() {
|
|||
</Card>
|
||||
) : view === "skills" ? (
|
||||
/* Skills list */
|
||||
<Card>
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
|
|
@ -417,7 +412,7 @@ export default function SkillsPage() {
|
|||
/* Toolsets grid */
|
||||
<>
|
||||
{filteredToolsets.length === 0 ? (
|
||||
<Card>
|
||||
<Card className="rounded-none">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t.skills.noToolsetsMatch}
|
||||
</CardContent>
|
||||
|
|
@ -431,7 +426,7 @@ export default function SkillsPage() {
|
|||
ts.name;
|
||||
|
||||
return (
|
||||
<Card key={ts.name} className="relative">
|
||||
<Card key={ts.name} className="relative rounded-none">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
|
|
@ -536,7 +531,7 @@ function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
|
|||
active={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-sm whitespace-nowrap px-2.5 py-1.5",
|
||||
"rounded-none whitespace-nowrap px-2.5 py-1.5",
|
||||
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
|
||||
active && "bg-foreground/90 text-background hover:text-background",
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue