feat(ui): hold header rows and page sections to rearrange or hide them #1

Open
gabrielvidal wants to merge 5 commits from navbar-hold into main
25 changed files with 1154 additions and 1447 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 */}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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({

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -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: [

View File

@@ -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

View 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>
);
}

View File

@@ -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,
)}
</>
);
}

View 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>
);
}