feat(ui): hold header rows and page sections to rearrange or hide them #1
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "ai-agent",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@gabvdl/ui": "^0.30.1",
|
||||
"@gabvdl/ui": "^0.30.3",
|
||||
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||
@@ -1473,9 +1473,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@gabvdl/ui": {
|
||||
"version": "0.30.1",
|
||||
"resolved": "http://localhost:4873/@gabvdl/ui/-/ui-0.30.1.tgz",
|
||||
"integrity": "sha512-rhVZiO/X2YZ21/si5Sk+iPSV5SI5Ydfui2Svyp1WKcwh9+P4YIxB0NvDOl5YUNw3uqE9MIcAiRlQbBPhbLixOA==",
|
||||
"version": "0.30.3",
|
||||
"resolved": "http://localhost:4873/@gabvdl/ui/-/ui-0.30.3.tgz",
|
||||
"integrity": "sha512-qqKtXGk1WkeoglTXRMDXqrEgmj9HKgZpxF64WG+7YnYymtIA95HGPKbOxv/chcU3ACy+hbyLDx1nZixA6GqyfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.5",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gabvdl/ui": "^0.30.1",
|
||||
"@gabvdl/ui": "^0.30.3",
|
||||
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
FolderGit2,
|
||||
Settings as SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sectionOf, type Section } from "@/nav";
|
||||
import { useSpawnDock } from "@/lib/spawnDock";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
|
||||
interface Tab {
|
||||
section: Section;
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
Images,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { ProgressiveList, ResizableLayout, useImageViewer, type ViewerMedia } from "@gabvdl/ui";
|
||||
import { HoldEditable, ProgressiveList, ResizableLayout, type ViewerMedia, useImageViewer } from "@gabvdl/ui";
|
||||
import { AnimationSpeedContext } from "@/lib/animationSpeed";
|
||||
import { cn, fmtTokens, fmtCost, fmtDateTime, fmtLatency, relTime } from "@/lib/utils";
|
||||
import {
|
||||
@@ -51,9 +51,9 @@ import {
|
||||
import { useStore } from "@/store";
|
||||
import { useVis, type VisKey } from "@/lib/visibility";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
import { EffortTags, ModelTags, effortsOf, modelFamily } from "@/lib/models";
|
||||
import { VisibilityPopover } from "@/technical/VisibilityPopover";
|
||||
import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions";
|
||||
import { interruptConversation } from "@/api";
|
||||
import { useConversation, useConversationCommits, useUncommittedDiffStats } from "@/lib/queries";
|
||||
import { repoTokenHref, conversationDiffHref, uncommittedDiffHref } from "@/lib/diffRoute";
|
||||
@@ -425,65 +425,95 @@ export function Conversation() {
|
||||
{convo && (
|
||||
<div className="flex min-w-0 items-center gap-1 md:ml-auto">
|
||||
<div className="min-w-0 flex-1 md:hidden">{metaLine}</div>
|
||||
{(serverRunning || serverPaused) && (
|
||||
<button
|
||||
onClick={() => void interrupt()}
|
||||
disabled={interrupting}
|
||||
title={
|
||||
interruptErr
|
||||
? `Interrupt failed: ${interruptErr}`
|
||||
: "Interrupt this running session (sends Esc / SIGINT)"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-1.5 rounded-md border px-2 text-[12px] font-medium transition-colors",
|
||||
interruptErr
|
||||
? "border-rose-500/40 bg-rose-500/10 text-rose-500"
|
||||
: "border-rose-500/30 text-rose-500 hover:bg-rose-500/10",
|
||||
interrupting && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{interrupting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CircleStop className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{interrupting ? "Stopping…" : "Interrupt"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAll(expandState !== "expanded")}
|
||||
title={
|
||||
expandState === "expanded"
|
||||
? "All tools expanded — collapse them"
|
||||
: expandState === "collapsed"
|
||||
? "All tools collapsed — expand them"
|
||||
: "Expand all tools"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
|
||||
expandState === "mixed" ? "text-muted-foreground" : "text-primary",
|
||||
)}
|
||||
>
|
||||
{expandState === "expanded" ? (
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{/* No composer toggle: it's a plain bar pinned under the thread. */}
|
||||
<VisibilityPopover />
|
||||
<button
|
||||
onClick={() => setInfoOpen((o) => !o)}
|
||||
title="Costs & details"
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
|
||||
infoOpen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Hold any of these to rearrange the row, or drop one on the stash
|
||||
to bench it — a reader who never touches the details panel can
|
||||
take it out of the header. Interrupt is pinned: a running
|
||||
session must always be stoppable from here. */}
|
||||
<HeaderActions
|
||||
name="header:conversation"
|
||||
actions={[
|
||||
...((serverRunning || serverPaused)
|
||||
? [
|
||||
{
|
||||
key: "interrupt",
|
||||
label: "Interrupt",
|
||||
pinned: true,
|
||||
node: (
|
||||
<button
|
||||
onClick={() => void interrupt()}
|
||||
disabled={interrupting}
|
||||
title={
|
||||
interruptErr
|
||||
? `Interrupt failed: ${interruptErr}`
|
||||
: "Interrupt this running session (sends Esc / SIGINT)"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-1.5 rounded-md border px-2 text-[12px] font-medium transition-colors",
|
||||
interruptErr
|
||||
? "border-rose-500/40 bg-rose-500/10 text-rose-500"
|
||||
: "border-rose-500/30 text-rose-500 hover:bg-rose-500/10",
|
||||
interrupting && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{interrupting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CircleStop className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{interrupting ? "Stopping…" : "Interrupt"}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
} satisfies HeaderAction,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "expand",
|
||||
label: "Expand tools",
|
||||
node: (
|
||||
<button
|
||||
onClick={() => setAll(expandState !== "expanded")}
|
||||
title={
|
||||
expandState === "expanded"
|
||||
? "All tools expanded — collapse them"
|
||||
: expandState === "collapsed"
|
||||
? "All tools collapsed — expand them"
|
||||
: "Expand all tools"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
|
||||
expandState === "mixed" ? "text-muted-foreground" : "text-primary",
|
||||
)}
|
||||
>
|
||||
{expandState === "expanded" ? (
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
// No composer toggle: it's a plain bar pinned under the thread.
|
||||
{ key: "visibility", label: "Visibility", node: <VisibilityPopover /> },
|
||||
{
|
||||
key: "details",
|
||||
label: "Details",
|
||||
node: (
|
||||
<button
|
||||
onClick={() => setInfoOpen((o) => !o)}
|
||||
title="Costs & details"
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
|
||||
infoOpen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useStore } from "@/store";
|
||||
import { useHydrated } from "@/lib/useHydrated";
|
||||
import { buildFeed } from "@/lib/notifications";
|
||||
import { PageHeader } from "@/technical/PageHeader";
|
||||
import { HeaderActions } from "@/technical/ui/HeaderActions";
|
||||
import { NotificationCard } from "@/business/notifications/components/NotificationCard";
|
||||
import { RunningConversations } from "@/business/conversations/components/RunningConversations";
|
||||
import { SpawnDrawer } from "@/business/composer/components/SpawnDrawer";
|
||||
@@ -71,34 +72,65 @@ export function ConversationHome() {
|
||||
: "notify-done pushes"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to="/conversations"
|
||||
title="All conversations"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/notifications"
|
||||
title="All notifications"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Inbox className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboards/conversations"
|
||||
title="Analytics dashboard"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</button>
|
||||
{/* Hold to rearrange, or drop on the stash to bench a shortcut you
|
||||
never take (see HeaderActions). */}
|
||||
<HeaderActions
|
||||
name="header:conversation-home"
|
||||
actions={[
|
||||
{
|
||||
key: "conversations",
|
||||
label: "All conversations",
|
||||
node: (
|
||||
<Link
|
||||
to="/conversations"
|
||||
title="All conversations"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
label: "All notifications",
|
||||
node: (
|
||||
<Link
|
||||
to="/notifications"
|
||||
title="All notifications"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Inbox className="h-4 w-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
label: "Analytics",
|
||||
node: (
|
||||
<Link
|
||||
to="/dashboards/conversations"
|
||||
title="Analytics dashboard"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
label: "Refresh",
|
||||
node: (
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EffortTags, ModelTags, effortsOf } from "@/lib/models";
|
||||
import { useLive } from "@/lib/sseBus";
|
||||
import { convTime } from "@/lib/analytics";
|
||||
import { PageHeader } from "@/technical/PageHeader";
|
||||
import { HeaderActions } from "@/technical/ui/HeaderActions";
|
||||
import {
|
||||
StateBadge,
|
||||
HarnessBadge,
|
||||
@@ -206,27 +207,52 @@ export function Conversations() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to="/conversation"
|
||||
title="Back"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboards/conversations"
|
||||
title="Analytics dashboard"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</button>
|
||||
{/* Back is pinned — it is this page's way out. */}
|
||||
<HeaderActions
|
||||
name="header:conversations"
|
||||
actions={[
|
||||
{
|
||||
key: "back",
|
||||
label: "Back",
|
||||
pinned: true,
|
||||
node: (
|
||||
<Link
|
||||
to="/conversation"
|
||||
title="Back"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
label: "Analytics",
|
||||
node: (
|
||||
<Link
|
||||
to="/dashboards/conversations"
|
||||
title="Analytics dashboard"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
label: "Refresh",
|
||||
node: (
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
{/* search + controls */}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Gauge, MessagesSquare, Leaf, Waypoints } from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
|
||||
interface Page {
|
||||
to: string;
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn, fmtTokens, fmtCost } from "@/lib/utils";
|
||||
import { useStore } from "@/store";
|
||||
import { useConversationsFull } from "@/lib/queries";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
import { useLive } from "@/lib/sseBus";
|
||||
import {
|
||||
RANGES,
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
Activity,
|
||||
Coins,
|
||||
} from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn, fmtTokens, fmtNum, fmtCost, relTime } from "@/lib/utils";
|
||||
import { useStore } from "@/store";
|
||||
import { useBundle, useDashboard } from "@/lib/queries";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
import { Button } from "@/technical/ui/button";
|
||||
import type { SkillStat } from "@/types";
|
||||
import { Roll } from "@/technical/Roll";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Search, X, Brain, DollarSign, Bell, ClipboardList, Target } from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn, fmtTokens, fmtCost } from "@/lib/utils";
|
||||
import { useStore } from "@/store";
|
||||
import { useBundle } from "@/lib/queries";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { buildTree, filterFiles } from "@/tree";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
import { FileTree } from "./FileTree";
|
||||
|
||||
interface Shortcut {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { FolderTree, FileText, Brain, Wrench, Bell, ClipboardList, Target } from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { useBundle } from "@/lib/queries";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
import { PageHeader } from "@/technical/PageHeader";
|
||||
|
||||
interface JumpDef {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button, EmptyState, FuzzyList, type FuzzyRenderContext } from "@gabvdl/
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useGoals } from "@/lib/queries";
|
||||
import { PageHeader } from "@/technical/PageHeader";
|
||||
import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions";
|
||||
import { GoalCard } from "../components/GoalCard";
|
||||
import type { GoalBoardEntry } from "@/types";
|
||||
import { Roll } from "@/technical/Roll";
|
||||
@@ -71,21 +72,43 @@ export function Goals() {
|
||||
title="Goals"
|
||||
subtitle={subtitle()}
|
||||
>
|
||||
{agents > 0 && (
|
||||
<span
|
||||
className="mr-1 flex shrink-0 items-center gap-1 rounded-full bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-600 dark:text-sky-400"
|
||||
title={`${agents} agent ${agents === 1 ? "session" : "sessions"} running across all goals`}
|
||||
>
|
||||
<Bot className="h-3 w-3 animate-pulse" />
|
||||
<Roll value={agents} />
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => goalsQ.refetch()}
|
||||
tooltip="Refresh"
|
||||
icon={<RefreshCw className={cn("h-4 w-4", goalsQ.isFetching && "animate-spin")} />}
|
||||
{/* The running-agents badge rides in the same row, so it can be
|
||||
benched by anyone who does not want a live counter up there. */}
|
||||
<HeaderActions
|
||||
name="header:goals"
|
||||
actions={[
|
||||
...(agents > 0
|
||||
? [
|
||||
{
|
||||
key: "agents",
|
||||
label: "Agents running",
|
||||
node: (
|
||||
<span
|
||||
className="mr-1 flex shrink-0 items-center gap-1 rounded-full bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-600 dark:text-sky-400"
|
||||
title={`${agents} agent ${agents === 1 ? "session" : "sessions"} running across all goals`}
|
||||
>
|
||||
<Bot className="h-3 w-3 animate-pulse" />
|
||||
<Roll value={agents} />
|
||||
</span>
|
||||
),
|
||||
} satisfies HeaderAction,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "refresh",
|
||||
label: "Refresh",
|
||||
pinned: true,
|
||||
node: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => goalsQ.refetch()}
|
||||
tooltip="Refresh"
|
||||
icon={<RefreshCw className={cn("h-4 w-4", goalsQ.isFetching && "animate-spin")} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ import { useIsFetching } from "@tanstack/react-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { useStore } from "@/store";
|
||||
import { useDesktop } from "@/lib/floating";
|
||||
import { sectionOf, type Section } from "@/nav";
|
||||
import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions";
|
||||
import { ConversationsPanel } from "@/business/conversations/components/ConversationsPanel";
|
||||
import { DashboardsPanel } from "@/business/dashboards/components/DashboardsPanel";
|
||||
import { FilesPanel } from "@/business/files/components/FilesPanel";
|
||||
@@ -37,6 +39,7 @@ const META: Record<Section, { title: string; icon: typeof LayoutDashboard }> = {
|
||||
*/
|
||||
export function DrawerContent() {
|
||||
const setSidebar = useStore((s) => s.setSidebar);
|
||||
const isDesktop = useDesktop();
|
||||
// Refresh = revalidate every mounted React Query (whatever this panel shows).
|
||||
const fetching = useIsFetching() > 0;
|
||||
const refresh = () => queryClient.invalidateQueries();
|
||||
@@ -58,20 +61,47 @@ export function DrawerContent() {
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<Icon className="h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="truncate text-sm font-semibold">{title}</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
title="Refresh"
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", fetching && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSidebar(false)}
|
||||
title="Close"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground md:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Hold-to-rearrange row. Close is rendered conditionally rather than
|
||||
with `md:hidden`: a display:none slot measures zero, which would
|
||||
leave the drag a dead gap to hit. It is also pinned — on mobile the
|
||||
drawer covers the app, so benching its only dismiss is a trap. */}
|
||||
<HeaderActions
|
||||
name="header:drawer"
|
||||
className="ml-auto flex shrink-0 items-center gap-0.5"
|
||||
actions={[
|
||||
{
|
||||
key: "refresh",
|
||||
label: "Refresh",
|
||||
node: (
|
||||
<button
|
||||
onClick={refresh}
|
||||
title="Refresh"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", fetching && "animate-spin")} />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
...(isDesktop
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "close",
|
||||
label: "Close",
|
||||
pinned: true,
|
||||
node: (
|
||||
<button
|
||||
onClick={() => setSidebar(false)}
|
||||
title="Close"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
),
|
||||
} satisfies HeaderAction,
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isDiff ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNotifications, useNotifyLog } from "@/lib/queries";
|
||||
import { useLive } from "@/lib/sseBus";
|
||||
import { buildFeed, mergeLog, matchNotif } from "@/lib/notifications";
|
||||
import { PageHeader } from "@/technical/PageHeader";
|
||||
import { HeaderActions } from "@/technical/ui/HeaderActions";
|
||||
import { NotificationCard } from "@/business/notifications/components/NotificationCard";
|
||||
|
||||
/** Group header label for a notification's day. */
|
||||
@@ -71,13 +72,24 @@ export function NotificationsHome() {
|
||||
: "loading…"
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</button>
|
||||
<HeaderActions
|
||||
name="header:notifications"
|
||||
actions={[
|
||||
{
|
||||
key: "refresh",
|
||||
label: "Refresh",
|
||||
node: (
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
{/* search */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { fmtTokens, fmtCost, fmtNum, relTime } from "@/lib/utils";
|
||||
import { commitDiffHref } from "@/lib/diffRoute";
|
||||
import { useStore } from "@/store";
|
||||
import { fetchProject, fetchConversations, subscribeEvents } from "@/api";
|
||||
import { SectionStack } from "@/technical/ui/SectionStack";
|
||||
import { Markdown } from "@/technical/Markdown";
|
||||
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
|
||||
import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
|
||||
@@ -212,142 +213,172 @@ export function Project() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* aggregated conversation cost */}
|
||||
{project.costs && <CostCard costs={project.costs} />}
|
||||
|
||||
{/* GOAL.md checklist — one Work button per item — above the file */}
|
||||
{project.goal && (
|
||||
<GoalChecklist goal={project.goal} kind="project" dir={project.dir} />
|
||||
)}
|
||||
|
||||
{/* GOAL.md — the direction the goal-keeper agent pushes forward */}
|
||||
{project.goal && <GoalSection goal={project.goal} />}
|
||||
|
||||
{/* key files present */}
|
||||
{project.keyFiles.length > 0 && (
|
||||
<Section icon={FileText} title="Key files">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.keyFiles.map((f) => (
|
||||
<span
|
||||
key={f.name}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* npm scripts */}
|
||||
{project.scripts.length > 0 && (
|
||||
<Section icon={Terminal} title="Scripts">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.scripts.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* .env editor — regular vars + write-only secrets */}
|
||||
<EnvEditor slug={project.dir} />
|
||||
|
||||
{/* recent commits */}
|
||||
{project.commits.length > 0 && (
|
||||
<Section icon={GitCommit} title="Recent commits">
|
||||
<ul className="space-y-0.5">
|
||||
{project.commits.map((c) => (
|
||||
<li key={c.hash}>
|
||||
<Link
|
||||
to={commitDiffHref("project", slug, c.hash)}
|
||||
className="flex items-baseline gap-2 rounded-md px-1.5 py-1 text-[13px] hover:bg-accent/60"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-primary">
|
||||
{c.hash}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{relTime(c.date)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* conversations that worked on this project */}
|
||||
<Section icon={MessagesSquare} title="Conversations">
|
||||
{convos == null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : convos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No conversations tagged with this project yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{convos.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/conversation/${c.id}`}
|
||||
className="block rounded-lg border border-border bg-card p-2.5 hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StateBadge state={c.meta?.state} />
|
||||
<span className="truncate text-[13px] font-medium">
|
||||
{c.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-mono text-[13px] font-semibold tabular-nums">
|
||||
<Roll value={fmtCost(c.cost)} />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<Roll value={fmtTokens(c.tokens)} /> tok
|
||||
</div>
|
||||
</div>
|
||||
{/* Every block below is hold-to-rearrange and hold-to-hide: hold one
|
||||
for a moment and drop it on the stash to bench it (SectionStack).
|
||||
Each section carries its own bottom margin, hence a gapless stack. */}
|
||||
<SectionStack
|
||||
name="sections:project"
|
||||
className="space-y-0"
|
||||
sections={[
|
||||
{
|
||||
key: "cost",
|
||||
label: "Cost",
|
||||
node: project.costs && <CostCard costs={project.costs} />,
|
||||
},
|
||||
{
|
||||
key: "goal-checklist",
|
||||
label: "Goal checklist",
|
||||
node: project.goal && (
|
||||
<GoalChecklist goal={project.goal} kind="project" dir={project.dir} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "goal",
|
||||
label: "GOAL.md",
|
||||
node: project.goal && <GoalSection goal={project.goal} />,
|
||||
},
|
||||
{
|
||||
key: "key-files",
|
||||
label: "Key files",
|
||||
node: project.keyFiles.length > 0 && (
|
||||
<Section icon={FileText} title="Key files">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.keyFiles.map((f) => (
|
||||
<span
|
||||
key={f.name}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<StatusChecks meta={c.meta} hideEmpty />
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "scripts",
|
||||
label: "Scripts",
|
||||
node: project.scripts.length > 0 && (
|
||||
<Section icon={Terminal} title="Scripts">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.scripts.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* README */}
|
||||
{project.readme && (
|
||||
<Section icon={FileText} title="README.md">
|
||||
<Markdown source={project.readme} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* CLAUDE.md (collapsed) */}
|
||||
{project.claudeMd && (
|
||||
<details className="mt-4 overflow-hidden rounded-xl border border-border bg-card">
|
||||
<summary className="cursor-pointer list-none px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground">
|
||||
CLAUDE.md
|
||||
</summary>
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<Markdown source={project.claudeMd} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment",
|
||||
node: <EnvEditor slug={project.dir} />,
|
||||
},
|
||||
{
|
||||
key: "commits",
|
||||
label: "Commits",
|
||||
node: project.commits.length > 0 && (
|
||||
<Section icon={GitCommit} title="Recent commits">
|
||||
<ul className="space-y-0.5">
|
||||
{project.commits.map((c) => (
|
||||
<li key={c.hash}>
|
||||
<Link
|
||||
to={commitDiffHref("project", slug, c.hash)}
|
||||
className="flex items-baseline gap-2 rounded-md px-1.5 py-1 text-[13px] hover:bg-accent/60"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-primary">
|
||||
{c.hash}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{relTime(c.date)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "conversations",
|
||||
label: "Conversations",
|
||||
node: <Section icon={MessagesSquare} title="Conversations">
|
||||
{convos == null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : convos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No conversations tagged with this project yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{convos.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/conversation/${c.id}`}
|
||||
className="block rounded-lg border border-border bg-card p-2.5 hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StateBadge state={c.meta?.state} />
|
||||
<span className="truncate text-[13px] font-medium">
|
||||
{c.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-mono text-[13px] font-semibold tabular-nums">
|
||||
<Roll value={fmtCost(c.cost)} />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<Roll value={fmtTokens(c.tokens)} /> tok
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<StatusChecks meta={c.meta} hideEmpty />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>,
|
||||
},
|
||||
{
|
||||
key: "readme",
|
||||
label: "README",
|
||||
node: project.readme && (
|
||||
<Section icon={FileText} title="README.md">
|
||||
<Markdown source={project.readme} />
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "claude-md",
|
||||
label: "CLAUDE.md",
|
||||
node: project.claudeMd && (
|
||||
<details className="mt-4 overflow-hidden rounded-xl border border-border bg-card">
|
||||
<summary className="cursor-pointer list-none px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground">
|
||||
CLAUDE.md
|
||||
</summary>
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<Markdown source={project.claudeMd} />
|
||||
</div>
|
||||
</details>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
fetchMemories,
|
||||
subscribeEvents,
|
||||
} from "@/api";
|
||||
import { SectionStack } from "@/technical/ui/SectionStack";
|
||||
import { Markdown } from "@/technical/Markdown";
|
||||
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
|
||||
import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
|
||||
@@ -528,245 +529,279 @@ export function Service() {
|
||||
</Fact>
|
||||
</div>
|
||||
|
||||
{/* GOAL.md checklist — one Work button per item — above the file */}
|
||||
{service.goal && (
|
||||
<GoalChecklist goal={service.goal} kind="service" dir={service.dir} />
|
||||
)}
|
||||
|
||||
{/* GOAL.md — the direction the goal-keeper agent pushes forward */}
|
||||
{service.goal && <GoalSection goal={service.goal} />}
|
||||
|
||||
{/* URLs */}
|
||||
{service.urls.length > 0 && (
|
||||
<Section icon={Globe} title="URLs" count={service.urls.length}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{service.urls.map((u) =>
|
||||
u.startsWith("http") ? (
|
||||
<a
|
||||
key={u}
|
||||
href={u}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 text-[12px] text-primary hover:bg-accent"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{hostOf(u)}
|
||||
</a>
|
||||
) : (
|
||||
<Chip key={u}>{u}</Chip>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* containers */}
|
||||
{service.containersDetail.length > 0 && (
|
||||
<Section icon={Boxes} title="Containers" count={service.containersDetail.length}>
|
||||
<div className="space-y-2">
|
||||
{service.containersDetail.map((c) => (
|
||||
<ContainerCard key={`${c.composeFile}:${c.key}`} c={c} />
|
||||
))}
|
||||
</div>
|
||||
{service.composeFiles.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
compose:
|
||||
</span>
|
||||
{service.composeFiles.map((f) => (
|
||||
<Chip key={f}>{f}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* traefik routers */}
|
||||
{(httpRouters.length > 0 || tcpRouters.length > 0) && (
|
||||
<Section icon={RouteIcon} title="Traefik routers" count={service.routers.length}>
|
||||
<div className="space-y-1.5">
|
||||
{service.routers.map((r) => (
|
||||
<div key={`${r.kind}:${r.name}`} className="rounded-lg border border-border bg-muted/20 p-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-mono text-[12px] font-semibold text-primary">{r.name}</span>
|
||||
{r.kind === "tcp" && <Chip>tcp</Chip>}
|
||||
{r.priority != null && <Chip>prio {r.priority}</Chip>}
|
||||
{r.entryPoints.map((e) => (
|
||||
<Chip key={e}>{e}</Chip>
|
||||
{/* Hold-to-rearrange, hold-to-hide: drop a section on the stash
|
||||
popover to bench it, drag its tag back to restore it. Sections
|
||||
carry their own bottom margin, hence a gapless stack. */}
|
||||
<SectionStack
|
||||
name="sections:service"
|
||||
className="space-y-0"
|
||||
sections={[
|
||||
{
|
||||
key: "goal-checklist",
|
||||
label: "Goal checklist",
|
||||
node: service.goal && (
|
||||
<GoalChecklist goal={service.goal} kind="service" dir={service.dir} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "goal",
|
||||
label: "GOAL.md",
|
||||
node: service.goal && <GoalSection goal={service.goal} />,
|
||||
},
|
||||
{
|
||||
key: "urls",
|
||||
label: "URLs",
|
||||
node: service.urls.length > 0 && (
|
||||
<Section icon={Globe} title="URLs" count={service.urls.length}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{service.urls.map((u) =>
|
||||
u.startsWith("http") ? (
|
||||
<a
|
||||
key={u}
|
||||
href={u}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 text-[12px] text-primary hover:bg-accent"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{hostOf(u)}
|
||||
</a>
|
||||
) : (
|
||||
<Chip key={u}>{u}</Chip>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "containers",
|
||||
label: "Containers",
|
||||
node: service.containersDetail.length > 0 && (
|
||||
<Section icon={Boxes} title="Containers" count={service.containersDetail.length}>
|
||||
<div className="space-y-2">
|
||||
{service.containersDetail.map((c) => (
|
||||
<ContainerCard key={`${c.composeFile}:${c.key}`} c={c} />
|
||||
))}
|
||||
</div>
|
||||
<code className="mt-1 block truncate text-[11px] text-muted-foreground" title={r.rule}>
|
||||
{r.rule}
|
||||
</code>
|
||||
{r.middlewares.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{r.middlewares.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-medium text-accent-foreground"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
{service.composeFiles.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
compose:
|
||||
</span>
|
||||
{service.composeFiles.map((f) => (
|
||||
<Chip key={f}>{f}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{service.traefikServices.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{service.traefikServices.map((ts) => (
|
||||
<div key={ts.name} className="flex items-center gap-1 font-mono text-[11px] text-muted-foreground">
|
||||
<span className="text-foreground/70">{ts.name}</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/40" />
|
||||
<span className="truncate">{ts.servers.join(", ")}</span>
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "routers",
|
||||
label: "Traefik routers",
|
||||
node: (httpRouters.length > 0 || tcpRouters.length > 0) && (
|
||||
<Section icon={RouteIcon} title="Traefik routers" count={service.routers.length}>
|
||||
<div className="space-y-1.5">
|
||||
{service.routers.map((r) => (
|
||||
<div key={`${r.kind}:${r.name}`} className="rounded-lg border border-border bg-muted/20 p-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-mono text-[12px] font-semibold text-primary">{r.name}</span>
|
||||
{r.kind === "tcp" && <Chip>tcp</Chip>}
|
||||
{r.priority != null && <Chip>prio {r.priority}</Chip>}
|
||||
{r.entryPoints.map((e) => (
|
||||
<Chip key={e}>{e}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<code className="mt-1 block truncate text-[11px] text-muted-foreground" title={r.rule}>
|
||||
{r.rule}
|
||||
</code>
|
||||
{r.middlewares.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{r.middlewares.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-medium text-accent-foreground"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* env keys (union) */}
|
||||
{service.envKeys.length > 0 && (
|
||||
<Section icon={KeyRound} title="Environment keys" count={service.envKeys.length}>
|
||||
<p className="mb-2 text-[11px] text-muted-foreground/70">
|
||||
Keys only — values are never read.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{service.envKeys.map((k) => (
|
||||
<Chip key={k}>{k}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* file tree */}
|
||||
{service.tree.length > 0 && (
|
||||
<Section icon={Folder} title="Files">
|
||||
<ul className="max-h-96 overflow-auto">
|
||||
{service.tree.map((n) => (
|
||||
<TreeItem key={n.path} node={n} depth={0} onOpen={setPreview} />
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* recent commits */}
|
||||
{service.commits.length > 0 && (
|
||||
<Section icon={GitCommit} title="Recent commits">
|
||||
<ul className="space-y-0.5">
|
||||
{service.commits.map((c) => (
|
||||
<li key={c.hash}>
|
||||
<Link
|
||||
to={commitDiffHref("service", slug, c.hash)}
|
||||
className="flex items-baseline gap-2 rounded-md px-1.5 py-1 text-[13px] hover:bg-accent/60"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-primary">{c.hash}</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{relTime(c.date)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* conversations that worked on this service */}
|
||||
<Section icon={MessagesSquare} title="Conversations" count={convos?.length}>
|
||||
{convos == null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : convos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No conversations tagged with this service yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{convos.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/conversation/${c.id}`}
|
||||
className="block rounded-lg border border-border bg-card p-2.5 hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StateBadge state={c.meta?.state} />
|
||||
<span className="truncate text-[13px] font-medium">{c.title}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)}
|
||||
</div>
|
||||
{service.traefikServices.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{service.traefikServices.map((ts) => (
|
||||
<div key={ts.name} className="flex items-center gap-1 font-mono text-[11px] text-muted-foreground">
|
||||
<span className="text-foreground/70">{ts.name}</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/40" />
|
||||
<span className="truncate">{ts.servers.join(", ")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-mono text-[13px] font-semibold tabular-nums">
|
||||
<Roll value={fmtCost(c.cost)} />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<Roll value={fmtTokens(c.tokens)} /> tok
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<StatusChecks meta={c.meta} hideEmpty />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* memories that mention this service */}
|
||||
{memories != null && memories.length > 0 && (
|
||||
<Section icon={Brain} title="Memories" count={memories.length}>
|
||||
<div className="space-y-1.5">
|
||||
{memories.map((m) => (
|
||||
<Link
|
||||
key={m.slug}
|
||||
to={`/memories/${encodeURIComponent(m.slug)}`}
|
||||
className="block rounded-lg border border-border bg-card p-2.5 hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{m.type && (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{m.type}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[13px] font-medium">{m.name}</span>
|
||||
</div>
|
||||
{m.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-[12px] text-muted-foreground">
|
||||
{m.description}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* README */}
|
||||
{service.readme && (
|
||||
<Section icon={FileText} title="README.md">
|
||||
<Markdown source={service.readme} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* CLAUDE.md (collapsed) */}
|
||||
{service.claudeMd && (
|
||||
<details className="mt-4 overflow-hidden rounded-xl border border-border bg-card">
|
||||
<summary className="cursor-pointer list-none px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground">
|
||||
CLAUDE.md
|
||||
</summary>
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<Markdown source={service.claudeMd} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "env-keys",
|
||||
label: "Environment keys",
|
||||
node: service.envKeys.length > 0 && (
|
||||
<Section icon={KeyRound} title="Environment keys" count={service.envKeys.length}>
|
||||
<p className="mb-2 text-[11px] text-muted-foreground/70">
|
||||
Keys only — values are never read.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{service.envKeys.map((k) => (
|
||||
<Chip key={k}>{k}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
label: "Files",
|
||||
node: service.tree.length > 0 && (
|
||||
<Section icon={Folder} title="Files">
|
||||
<ul className="max-h-96 overflow-auto">
|
||||
{service.tree.map((n) => (
|
||||
<TreeItem key={n.path} node={n} depth={0} onOpen={setPreview} />
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "commits",
|
||||
label: "Commits",
|
||||
node: service.commits.length > 0 && (
|
||||
<Section icon={GitCommit} title="Recent commits">
|
||||
<ul className="space-y-0.5">
|
||||
{service.commits.map((c) => (
|
||||
<li key={c.hash}>
|
||||
<Link
|
||||
to={commitDiffHref("service", slug, c.hash)}
|
||||
className="flex items-baseline gap-2 rounded-md px-1.5 py-1 text-[13px] hover:bg-accent/60"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-primary">{c.hash}</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{relTime(c.date)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "conversations",
|
||||
label: "Conversations",
|
||||
node: <Section icon={MessagesSquare} title="Conversations" count={convos?.length}>
|
||||
{convos == null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : convos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No conversations tagged with this service yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{convos.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/conversation/${c.id}`}
|
||||
className="block rounded-lg border border-border bg-card p-2.5 hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StateBadge state={c.meta?.state} />
|
||||
<span className="truncate text-[13px] font-medium">{c.title}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-mono text-[13px] font-semibold tabular-nums">
|
||||
<Roll value={fmtCost(c.cost)} />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<Roll value={fmtTokens(c.tokens)} /> tok
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<StatusChecks meta={c.meta} hideEmpty />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>,
|
||||
},
|
||||
{
|
||||
key: "memories",
|
||||
label: "Memories",
|
||||
node: memories != null && memories.length > 0 && (
|
||||
<Section icon={Brain} title="Memories" count={memories.length}>
|
||||
<div className="space-y-1.5">
|
||||
{memories.map((m) => (
|
||||
<Link
|
||||
key={m.slug}
|
||||
to={`/memories/${encodeURIComponent(m.slug)}`}
|
||||
className="block rounded-lg border border-border bg-card p-2.5 hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{m.type && (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{m.type}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[13px] font-medium">{m.name}</span>
|
||||
</div>
|
||||
{m.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-[12px] text-muted-foreground">
|
||||
{m.description}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "readme",
|
||||
label: "README",
|
||||
node: service.readme && (
|
||||
<Section icon={FileText} title="README.md">
|
||||
<Markdown source={service.readme} />
|
||||
</Section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "claude-md",
|
||||
label: "CLAUDE.md",
|
||||
node: service.claudeMd && (
|
||||
<details className="mt-4 overflow-hidden rounded-xl border border-border bg-card">
|
||||
<summary className="cursor-pointer list-none px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground">
|
||||
CLAUDE.md
|
||||
</summary>
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<Markdown source={service.claudeMd} />
|
||||
</div>
|
||||
</details>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Monitor, CreditCard, Wand2, Clock, Webhook, Rabbit } from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettings, type PromptShortcut } from "@/settings";
|
||||
import { SHORTCUT_ICON_KEYS, shortcutIcon } from "@/lib/shortcutIcons";
|
||||
import { PageHeader } from "@/technical/PageHeader";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
|
||||
/** A compact text input used across the shortcut editor. */
|
||||
function TextField({
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/settings";
|
||||
import { useAvatarSettings } from "@/lib/avatar";
|
||||
import { subscriptionPaid, billingMonths } from "@/lib/analytics";
|
||||
import { SectionStack } from "@/technical/ui/SectionStack";
|
||||
import { PageHeader } from "@/technical/PageHeader";
|
||||
|
||||
const TIMEZONES = [
|
||||
@@ -377,165 +378,183 @@ export function Settings() {
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
{/* ── Display ─────────────────────────────────────────────────── */}
|
||||
<Card
|
||||
id="display"
|
||||
icon={<Monitor className="h-4 w-4 text-primary" />}
|
||||
title="Display"
|
||||
innerRef={(el) => (refs.current.display = el)}
|
||||
>
|
||||
<Row title="Theme" help="Color scheme for the interface.">
|
||||
<Segmented<ThemePref>
|
||||
value={s.theme}
|
||||
onChange={s.setTheme}
|
||||
options={[
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-3.5 w-3.5" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-3.5 w-3.5" /> },
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-3.5 w-3.5" /> },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row title="Currency" help="How all costs are displayed.">
|
||||
<Segmented<Currency>
|
||||
value={s.currency}
|
||||
onChange={s.setCurrency}
|
||||
options={[
|
||||
{ value: "USD", label: "$ USD" },
|
||||
{ value: "EUR", label: "€ EUR" },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{s.currency === "EUR" && (
|
||||
<Row title="USD → EUR rate" help="Exchange rate used to convert dollar costs to euros.">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={s.usdToEur}
|
||||
onChange={(e) => s.setUsdToEur(Number(e.target.value) || 0)}
|
||||
className="w-28 rounded-lg border border-border bg-background px-3 py-1.5 text-right font-mono text-sm tabular-nums outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row title="Timezone" help="Used for day-by-day charts and timestamps.">
|
||||
<select
|
||||
value={s.timezone}
|
||||
onChange={(e) => s.setTimezone(e.target.value)}
|
||||
className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">System ({Intl.DateTimeFormat().resolvedOptions().timeZone})</option>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
<Row title="Preview" help="An example cost in your current settings.">
|
||||
<span className="font-mono text-sm font-semibold tabular-nums">
|
||||
{fmtCost(12.3456, { currency: s.currency, rate: s.usdToEur })}
|
||||
</span>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ── Agent avatar ────────────────────────────────────────────── */}
|
||||
<Card
|
||||
id="avatar"
|
||||
icon={<Rabbit className="h-4 w-4 text-primary" />}
|
||||
title="Agent avatar"
|
||||
innerRef={(el) => (refs.current.avatar = el)}
|
||||
>
|
||||
<AvatarSettingsRows />
|
||||
</Card>
|
||||
|
||||
{/* ── Notification webhooks ───────────────────────────────────── */}
|
||||
<Card
|
||||
id="webhooks"
|
||||
icon={<WebhookIcon className="h-4 w-4 text-primary" />}
|
||||
title="Notification webhooks"
|
||||
innerRef={(el) => (refs.current.webhooks = el)}
|
||||
>
|
||||
<WebhooksEditor />
|
||||
</Card>
|
||||
|
||||
{/* ── Claude subscription ─────────────────────────────────────── */}
|
||||
<Card
|
||||
id="subscription"
|
||||
icon={<CreditCard className="h-4 w-4 text-primary" />}
|
||||
title="Claude subscription"
|
||||
innerRef={(el) => (refs.current.subscription = el)}
|
||||
>
|
||||
<Row title="Plan" help="Your Claude plan drives the token-savings estimate.">
|
||||
<Segmented<Plan>
|
||||
value={s.subscription.plan}
|
||||
onChange={pickPlan}
|
||||
options={[
|
||||
{ value: "none", label: PLAN_LABEL.none },
|
||||
{ value: "pro", label: PLAN_LABEL.pro },
|
||||
{ value: "max5", label: PLAN_LABEL.max5 },
|
||||
{ value: "max20", label: PLAN_LABEL.max20 },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{s.subscription.plan !== "none" && (
|
||||
<>
|
||||
<Row title="Monthly cost" help="What you actually pay each month.">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 focus-within:border-primary">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{s.currency === "EUR" ? "€" : "$"}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={Math.round(costInDisplay * 100) / 100}
|
||||
onChange={(e) => setCostFromDisplay(Number(e.target.value) || 0)}
|
||||
className="w-20 bg-transparent text-right font-mono text-sm tabular-nums outline-none"
|
||||
{/* Hold a card to rearrange the page, or drop it on the stash to
|
||||
bench it — Settings is mostly write-once, and a benched card is
|
||||
one less thing to scroll past. */}
|
||||
<SectionStack
|
||||
name="sections:settings"
|
||||
className="space-y-8"
|
||||
sections={[
|
||||
{
|
||||
key: "display",
|
||||
label: "Display",
|
||||
node: <Card
|
||||
id="display"
|
||||
icon={<Monitor className="h-4 w-4 text-primary" />}
|
||||
title="Display"
|
||||
innerRef={(el) => (refs.current.display = el)}
|
||||
>
|
||||
<Row title="Theme" help="Color scheme for the interface.">
|
||||
<Segmented<ThemePref>
|
||||
value={s.theme}
|
||||
onChange={s.setTheme}
|
||||
options={[
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-3.5 w-3.5" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-3.5 w-3.5" /> },
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-3.5 w-3.5" /> },
|
||||
]}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">/mo</span>
|
||||
</div>
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<Row title="Start date" help="When this subscription began — the savings epoch.">
|
||||
<input
|
||||
type="date"
|
||||
value={s.subscription.startDate}
|
||||
onChange={(e) => s.setSubscription({ startDate: e.target.value })}
|
||||
className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
<Row title="Currency" help="How all costs are displayed.">
|
||||
<Segmented<Currency>
|
||||
value={s.currency}
|
||||
onChange={s.setCurrency}
|
||||
options={[
|
||||
{ value: "USD", label: "$ USD" },
|
||||
{ value: "EUR", label: "€ EUR" },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* live estimate */}
|
||||
<div className="border-t border-border py-4">
|
||||
{configured ? (
|
||||
<div className="rounded-lg border border-primary/30 bg-gradient-to-br from-primary/10 to-transparent p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||
Estimate
|
||||
{s.currency === "EUR" && (
|
||||
<Row title="USD → EUR rate" help="Exchange rate used to convert dollar costs to euros.">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={s.usdToEur}
|
||||
onChange={(e) => s.setUsdToEur(Number(e.target.value) || 0)}
|
||||
className="w-28 rounded-lg border border-border bg-background px-3 py-1.5 text-right font-mono text-sm tabular-nums outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row title="Timezone" help="Used for day-by-day charts and timestamps.">
|
||||
<select
|
||||
value={s.timezone}
|
||||
onChange={(e) => s.setTimezone(e.target.value)}
|
||||
className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">System ({Intl.DateTimeFormat().resolvedOptions().timeZone})</option>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
<Row title="Preview" help="An example cost in your current settings.">
|
||||
<span className="font-mono text-sm font-semibold tabular-nums">
|
||||
{fmtCost(12.3456, { currency: s.currency, rate: s.usdToEur })}
|
||||
</span>
|
||||
</Row>
|
||||
</Card>,
|
||||
},
|
||||
{
|
||||
key: "avatar",
|
||||
label: "Agent avatar",
|
||||
node: <Card
|
||||
id="avatar"
|
||||
icon={<Rabbit className="h-4 w-4 text-primary" />}
|
||||
title="Agent avatar"
|
||||
innerRef={(el) => (refs.current.avatar = el)}
|
||||
>
|
||||
<AvatarSettingsRows />
|
||||
</Card>,
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
label: "Webhooks",
|
||||
node: <Card
|
||||
id="webhooks"
|
||||
icon={<WebhookIcon className="h-4 w-4 text-primary" />}
|
||||
title="Notification webhooks"
|
||||
innerRef={(el) => (refs.current.webhooks = el)}
|
||||
>
|
||||
<WebhooksEditor />
|
||||
</Card>,
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "Claude subscription",
|
||||
node: <Card
|
||||
id="subscription"
|
||||
icon={<CreditCard className="h-4 w-4 text-primary" />}
|
||||
title="Claude subscription"
|
||||
innerRef={(el) => (refs.current.subscription = el)}
|
||||
>
|
||||
<Row title="Plan" help="Your Claude plan drives the token-savings estimate.">
|
||||
<Segmented<Plan>
|
||||
value={s.subscription.plan}
|
||||
onChange={pickPlan}
|
||||
options={[
|
||||
{ value: "none", label: PLAN_LABEL.none },
|
||||
{ value: "pro", label: PLAN_LABEL.pro },
|
||||
{ value: "max5", label: PLAN_LABEL.max5 },
|
||||
{ value: "max20", label: PLAN_LABEL.max20 },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{s.subscription.plan !== "none" && (
|
||||
<>
|
||||
<Row title="Monthly cost" help="What you actually pay each month.">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 focus-within:border-primary">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{s.currency === "EUR" ? "€" : "$"}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={Math.round(costInDisplay * 100) / 100}
|
||||
onChange={(e) => setCostFromDisplay(Number(e.target.value) || 0)}
|
||||
className="w-20 bg-transparent text-right font-mono text-sm tabular-nums outline-none"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">/mo</span>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Row title="Start date" help="When this subscription began — the savings epoch.">
|
||||
<input
|
||||
type="date"
|
||||
value={s.subscription.startDate}
|
||||
onChange={(e) => s.setSubscription({ startDate: e.target.value })}
|
||||
className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* live estimate */}
|
||||
<div className="border-t border-border py-4">
|
||||
{configured ? (
|
||||
<div className="rounded-lg border border-primary/30 bg-gradient-to-br from-primary/10 to-transparent p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||
Estimate
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
On <span className="font-medium text-foreground">{PLAN_LABEL[s.subscription.plan]}</span>{" "}
|
||||
you've paid{" "}
|
||||
<span className="font-mono font-semibold text-foreground">{fmtCost(subscriptionPaid())}</span>{" "}
|
||||
over {billingMonths()} month{billingMonths() === 1 ? "" : "s"}. The Conversations
|
||||
dashboard compares that against the API-equivalent cost of everything you've run
|
||||
since {s.subscription.startDate} to show your token savings.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Pick a plan to estimate how much value your subscription returns versus paying per token.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
On <span className="font-medium text-foreground">{PLAN_LABEL[s.subscription.plan]}</span>{" "}
|
||||
you've paid{" "}
|
||||
<span className="font-mono font-semibold text-foreground">{fmtCost(subscriptionPaid())}</span>{" "}
|
||||
over {billingMonths()} month{billingMonths() === 1 ? "" : "s"}. The Conversations
|
||||
dashboard compares that against the API-equivalent cost of everything you've run
|
||||
since {s.subscription.startDate} to show your token savings.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Pick a plan to estimate how much value your subscription returns versus paying per token.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -54,3 +54,89 @@ export function useListOrder<T>(
|
||||
|
||||
return [ordered, commit];
|
||||
}
|
||||
|
||||
/** What {@link useListArrangement} hands straight to `<HoldEditable>`. */
|
||||
export interface ListArrangement<T> {
|
||||
/** The slotted items, in their user order. */
|
||||
items: T[];
|
||||
/** The benched items — rendered as tags in the stash popover. */
|
||||
stash: T[];
|
||||
/** Drop handler: commits a pure reorder. */
|
||||
onReorder: (next: T[]) => void;
|
||||
/** Stash-boundary handler: commits both lists at once. */
|
||||
onStashChange: (next: T[], nextStash: T[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link useListOrder} plus a **stash**: on top of the order, which items are
|
||||
* benched out of the list entirely. Spread the result into `<HoldEditable>` and
|
||||
* the group becomes hold-to-rearrange *and* hold-to-hide:
|
||||
*
|
||||
* ```tsx
|
||||
* const arr = useListArrangement("nav", TABS, tabKey);
|
||||
* <HoldEditable {...arr} getKey={tabKey} stashLabel={(t) => t.label}>…</HoldEditable>
|
||||
* ```
|
||||
*
|
||||
* Both lists persist as ids, in the same server-side settings the order uses
|
||||
* (`listOrders` + `listStashes`), and are reconciled the same way: ids the app
|
||||
* no longer ships are dropped, and an item the saved state never heard of is
|
||||
* **slotted**, never benched — shipping a new entry must show it, not hide it.
|
||||
* An item can only be in one of the two lists; the stash wins ties, since the
|
||||
* order bag also keeps a benched item's position for when it comes back.
|
||||
*
|
||||
* @param minSlotted Floor on how many items may stay slotted. `canStash` in the
|
||||
* returned arrangement is not enforced here — the guard is applied on commit,
|
||||
* so a stash-change that would take the group below the floor is ignored.
|
||||
*/
|
||||
export function useListArrangement<T>(
|
||||
name: string,
|
||||
items: T[],
|
||||
getKey: (item: T) => string,
|
||||
minSlotted = 0,
|
||||
): ListArrangement<T> {
|
||||
const savedStash = useSettings((s) => s.listStashes?.[name]);
|
||||
const setListStash = useSettings((s) => s.setListStash);
|
||||
const setListOrder = useSettings((s) => s.setListOrder);
|
||||
const [ordered] = useListOrder(name, items, getKey);
|
||||
|
||||
const { slotted, stash } = useMemo(() => {
|
||||
const benched = new Set(savedStash ?? []);
|
||||
return {
|
||||
slotted: ordered.filter((it) => !benched.has(getKey(it))),
|
||||
// Kept in the saved stash's own order, so the tag row is stable too.
|
||||
stash: (savedStash ?? [])
|
||||
.map((id) => ordered.find((it) => getKey(it) === id))
|
||||
.filter((it): it is T => it !== undefined),
|
||||
};
|
||||
}, [ordered, savedStash, getKey]);
|
||||
|
||||
/**
|
||||
* The order bag keeps *every* item, benched ones included (appended after the
|
||||
* slotted ones), so a tag dragged back out of the stash lands where it used
|
||||
* to be rather than at the end.
|
||||
*/
|
||||
const commitBoth = useCallback(
|
||||
(next: T[], nextStash: T[]) => {
|
||||
const benched = new Set(nextStash.map(getKey));
|
||||
const tail = ordered.filter((it) => benched.has(getKey(it)));
|
||||
setListOrder(name, [...next, ...tail].map(getKey));
|
||||
setListStash(name, nextStash.map(getKey));
|
||||
},
|
||||
[getKey, ordered, name, setListOrder, setListStash],
|
||||
);
|
||||
|
||||
const onReorder = useCallback(
|
||||
(next: T[]) => commitBoth(next, stash),
|
||||
[commitBoth, stash],
|
||||
);
|
||||
|
||||
const onStashChange = useCallback(
|
||||
(next: T[], nextStash: T[]) => {
|
||||
if (next.length < minSlotted) return;
|
||||
commitBoth(next, nextStash);
|
||||
},
|
||||
[minSlotted, commitBoth],
|
||||
);
|
||||
|
||||
return { items: slotted, stash, onReorder, onStashChange };
|
||||
}
|
||||
|
||||
@@ -144,6 +144,15 @@ export interface SettingsState {
|
||||
* renaming or dropping an entry can never strand a list.
|
||||
*/
|
||||
listOrders: Record<string, string[]>;
|
||||
/**
|
||||
* Which entries of those same lists are **benched** — dragged out of the
|
||||
* group into its stash popover and hidden until dragged back (see
|
||||
* `lib/listOrder.ts`'s `useListArrangement`). Kept beside {@link listOrders}
|
||||
* rather than encoded in it (e.g. as a suffix) so a list can be reordered
|
||||
* and un-stashed independently, and so a client that only knows about order
|
||||
* still reads a complete order.
|
||||
*/
|
||||
listStashes: Record<string, string[]>;
|
||||
/**
|
||||
* The model a spawned session runs on — the `--model` argument carried by the
|
||||
* composer's model select (a CLI alias like `opus`, or a pinned model id like
|
||||
@@ -187,6 +196,8 @@ export interface SettingsState {
|
||||
setAvatar: (patch: Partial<AvatarSettings>) => void;
|
||||
setListOrder: (name: string, order: string[]) => void;
|
||||
resetListOrder: (name: string) => void;
|
||||
setListStash: (name: string, stash: string[]) => void;
|
||||
resetListStash: (name: string) => void;
|
||||
addPromptShortcut: () => void;
|
||||
updatePromptShortcut: (id: string, patch: Partial<PromptShortcut>) => void;
|
||||
removePromptShortcut: (id: string) => void;
|
||||
@@ -227,6 +238,7 @@ export const useSettings = create<SettingsState>()(
|
||||
subscription: { plan: "none", monthlyCost: 0, startDate: "" },
|
||||
promptShortcuts: DEFAULT_PROMPT_SHORTCUTS.map((s) => ({ ...s })),
|
||||
listOrders: {},
|
||||
listStashes: {},
|
||||
spawnModel: "",
|
||||
spawnHarness: "claude",
|
||||
spawnThinking: true,
|
||||
@@ -256,6 +268,14 @@ export const useSettings = create<SettingsState>()(
|
||||
return { listOrders: rest };
|
||||
}),
|
||||
|
||||
setListStash: (name, stash) =>
|
||||
set((s) => ({ listStashes: { ...s.listStashes, [name]: stash } })),
|
||||
resetListStash: (name) =>
|
||||
set((s) => {
|
||||
const { [name]: _dropped, ...rest } = s.listStashes ?? {};
|
||||
return { listStashes: rest };
|
||||
}),
|
||||
|
||||
addPromptShortcut: () =>
|
||||
set((s) => ({
|
||||
promptShortcuts: [
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff, Check } from "lucide-react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { VIS_GROUPS, useVisibility, type VisGroup, type VisItem } from "@/lib/visibility";
|
||||
import { useListOrder } from "@/lib/listOrder";
|
||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
||||
|
||||
/**
|
||||
* Eye button + popover: switches for everything the conversation page renders
|
||||
|
||||
68
frontend/src/technical/ui/HeaderActions.tsx
Normal file
68
frontend/src/technical/ui/HeaderActions.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { useListArrangement } from "@/lib/listOrder";
|
||||
|
||||
/** One button (or link, or badge) in a header's action row. */
|
||||
export interface HeaderAction {
|
||||
/** Stable id — the persisted arrangement is a list of these. */
|
||||
key: string;
|
||||
/** Short human name: the stash tag's label, and the a11y name of the slot. */
|
||||
label: string;
|
||||
/** The control itself. Rendered as-is; it keeps its own click handler. */
|
||||
node: ReactNode;
|
||||
/**
|
||||
* This action can never be benched. Use for the row's escape hatches (a
|
||||
* back link, a close button) — the ones whose absence would strand the user.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A header's icon-button row, made hold-to-rearrange **and** hold-to-hide.
|
||||
*
|
||||
* Hold any button for a moment and the row enters edit mode: the buttons jump,
|
||||
* the held one follows the finger, and a stash popover opens under the header.
|
||||
* Drop a button on the popover to bench it — it disappears from the row until
|
||||
* you drag its tag back in. Order and stash both persist server-side (see
|
||||
* `useListArrangement`), so a phone and a desktop show the same row.
|
||||
*
|
||||
* A row is only *reachable* through its own buttons — an empty row has nothing
|
||||
* left to hold, so the stash could never be reopened. {@link MIN_ACTIONS} slots
|
||||
* therefore always stay, and {@link HeaderAction.pinned} actions never leave at
|
||||
* all.
|
||||
*
|
||||
* ```tsx
|
||||
* <HeaderActions name="header:goals" actions={[
|
||||
* { key: "refresh", label: "Refresh", node: <RefreshButton /> },
|
||||
* ]} />
|
||||
* ```
|
||||
*/
|
||||
export const MIN_ACTIONS = 1;
|
||||
|
||||
const actionKey = (a: HeaderAction) => a.key;
|
||||
|
||||
export function HeaderActions({
|
||||
name,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
/** Stable list name for the persisted arrangement, e.g. `header:goals`. */
|
||||
name: string;
|
||||
actions: HeaderAction[];
|
||||
/** Extra classes for the row (it is a flex row by default). */
|
||||
className?: string;
|
||||
}) {
|
||||
const arrangement = useListArrangement(name, actions, actionKey, MIN_ACTIONS);
|
||||
return (
|
||||
<HoldEditable
|
||||
{...arrangement}
|
||||
getKey={actionKey}
|
||||
canStash={(a) => !a.pinned && arrangement.items.length > MIN_ACTIONS}
|
||||
stashLabel={(a) => a.label}
|
||||
stashPlacement="bottom"
|
||||
className={className ?? "flex shrink-0 items-center gap-0.5"}
|
||||
>
|
||||
{(a) => a.node}
|
||||
</HoldEditable>
|
||||
);
|
||||
}
|
||||
@@ -1,767 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* HoldEditable — iOS-springboard-style "hold to rearrange" for small lists.
|
||||
*
|
||||
* Press and hold any item of the group for {@link HoldEditableProps.holdDelay}
|
||||
* (1.4s by default) and it is *picked up*: it lifts out of the flow and follows
|
||||
* the pointer, and every other item starts jumping in place (0.8s cycle, each
|
||||
* with its own random phase offset, so the group doesn't pulse in lockstep).
|
||||
* Dragging into another item's slot hands that slot over: the displaced items
|
||||
* glide out of the way and the held item's slot — a jumping ghost outline —
|
||||
* follows the pointer. Releasing drops the held item into that slot, ends edit
|
||||
* mode and commits the new order via `onReorder`.
|
||||
*
|
||||
* **The DOM order never changes during a drag.** Slots are measured once at
|
||||
* pickup and the rearrangement is expressed purely as transforms; only the drop
|
||||
* commits a real reorder, once the pointer is gone. That is not an optimisation
|
||||
* — it is what makes the drag work at all. Reordering the list live means React
|
||||
* moves the pressed node (and re-renders whichever item now occupies a
|
||||
* position-dependent slot), and a browser cancels the touch whose target was
|
||||
* detached: the drag died on the first hand-over. Freezing the DOM also means
|
||||
* slot geometry is fixed, so hit-testing needs no re-measuring and no animation
|
||||
* lock, and a fast drag can't outrun the shuffle.
|
||||
*
|
||||
* It is layout-agnostic on purpose: the group container gets whatever flex/grid
|
||||
* classes the caller passes, so the same component reorders a horizontal
|
||||
* navbar, a vertical panel of links, a column of cards — or a wrapping grid.
|
||||
* A grid is detected from the measured slots (several distinct rows *and*
|
||||
* columns) and switches the reflow to 2D slot-hopping: each displaced item
|
||||
* glides to the rect of the slot it now occupies, instead of shifting along a
|
||||
* single axis. Grid cells are assumed roughly equal-sized. It only ever
|
||||
* renders one wrapper `<div>` per item.
|
||||
*
|
||||
* ```tsx
|
||||
* <HoldEditable
|
||||
* items={tabs}
|
||||
* getKey={(t) => t.id}
|
||||
* onReorder={(next) => save(next.map((t) => t.id))}
|
||||
* className="flex items-stretch justify-between"
|
||||
* >
|
||||
* {(tab, { editing }) => <Tab tab={tab} muted={editing} />}
|
||||
* </HoldEditable>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/** How long the reorder glide (items shuffling into their new slots) runs, in ms. */
|
||||
const SHUFFLE_MS = 220;
|
||||
/** How long the picked-up item takes to land in its slot on release, in ms. */
|
||||
const DROP_MS = 220;
|
||||
/**
|
||||
* Finger travel (px) that cancels a pending hold — that's a scroll, not a hold.
|
||||
* Touch and pen only: a mouse can't scroll with the button down (see `onMove`).
|
||||
*/
|
||||
const MOVE_CANCEL_PX = 12;
|
||||
/** How far into a slot the pointer must reach to claim it (fraction of its extent). */
|
||||
const CROSS_FRACTION = 0.45;
|
||||
/** Clicks fired within this window after a drop are swallowed (see below). */
|
||||
const CLICK_SUPPRESS_MS = 500;
|
||||
/**
|
||||
* Sub-trees a press must not turn into a pickup: native text entry (a hold
|
||||
* there means "select text"), plus an explicit `data-hold-editable-ignore`
|
||||
* escape hatch for anything else the caller wants to keep pressable.
|
||||
*/
|
||||
const NO_HOLD_SELECTOR =
|
||||
"input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-hold-editable-ignore]";
|
||||
|
||||
export interface HoldEditableItemState {
|
||||
/** This item is the one picked up and following the pointer. */
|
||||
held: boolean;
|
||||
/** The group is in edit mode (some item is being held). */
|
||||
editing: boolean;
|
||||
/** The pointer is down on this item, waiting out the hold delay. */
|
||||
pressing: boolean;
|
||||
/**
|
||||
* The item's index. While a drag is in flight this is its **live slot** —
|
||||
* where it would land right now — not its position in the DOM.
|
||||
*/
|
||||
index: number;
|
||||
/** Number of items in the group. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HoldEditableProps<T> {
|
||||
/** The items, in their current order. */
|
||||
items: T[];
|
||||
/** Stable identity for an item — also the React key. */
|
||||
getKey: (item: T) => string;
|
||||
/** Called once, on drop, with the reordered items. Not called if nothing moved. */
|
||||
onReorder: (items: T[]) => void;
|
||||
/** Renders one item. Receives its live drag state (see {@link HoldEditableItemState}). */
|
||||
children: (item: T, state: HoldEditableItemState) => ReactNode;
|
||||
/** Classes for the group container — this is where the layout lives. */
|
||||
className?: string;
|
||||
/** Classes for each item's wrapper (rarely needed; the item renders its own box). */
|
||||
itemClassName?: string;
|
||||
/** Hold duration before an item is picked up, in ms. */
|
||||
holdDelay?: number;
|
||||
/** Jump cycle of the non-held items while editing, in ms. */
|
||||
jumpInterval?: number;
|
||||
/** Turns the whole interaction off — items render, nothing is draggable. */
|
||||
disabled?: boolean;
|
||||
/** Fired when an item is picked up. */
|
||||
onEditStart?: () => void;
|
||||
/** Fired when the pointer is released (or the drag is cancelled). */
|
||||
onEditEnd?: () => void;
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything frozen at pickup. `dom` is the DOM order for the whole drag;
|
||||
* `order` is the live arrangement the drop will commit; `home` is each slot's
|
||||
* geometry **relative to the group container**, so a page or panel that scrolls
|
||||
* mid-drag doesn't invalidate it.
|
||||
*/
|
||||
interface Arrangement {
|
||||
dom: string[];
|
||||
order: string[];
|
||||
home: Rect[];
|
||||
horizontal: boolean;
|
||||
/** The slots wrap into a real grid — reflow is 2D slot-hopping, not axis shifts. */
|
||||
grid: boolean;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
key: string;
|
||||
/** Size of the picked-up item, frozen at pickup. */
|
||||
w: number;
|
||||
h: number;
|
||||
/** Where inside the item the pointer grabbed it. */
|
||||
grabX: number;
|
||||
grabY: number;
|
||||
/** Live pointer position, in viewport coordinates. */
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface DropState {
|
||||
key: string;
|
||||
w: number;
|
||||
h: number;
|
||||
/** Viewport position of the slot the item is landing in. */
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const STYLE_ID = "hold-editable-styles";
|
||||
|
||||
/**
|
||||
* The keyframes live in a stylesheet rather than inline styles because a
|
||||
* `@keyframes` rule can't be expressed inline, and pulling in a CSS file would
|
||||
* make the component non-portable. Injected once, on first mount.
|
||||
*/
|
||||
const CSS = `
|
||||
@keyframes hold-editable-jump {
|
||||
0% { transform: translateY(0) rotate(0deg); }
|
||||
8% { transform: translateY(-24%) rotate(-1.2deg); }
|
||||
17% { transform: translateY(0) rotate(0deg); }
|
||||
24% { transform: translateY(-8%) rotate(0.8deg); }
|
||||
31% { transform: translateY(0) rotate(0deg); }
|
||||
100% { transform: translateY(0) rotate(0deg); }
|
||||
}
|
||||
[data-hold-editable-item] {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
[data-hold-editable-dragging] {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hold-editable-jump { animation: none !important; }
|
||||
}
|
||||
`;
|
||||
|
||||
function useHoldEditableStyles(): void {
|
||||
useEffect(() => {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const el = document.createElement("style");
|
||||
el.id = STYLE_ID;
|
||||
el.textContent = CSS;
|
||||
document.head.appendChild(el);
|
||||
}, []);
|
||||
}
|
||||
|
||||
const rectOf = (el: Element): Rect => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
||||
};
|
||||
|
||||
const hits = (r: Rect, x: number, y: number) =>
|
||||
x >= r.left && x <= r.left + r.width && y >= r.top && y <= r.top + r.height;
|
||||
|
||||
/**
|
||||
* How much a picked-up item swells. A fixed ratio reads as a tasteful lift on a
|
||||
* 64px navbar tab and as a jarring 100px growth spurt on a full-width card, so
|
||||
* scale the *absolute* overhang instead and clamp it.
|
||||
*/
|
||||
const liftScale = (w: number, h: number) =>
|
||||
1 + Math.min(0.08, Math.max(0.02, 8 / Math.max(w, h, 1)));
|
||||
|
||||
/**
|
||||
* Which way the group runs, read off the first two slots that sit apart. Rows
|
||||
* and columns need the reflow computed on different axes, and this beats asking
|
||||
* the caller to declare an orientation it already expressed in its CSS.
|
||||
*/
|
||||
function isHorizontal(rects: Rect[]): boolean {
|
||||
for (let i = 1; i < rects.length; i++) {
|
||||
const dx = Math.abs(rects[i].left - rects[0].left);
|
||||
const dy = Math.abs(rects[i].top - rects[0].top);
|
||||
if (dx > 1 || dy > 1) return dx > dy;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the slots form a wrapping grid (several distinct rows AND columns).
|
||||
* A grid can't reflow along one axis; instead items hop between the frozen
|
||||
* slot rects, so the transform model switches to 2D (see `offsets` below).
|
||||
* 4px buckets absorb sub-pixel layout jitter.
|
||||
*/
|
||||
function isGrid(rects: Rect[]): boolean {
|
||||
const xs = new Set<number>();
|
||||
const ys = new Set<number>();
|
||||
for (const r of rects) {
|
||||
xs.add(Math.round(r.left / 4));
|
||||
ys.add(Math.round(r.top / 4));
|
||||
}
|
||||
return xs.size > 1 && ys.size > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where every item sits, along the main axis, for a given order — the layout a
|
||||
* real reflow would produce, derived from the frozen slot geometry. Each item
|
||||
* keeps its own size and the original inter-slot gaps are preserved, so a list
|
||||
* of unequal items (an expanded card among collapsed ones) still lands right.
|
||||
*/
|
||||
function positionsFor(order: string[], a: Arrangement): Map<string, number> {
|
||||
const { dom, home, horizontal } = a;
|
||||
const sizeOf = new Map<string, number>();
|
||||
dom.forEach((k, i) => sizeOf.set(k, horizontal ? home[i].width : home[i].height));
|
||||
|
||||
const out = new Map<string, number>();
|
||||
let p = horizontal ? home[0].left : home[0].top;
|
||||
for (let j = 0; j < order.length; j++) {
|
||||
out.set(order[j], p);
|
||||
const size = sizeOf.get(order[j]) ?? 0;
|
||||
const gap =
|
||||
j + 1 < home.length
|
||||
? horizontal
|
||||
? home[j + 1].left - (home[j].left + home[j].width)
|
||||
: home[j + 1].top - (home[j].top + home[j].height)
|
||||
: 0;
|
||||
p += size + gap;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function HoldEditable<T>({
|
||||
items,
|
||||
getKey,
|
||||
onReorder,
|
||||
children,
|
||||
className,
|
||||
itemClassName,
|
||||
holdDelay = 1400,
|
||||
jumpInterval = 800,
|
||||
disabled = false,
|
||||
onEditStart,
|
||||
onEditEnd,
|
||||
}: HoldEditableProps<T>) {
|
||||
useHoldEditableStyles();
|
||||
|
||||
const [arrangement, setArrangement] = useState<Arrangement | null>(null);
|
||||
const [pressKey, setPressKey] = useState<string | null>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
|
||||
const byKey = useMemo(() => {
|
||||
const m = new Map<string, T>();
|
||||
for (const it of items) m.set(getKey(it), it);
|
||||
return m;
|
||||
}, [items, getKey]);
|
||||
|
||||
// While a drag is in flight we render the frozen DOM order; otherwise the
|
||||
// parent's. The frozen order is dropped if it no longer describes exactly the
|
||||
// items we were handed (the parent may add or remove one mid-drag).
|
||||
const list = useMemo(() => {
|
||||
const dom = arrangement?.dom;
|
||||
if (!dom || dom.length !== items.length) return items;
|
||||
if (dom.some((k) => !byKey.has(k))) return items;
|
||||
return dom.map((k) => byKey.get(k)!);
|
||||
}, [arrangement, items, byKey]);
|
||||
|
||||
const keys = useMemo(() => list.map(getKey), [list, getKey]);
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
/** Wrapper element per key — the measurable "slot". */
|
||||
const slots = useRef(new Map<string, HTMLDivElement>());
|
||||
/** Order at pickup, so Escape / pointercancel can put everything back. */
|
||||
const orderAtPickup = useRef<string[]>([]);
|
||||
/** Timestamp of the last drop — see the click-suppression effect below. */
|
||||
const droppedAt = useRef(0);
|
||||
const holdTimer = useRef<number | null>(null);
|
||||
const dropTimer = useRef<number | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
dragRef.current = drag;
|
||||
const arrangementRef = useRef<Arrangement | null>(null);
|
||||
arrangementRef.current = arrangement;
|
||||
const pressPos = useRef({ x: 0, y: 0 });
|
||||
/** Last pointer position seen during a drag, in viewport coordinates. */
|
||||
const lastPointer = useRef({ x: 0, y: 0 });
|
||||
/** Pointer that started the press — needed to capture it at pickup. */
|
||||
const pointerId = useRef<number | null>(null);
|
||||
|
||||
/** Per-item jump phase, so the group jumps out of sync. Stable per key. */
|
||||
const jumpPhase = useRef(new Map<string, number>());
|
||||
const phaseOf = (key: string) => {
|
||||
let p = jumpPhase.current.get(key);
|
||||
if (p === undefined) {
|
||||
p = -Math.random() * jumpInterval;
|
||||
jumpPhase.current.set(key, p);
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
const clearHoldTimer = () => {
|
||||
if (holdTimer.current !== null) {
|
||||
window.clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- pickup */
|
||||
|
||||
const beginDrag = useCallback(
|
||||
(key: string, x: number, y: number) => {
|
||||
const el = slots.current.get(key);
|
||||
const container = containerRef.current;
|
||||
if (!el || !container) return;
|
||||
|
||||
// Measure every slot once, relative to the container. These rects are the
|
||||
// drop hitboxes and the layout model for the rest of the drag; nothing
|
||||
// re-measures, because nothing in the DOM moves.
|
||||
const c = rectOf(container);
|
||||
const dom = [...keysRef.current];
|
||||
const home: Rect[] = [];
|
||||
for (const k of dom) {
|
||||
const slot = slots.current.get(k);
|
||||
if (!slot) return;
|
||||
const r = rectOf(slot);
|
||||
home.push({ left: r.left - c.left, top: r.top - c.top, width: r.width, height: r.height });
|
||||
}
|
||||
|
||||
const r = rectOf(el);
|
||||
// Capture the pointer on the slot wrapper: with the DOM frozen it stays
|
||||
// put for the whole drag, so every later event is guaranteed to reach us
|
||||
// even if the item's own content re-renders.
|
||||
if (pointerId.current !== null) {
|
||||
try {
|
||||
el.setPointerCapture(pointerId.current);
|
||||
} catch {
|
||||
/* pointer already gone — the drag will end on the next event */
|
||||
}
|
||||
}
|
||||
lastPointer.current = { x, y };
|
||||
orderAtPickup.current = dom;
|
||||
setArrangement({
|
||||
dom,
|
||||
order: [...dom],
|
||||
home,
|
||||
horizontal: isHorizontal(home),
|
||||
grid: isGrid(home),
|
||||
});
|
||||
setPressKey(null);
|
||||
setDrag({ key, w: r.width, h: r.height, grabX: x - r.left, grabY: y - r.top, x, y });
|
||||
navigator.vibrate?.(12);
|
||||
onEditStart?.();
|
||||
},
|
||||
[onEditStart],
|
||||
);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent, key: string) => {
|
||||
if (disabled || dragRef.current || drop) return;
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
// Text fields (and anything explicitly opted out) keep their own press
|
||||
// semantics: holding inside a textarea must select text, not lift the card.
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest?.(NO_HOLD_SELECTOR)) return;
|
||||
pressPos.current = { x: e.clientX, y: e.clientY };
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
pointerId.current = e.pointerId;
|
||||
setPressKey(key);
|
||||
clearHoldTimer();
|
||||
holdTimer.current = window.setTimeout(() => {
|
||||
holdTimer.current = null;
|
||||
// Pick up wherever the pointer is *now*, not where it went down: over
|
||||
// 1.4s a mouse drifts, and starting the drag at a stale point would make
|
||||
// the item jump out from under the cursor.
|
||||
beginDrag(key, lastPointer.current.x, lastPointer.current.y);
|
||||
}, holdDelay);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------ reordering */
|
||||
|
||||
/**
|
||||
* Hit-test the pointer against the frozen slots and re-file the held item if
|
||||
* it has moved into someone else's. The held item is *moved* to that index
|
||||
* (the items in between shift by one) rather than swapped with it: for the
|
||||
* neighbour-by-neighbour hops a real drag produces the two are the same, but
|
||||
* only "move" converges on the slot the pointer is over when a fast drag
|
||||
* crosses several at once.
|
||||
*/
|
||||
const considerMove = useCallback(() => {
|
||||
const d = dragRef.current;
|
||||
const a = arrangementRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!d || !a || !container) return;
|
||||
|
||||
// Slot geometry is frozen, but the group itself can scroll or reflow under
|
||||
// the pointer — one container rect per move is enough to stay exact.
|
||||
const c = rectOf(container);
|
||||
const x = lastPointer.current.x - c.left;
|
||||
const y = lastPointer.current.y - c.top;
|
||||
|
||||
const held = a.order.indexOf(d.key);
|
||||
const over = a.home.findIndex((r) => hits(r, x, y));
|
||||
if (over < 0 || held < 0 || over === held) return;
|
||||
|
||||
// Hysteresis: the pointer has to travel a little way into the target before
|
||||
// it takes the slot over, so items of unequal size can't ping-pong on the
|
||||
// boundary. The threshold sits just *short* of the middle on purpose —
|
||||
// aiming at the centre of the item you want is the most natural thing to
|
||||
// do, and an exact midpoint rule turns that into a coin flip that leaves
|
||||
// the item one slot short. (A grid needs none of this: its hitboxes are
|
||||
// the frozen cell rects themselves, so a claim can't oscillate.)
|
||||
if (!a.grid) {
|
||||
const r = a.home[over];
|
||||
const pos = a.horizontal ? x : y;
|
||||
const near = a.horizontal ? r.left : r.top;
|
||||
const span = a.horizontal ? r.width : r.height;
|
||||
const threshold = near + span * (over > held ? CROSS_FRACTION : 1 - CROSS_FRACTION);
|
||||
if (over > held ? pos < threshold : pos > threshold) return;
|
||||
}
|
||||
|
||||
const next = [...a.order];
|
||||
next.splice(over, 0, next.splice(held, 1)[0]);
|
||||
setArrangement({ ...a, order: next });
|
||||
}, []);
|
||||
|
||||
/* --------------------------------------------------------- drag lifetime */
|
||||
|
||||
const endDrag = useCallback(
|
||||
(cancelled: boolean) => {
|
||||
clearHoldTimer();
|
||||
setPressKey(null);
|
||||
const d = dragRef.current;
|
||||
const a = arrangementRef.current;
|
||||
if (!d || !a) return;
|
||||
|
||||
const finalOrder = cancelled ? orderAtPickup.current : a.order;
|
||||
const settled: Arrangement = { ...a, order: finalOrder };
|
||||
setArrangement(settled);
|
||||
|
||||
// Land the ghost on the slot the item is taking, in viewport coordinates.
|
||||
const c = containerRef.current ? rectOf(containerRef.current) : { left: 0, top: 0 };
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (a.grid) {
|
||||
const slot = a.home[finalOrder.indexOf(d.key)];
|
||||
x = c.left + slot.left;
|
||||
y = c.top + slot.top;
|
||||
} else {
|
||||
const pos = positionsFor(finalOrder, settled).get(d.key) ?? 0;
|
||||
const homeRect = a.home[a.dom.indexOf(d.key)];
|
||||
x = a.horizontal ? c.left + pos : c.left + homeRect.left;
|
||||
y = a.horizontal ? c.top + homeRect.top : c.top + pos;
|
||||
}
|
||||
|
||||
setDrag(null);
|
||||
setDrop({ key: d.key, w: d.w, h: d.h, x, y });
|
||||
droppedAt.current = Date.now();
|
||||
|
||||
if (dropTimer.current !== null) window.clearTimeout(dropTimer.current);
|
||||
dropTimer.current = window.setTimeout(() => {
|
||||
dropTimer.current = null;
|
||||
setDrop(null);
|
||||
// Only now does the DOM reorder — into exactly the arrangement the
|
||||
// transforms were already showing, so the swap is invisible.
|
||||
setArrangement(null);
|
||||
}, DROP_MS);
|
||||
|
||||
const changed =
|
||||
!cancelled && finalOrder.some((k, i) => k !== orderAtPickup.current[i]);
|
||||
if (changed) {
|
||||
const next = finalOrder.map((k) => byKey.get(k)).filter(Boolean) as T[];
|
||||
if (next.length === finalOrder.length) onReorder(next);
|
||||
}
|
||||
onEditEnd?.();
|
||||
},
|
||||
[byKey, onReorder, onEditEnd],
|
||||
);
|
||||
|
||||
// Window-level pointer handling: a drag must survive the pointer leaving the
|
||||
// item (and the group) entirely.
|
||||
useEffect(() => {
|
||||
if (!pressKey && !drag) return;
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) {
|
||||
// Still waiting out the hold. Travel only cancels it for a *finger*,
|
||||
// where it means "I'm scrolling, not holding". A mouse can't scroll
|
||||
// with the button down, and a hand resting on one drifts well past any
|
||||
// sane threshold over 1.4s — cancelling on that made hold-to-drag
|
||||
// essentially impossible on desktop. So the mouse keeps holding, and
|
||||
// the pickup simply happens wherever the cursor ended up.
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
if (e.pointerType === "mouse") return;
|
||||
const dx = e.clientX - pressPos.current.x;
|
||||
const dy = e.clientY - pressPos.current.y;
|
||||
if (Math.hypot(dx, dy) > MOVE_CANCEL_PX) {
|
||||
clearHoldTimer();
|
||||
setPressKey(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
setDrag({ ...d, x: e.clientX, y: e.clientY });
|
||||
considerMove();
|
||||
};
|
||||
const onUp = () => endDrag(false);
|
||||
const onCancel = () => (dragRef.current ? endDrag(true) : setPressKey(null));
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && dragRef.current) endDrag(true);
|
||||
};
|
||||
// Once an item is picked up the finger owns it: stop the page scrolling
|
||||
// under it. Must be non-passive to be allowed to preventDefault.
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (dragRef.current) e.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onCancel);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onCancel);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
};
|
||||
}, [pressKey, drag, endDrag, considerMove]);
|
||||
|
||||
// A hold-then-release still fires a click on whatever was under the pointer.
|
||||
// These items are usually links or buttons, so swallow the click that closes
|
||||
// a drag — reordering the navbar must not also navigate.
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (Date.now() - droppedAt.current > CLICK_SUPPRESS_MS) return;
|
||||
droppedAt.current = 0;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
window.addEventListener("click", onClick, true);
|
||||
return () => window.removeEventListener("click", onClick, true);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearHoldTimer();
|
||||
if (dropTimer.current !== null) window.clearTimeout(dropTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------- rendering */
|
||||
|
||||
const editing = drag !== null;
|
||||
const ghostKey = drag?.key ?? drop?.key ?? null;
|
||||
const ghostItem = ghostKey ? byKey.get(ghostKey) : undefined;
|
||||
|
||||
// Where each item currently sits, versus where its DOM node actually is.
|
||||
// Rows and columns shift along their axis; a grid hops between slot rects.
|
||||
const offsets = useMemo(() => {
|
||||
if (!arrangement || arrangement.dom.length !== arrangement.order.length) return null;
|
||||
const out = new Map<string, { x: number; y: number }>();
|
||||
if (arrangement.grid) {
|
||||
arrangement.dom.forEach((k, i) => {
|
||||
const j = arrangement.order.indexOf(k);
|
||||
const target = arrangement.home[j] ?? arrangement.home[i];
|
||||
out.set(k, {
|
||||
x: target.left - arrangement.home[i].left,
|
||||
y: target.top - arrangement.home[i].top,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const pos = positionsFor(arrangement.order, arrangement);
|
||||
arrangement.dom.forEach((k, i) => {
|
||||
const home = arrangement.horizontal ? arrangement.home[i].left : arrangement.home[i].top;
|
||||
const shift = (pos.get(k) ?? home) - home;
|
||||
out.set(k, arrangement.horizontal ? { x: shift, y: 0 } : { x: 0, y: shift });
|
||||
});
|
||||
return out;
|
||||
}, [arrangement]);
|
||||
|
||||
const slotIndex = (key: string) =>
|
||||
arrangement ? arrangement.order.indexOf(key) : keys.indexOf(key);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
data-hold-editable-dragging={editing || undefined}
|
||||
>
|
||||
{list.map((item, domIndex) => {
|
||||
const key = keys[domIndex];
|
||||
const held = key === ghostKey;
|
||||
const pressing = key === pressKey;
|
||||
const shift = offsets?.get(key);
|
||||
const index = slotIndex(key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-hold-editable-item=""
|
||||
ref={(el) => {
|
||||
if (el) slots.current.set(key, el);
|
||||
else slots.current.delete(key);
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => {
|
||||
// A long press pops the OS context menu / selection callout on
|
||||
// touch, which would fight the pickup.
|
||||
if (pressing || editing) e.preventDefault();
|
||||
}}
|
||||
onPointerDown={(e) => onPointerDown(e, key)}
|
||||
className={itemClassName}
|
||||
style={
|
||||
arrangement && shift
|
||||
? {
|
||||
position: "relative",
|
||||
transform: `translate(${shift.x}px, ${shift.y}px)`,
|
||||
transition: `transform ${SHUFFLE_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* The item's own subtree is rendered in every state and is never
|
||||
swapped out — a held item is only made invisible, and it keeps
|
||||
rendering with exactly the props it had before the pickup.
|
||||
That last part matters: a caller whose markup depends on the
|
||||
index (the navbar's centre tab is a different element) would
|
||||
otherwise have this very node — the one the pointer went down
|
||||
on — unmounted the moment the held item changed slot, and the
|
||||
browser cancels a touch whose target left the document. The
|
||||
copy is invisible, so freezing it costs nothing. */}
|
||||
<div
|
||||
className={cn("h-full w-full", editing && !held && "hold-editable-jump")}
|
||||
style={
|
||||
held
|
||||
? { visibility: "hidden" }
|
||||
: editing
|
||||
? {
|
||||
animation: `hold-editable-jump ${jumpInterval}ms ease-in-out infinite`,
|
||||
animationDelay: `${phaseOf(key)}ms`,
|
||||
}
|
||||
: pressing
|
||||
? {
|
||||
transform: "scale(0.94)",
|
||||
transition: `transform ${holdDelay}ms cubic-bezier(0.4, 0, 0.6, 1)`,
|
||||
}
|
||||
: { transition: "transform 140ms ease-out" }
|
||||
}
|
||||
>
|
||||
{children(
|
||||
item,
|
||||
held
|
||||
? { held: false, editing: false, pressing: false, index: domIndex, count: list.length }
|
||||
: { held: false, editing, pressing, index, count: list.length },
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Where the held item will land: a jumping outline over its slot. */}
|
||||
{held && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-xl border border-dashed border-primary/40 bg-primary/5",
|
||||
editing && "hold-editable-jump",
|
||||
)}
|
||||
style={
|
||||
editing
|
||||
? {
|
||||
animation: `hold-editable-jump ${jumpInterval}ms ease-in-out infinite`,
|
||||
animationDelay: `${phaseOf(key)}ms`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* The picked-up item, lifted out of the flow into a body-level layer so
|
||||
no `overflow: hidden` ancestor can clip it while it roams. */}
|
||||
{ghostItem !== undefined &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: drag?.w ?? drop?.w,
|
||||
height: drag?.h ?? drop?.h,
|
||||
zIndex: 100,
|
||||
pointerEvents: "none",
|
||||
willChange: "transform",
|
||||
transform: drag
|
||||
? `translate3d(${drag.x - drag.grabX}px, ${drag.y - drag.grabY}px, 0) scale(${liftScale(drag.w, drag.h)})`
|
||||
: `translate3d(${drop!.x}px, ${drop!.y}px, 0) scale(1)`,
|
||||
transition: drag
|
||||
? "none"
|
||||
: `transform ${DROP_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1), filter ${DROP_MS}ms ease-out`,
|
||||
// The lift shadow fades as the item lands, so it doesn't blink
|
||||
// out when the ghost unmounts and the real slot takes over.
|
||||
filter: drag
|
||||
? "drop-shadow(0 10px 18px rgb(0 0 0 / 0.28))"
|
||||
: "drop-shadow(0 0 0 rgb(0 0 0 / 0))",
|
||||
}}
|
||||
>
|
||||
{children(ghostItem, {
|
||||
held: true,
|
||||
editing,
|
||||
pressing: false,
|
||||
index: ghostKey ? slotIndex(ghostKey) : -1,
|
||||
count: list.length,
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
62
frontend/src/technical/ui/SectionStack.tsx
Normal file
62
frontend/src/technical/ui/SectionStack.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { HoldEditable } from "@gabvdl/ui";
|
||||
import { useListArrangement } from "@/lib/listOrder";
|
||||
|
||||
/** One block of a page: a card, a table, a README — anything stack-shaped. */
|
||||
export interface PageSection {
|
||||
/** Stable id — the persisted arrangement is a list of these. */
|
||||
key: string;
|
||||
/** Short human name: the stash tag's label. */
|
||||
label: string;
|
||||
/** The section itself, or `null`/`false` when this page has nothing to show. */
|
||||
node: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's stack of sections, made hold-to-rearrange **and** hold-to-hide.
|
||||
*
|
||||
* Same gesture as {@link HeaderActions}, one altitude up: hold a section for a
|
||||
* moment, the stack enters edit mode, and a popover of tags opens beside it.
|
||||
* Drop a section on the popover to bench it — the Service page's Traefik
|
||||
* routers are fascinating exactly once — and drag its tag back to bring it
|
||||
* home. Order and stash persist server-side per {@link name}, so the same page
|
||||
* looks the same on a phone.
|
||||
*
|
||||
* At least {@link MIN_SECTIONS} section always stays: the stack is only
|
||||
* reachable through its own sections, so an empty one could never be reopened.
|
||||
*
|
||||
* Sections a page can't render (no commits, no GOAL.md) are dropped here rather
|
||||
* than by the caller, so the list reads as the page's full repertoire and a
|
||||
* section that comes back later returns to its saved slot.
|
||||
*/
|
||||
export const MIN_SECTIONS = 1;
|
||||
|
||||
const sectionKey = (s: PageSection) => s.key;
|
||||
|
||||
export function SectionStack({
|
||||
name,
|
||||
sections,
|
||||
className,
|
||||
}: {
|
||||
/** Stable list name for the persisted arrangement, e.g. `sections:service`. */
|
||||
name: string;
|
||||
sections: PageSection[];
|
||||
className?: string;
|
||||
}) {
|
||||
const present = sections.filter((s) => s.node);
|
||||
const arrangement = useListArrangement(name, present, sectionKey, MIN_SECTIONS);
|
||||
return (
|
||||
<HoldEditable
|
||||
{...arrangement}
|
||||
getKey={sectionKey}
|
||||
canStash={() => arrangement.items.length > MIN_SECTIONS}
|
||||
stashLabel={(s) => s.label}
|
||||
// The popover hangs above the stack and flips/pins itself into view when
|
||||
// the stack is taller than the screen (@gabvdl/ui ≥ 0.30.3).
|
||||
stashPlacement="top"
|
||||
className={className ?? "space-y-4"}
|
||||
>
|
||||
{(s) => s.node}
|
||||
</HoldEditable>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user