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",
|
"name": "ai-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gabvdl/ui": "^0.30.1",
|
"@gabvdl/ui": "^0.30.3",
|
||||||
"@tanstack/query-async-storage-persister": "^5.101.2",
|
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||||
@@ -1473,9 +1473,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@gabvdl/ui": {
|
"node_modules/@gabvdl/ui": {
|
||||||
"version": "0.30.1",
|
"version": "0.30.3",
|
||||||
"resolved": "http://localhost:4873/@gabvdl/ui/-/ui-0.30.1.tgz",
|
"resolved": "http://localhost:4873/@gabvdl/ui/-/ui-0.30.3.tgz",
|
||||||
"integrity": "sha512-rhVZiO/X2YZ21/si5Sk+iPSV5SI5Ydfui2Svyp1WKcwh9+P4YIxB0NvDOl5YUNw3uqE9MIcAiRlQbBPhbLixOA==",
|
"integrity": "sha512-qqKtXGk1WkeoglTXRMDXqrEgmj9HKgZpxF64WG+7YnYymtIA95HGPKbOxv/chcU3ACy+hbyLDx1nZixA6GqyfA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.14.5",
|
"@tanstack/react-virtual": "^3.14.5",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gabvdl/ui": "^0.30.1",
|
"@gabvdl/ui": "^0.30.3",
|
||||||
"@tanstack/query-async-storage-persister": "^5.101.2",
|
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
FolderGit2,
|
FolderGit2,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { sectionOf, type Section } from "@/nav";
|
import { sectionOf, type Section } from "@/nav";
|
||||||
import { useSpawnDock } from "@/lib/spawnDock";
|
import { useSpawnDock } from "@/lib/spawnDock";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
section: Section;
|
section: Section;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
Images,
|
Images,
|
||||||
Play,
|
Play,
|
||||||
} from "lucide-react";
|
} 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 { AnimationSpeedContext } from "@/lib/animationSpeed";
|
||||||
import { cn, fmtTokens, fmtCost, fmtDateTime, fmtLatency, relTime } from "@/lib/utils";
|
import { cn, fmtTokens, fmtCost, fmtDateTime, fmtLatency, relTime } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -51,9 +51,9 @@ import {
|
|||||||
import { useStore } from "@/store";
|
import { useStore } from "@/store";
|
||||||
import { useVis, type VisKey } from "@/lib/visibility";
|
import { useVis, type VisKey } from "@/lib/visibility";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
import { EffortTags, ModelTags, effortsOf, modelFamily } from "@/lib/models";
|
import { EffortTags, ModelTags, effortsOf, modelFamily } from "@/lib/models";
|
||||||
import { VisibilityPopover } from "@/technical/VisibilityPopover";
|
import { VisibilityPopover } from "@/technical/VisibilityPopover";
|
||||||
|
import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions";
|
||||||
import { interruptConversation } from "@/api";
|
import { interruptConversation } from "@/api";
|
||||||
import { useConversation, useConversationCommits, useUncommittedDiffStats } from "@/lib/queries";
|
import { useConversation, useConversationCommits, useUncommittedDiffStats } from "@/lib/queries";
|
||||||
import { repoTokenHref, conversationDiffHref, uncommittedDiffHref } from "@/lib/diffRoute";
|
import { repoTokenHref, conversationDiffHref, uncommittedDiffHref } from "@/lib/diffRoute";
|
||||||
@@ -425,65 +425,95 @@ export function Conversation() {
|
|||||||
{convo && (
|
{convo && (
|
||||||
<div className="flex min-w-0 items-center gap-1 md:ml-auto">
|
<div className="flex min-w-0 items-center gap-1 md:ml-auto">
|
||||||
<div className="min-w-0 flex-1 md:hidden">{metaLine}</div>
|
<div className="min-w-0 flex-1 md:hidden">{metaLine}</div>
|
||||||
{(serverRunning || serverPaused) && (
|
{/* Hold any of these to rearrange the row, or drop one on the stash
|
||||||
<button
|
to bench it — a reader who never touches the details panel can
|
||||||
onClick={() => void interrupt()}
|
take it out of the header. Interrupt is pinned: a running
|
||||||
disabled={interrupting}
|
session must always be stoppable from here. */}
|
||||||
title={
|
<HeaderActions
|
||||||
interruptErr
|
name="header:conversation"
|
||||||
? `Interrupt failed: ${interruptErr}`
|
actions={[
|
||||||
: "Interrupt this running session (sends Esc / SIGINT)"
|
...((serverRunning || serverPaused)
|
||||||
}
|
? [
|
||||||
className={cn(
|
{
|
||||||
"flex h-8 items-center gap-1.5 rounded-md border px-2 text-[12px] font-medium transition-colors",
|
key: "interrupt",
|
||||||
interruptErr
|
label: "Interrupt",
|
||||||
? "border-rose-500/40 bg-rose-500/10 text-rose-500"
|
pinned: true,
|
||||||
: "border-rose-500/30 text-rose-500 hover:bg-rose-500/10",
|
node: (
|
||||||
interrupting && "opacity-60",
|
<button
|
||||||
)}
|
onClick={() => void interrupt()}
|
||||||
>
|
disabled={interrupting}
|
||||||
{interrupting ? (
|
title={
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
interruptErr
|
||||||
) : (
|
? `Interrupt failed: ${interruptErr}`
|
||||||
<CircleStop className="h-4 w-4" />
|
: "Interrupt this running session (sends Esc / SIGINT)"
|
||||||
)}
|
}
|
||||||
<span className="hidden sm:inline">
|
className={cn(
|
||||||
{interrupting ? "Stopping…" : "Interrupt"}
|
"flex h-8 items-center gap-1.5 rounded-md border px-2 text-[12px] font-medium transition-colors",
|
||||||
</span>
|
interruptErr
|
||||||
</button>
|
? "border-rose-500/40 bg-rose-500/10 text-rose-500"
|
||||||
)}
|
: "border-rose-500/30 text-rose-500 hover:bg-rose-500/10",
|
||||||
<button
|
interrupting && "opacity-60",
|
||||||
onClick={() => setAll(expandState !== "expanded")}
|
)}
|
||||||
title={
|
>
|
||||||
expandState === "expanded"
|
{interrupting ? (
|
||||||
? "All tools expanded — collapse them"
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
: expandState === "collapsed"
|
) : (
|
||||||
? "All tools collapsed — expand them"
|
<CircleStop className="h-4 w-4" />
|
||||||
: "Expand all tools"
|
)}
|
||||||
}
|
<span className="hidden sm:inline">
|
||||||
className={cn(
|
{interrupting ? "Stopping…" : "Interrupt"}
|
||||||
"flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
|
</span>
|
||||||
expandState === "mixed" ? "text-muted-foreground" : "text-primary",
|
</button>
|
||||||
)}
|
),
|
||||||
>
|
} satisfies HeaderAction,
|
||||||
{expandState === "expanded" ? (
|
]
|
||||||
<ChevronsDownUp className="h-4 w-4" />
|
: []),
|
||||||
) : (
|
{
|
||||||
<ChevronsUpDown className="h-4 w-4" />
|
key: "expand",
|
||||||
)}
|
label: "Expand tools",
|
||||||
</button>
|
node: (
|
||||||
{/* No composer toggle: it's a plain bar pinned under the thread. */}
|
<button
|
||||||
<VisibilityPopover />
|
onClick={() => setAll(expandState !== "expanded")}
|
||||||
<button
|
title={
|
||||||
onClick={() => setInfoOpen((o) => !o)}
|
expandState === "expanded"
|
||||||
title="Costs & details"
|
? "All tools expanded — collapse them"
|
||||||
className={cn(
|
: expandState === "collapsed"
|
||||||
"flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
|
? "All tools collapsed — expand them"
|
||||||
infoOpen ? "text-primary" : "text-muted-foreground",
|
: "Expand all tools"
|
||||||
)}
|
}
|
||||||
>
|
className={cn(
|
||||||
<PanelRight className="h-4 w-4" />
|
"flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
|
||||||
</button>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useStore } from "@/store";
|
|||||||
import { useHydrated } from "@/lib/useHydrated";
|
import { useHydrated } from "@/lib/useHydrated";
|
||||||
import { buildFeed } from "@/lib/notifications";
|
import { buildFeed } from "@/lib/notifications";
|
||||||
import { PageHeader } from "@/technical/PageHeader";
|
import { PageHeader } from "@/technical/PageHeader";
|
||||||
|
import { HeaderActions } from "@/technical/ui/HeaderActions";
|
||||||
import { NotificationCard } from "@/business/notifications/components/NotificationCard";
|
import { NotificationCard } from "@/business/notifications/components/NotificationCard";
|
||||||
import { RunningConversations } from "@/business/conversations/components/RunningConversations";
|
import { RunningConversations } from "@/business/conversations/components/RunningConversations";
|
||||||
import { SpawnDrawer } from "@/business/composer/components/SpawnDrawer";
|
import { SpawnDrawer } from "@/business/composer/components/SpawnDrawer";
|
||||||
@@ -71,34 +72,65 @@ export function ConversationHome() {
|
|||||||
: "notify-done pushes"
|
: "notify-done pushes"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link
|
{/* Hold to rearrange, or drop on the stash to bench a shortcut you
|
||||||
to="/conversations"
|
never take (see HeaderActions). */}
|
||||||
title="All conversations"
|
<HeaderActions
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
name="header:conversation-home"
|
||||||
>
|
actions={[
|
||||||
<MessagesSquare className="h-4 w-4" />
|
{
|
||||||
</Link>
|
key: "conversations",
|
||||||
<Link
|
label: "All conversations",
|
||||||
to="/notifications"
|
node: (
|
||||||
title="All notifications"
|
<Link
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
to="/conversations"
|
||||||
>
|
title="All conversations"
|
||||||
<Inbox className="h-4 w-4" />
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
</Link>
|
>
|
||||||
<Link
|
<MessagesSquare className="h-4 w-4" />
|
||||||
to="/dashboards/conversations"
|
</Link>
|
||||||
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" />
|
key: "notifications",
|
||||||
</Link>
|
label: "All notifications",
|
||||||
<button
|
node: (
|
||||||
onClick={() => refetch()}
|
<Link
|
||||||
title="Refresh"
|
to="/notifications"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
title="All notifications"
|
||||||
>
|
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>
|
<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>
|
</PageHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
<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 { useLive } from "@/lib/sseBus";
|
||||||
import { convTime } from "@/lib/analytics";
|
import { convTime } from "@/lib/analytics";
|
||||||
import { PageHeader } from "@/technical/PageHeader";
|
import { PageHeader } from "@/technical/PageHeader";
|
||||||
|
import { HeaderActions } from "@/technical/ui/HeaderActions";
|
||||||
import {
|
import {
|
||||||
StateBadge,
|
StateBadge,
|
||||||
HarnessBadge,
|
HarnessBadge,
|
||||||
@@ -206,27 +207,52 @@ export function Conversations() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link
|
{/* Back is pinned — it is this page's way out. */}
|
||||||
to="/conversation"
|
<HeaderActions
|
||||||
title="Back"
|
name="header:conversations"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
actions={[
|
||||||
>
|
{
|
||||||
<ArrowLeft className="h-4 w-4" />
|
key: "back",
|
||||||
</Link>
|
label: "Back",
|
||||||
<Link
|
pinned: true,
|
||||||
to="/dashboards/conversations"
|
node: (
|
||||||
title="Analytics dashboard"
|
<Link
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
to="/conversation"
|
||||||
>
|
title="Back"
|
||||||
<BarChart3 className="h-4 w-4" />
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
</Link>
|
>
|
||||||
<button
|
<ArrowLeft className="h-4 w-4" />
|
||||||
onClick={() => refetch()}
|
</Link>
|
||||||
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")} />
|
key: "analytics",
|
||||||
</button>
|
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>
|
</PageHeader>
|
||||||
|
|
||||||
{/* search + controls */}
|
{/* search + controls */}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { Gauge, MessagesSquare, Leaf, Waypoints } from "lucide-react";
|
import { Gauge, MessagesSquare, Leaf, Waypoints } from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
|
|
||||||
interface Page {
|
interface Page {
|
||||||
to: string;
|
to: string;
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn, fmtTokens, fmtCost } from "@/lib/utils";
|
import { cn, fmtTokens, fmtCost } from "@/lib/utils";
|
||||||
import { useStore } from "@/store";
|
import { useStore } from "@/store";
|
||||||
import { useConversationsFull } from "@/lib/queries";
|
import { useConversationsFull } from "@/lib/queries";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
import { useLive } from "@/lib/sseBus";
|
import { useLive } from "@/lib/sseBus";
|
||||||
import {
|
import {
|
||||||
RANGES,
|
RANGES,
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Coins,
|
Coins,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn, fmtTokens, fmtNum, fmtCost, relTime } from "@/lib/utils";
|
import { cn, fmtTokens, fmtNum, fmtCost, relTime } from "@/lib/utils";
|
||||||
import { useStore } from "@/store";
|
import { useStore } from "@/store";
|
||||||
import { useBundle, useDashboard } from "@/lib/queries";
|
import { useBundle, useDashboard } from "@/lib/queries";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
import { Button } from "@/technical/ui/button";
|
import { Button } from "@/technical/ui/button";
|
||||||
import type { SkillStat } from "@/types";
|
import type { SkillStat } from "@/types";
|
||||||
import { Roll } from "@/technical/Roll";
|
import { Roll } from "@/technical/Roll";
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { Search, X, Brain, DollarSign, Bell, ClipboardList, Target } from "lucide-react";
|
import { Search, X, Brain, DollarSign, Bell, ClipboardList, Target } from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn, fmtTokens, fmtCost } from "@/lib/utils";
|
import { cn, fmtTokens, fmtCost } from "@/lib/utils";
|
||||||
import { useStore } from "@/store";
|
import { useStore } from "@/store";
|
||||||
import { useBundle } from "@/lib/queries";
|
import { useBundle } from "@/lib/queries";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { buildTree, filterFiles } from "@/tree";
|
import { buildTree, filterFiles } from "@/tree";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
import { FileTree } from "./FileTree";
|
import { FileTree } from "./FileTree";
|
||||||
|
|
||||||
interface Shortcut {
|
interface Shortcut {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FolderTree, FileText, Brain, Wrench, Bell, ClipboardList, Target } from "lucide-react";
|
import { FolderTree, FileText, Brain, Wrench, Bell, ClipboardList, Target } from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { useBundle } from "@/lib/queries";
|
import { useBundle } from "@/lib/queries";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
import { PageHeader } from "@/technical/PageHeader";
|
import { PageHeader } from "@/technical/PageHeader";
|
||||||
|
|
||||||
interface JumpDef {
|
interface JumpDef {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button, EmptyState, FuzzyList, type FuzzyRenderContext } from "@gabvdl/
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useGoals } from "@/lib/queries";
|
import { useGoals } from "@/lib/queries";
|
||||||
import { PageHeader } from "@/technical/PageHeader";
|
import { PageHeader } from "@/technical/PageHeader";
|
||||||
|
import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions";
|
||||||
import { GoalCard } from "../components/GoalCard";
|
import { GoalCard } from "../components/GoalCard";
|
||||||
import type { GoalBoardEntry } from "@/types";
|
import type { GoalBoardEntry } from "@/types";
|
||||||
import { Roll } from "@/technical/Roll";
|
import { Roll } from "@/technical/Roll";
|
||||||
@@ -71,21 +72,43 @@ export function Goals() {
|
|||||||
title="Goals"
|
title="Goals"
|
||||||
subtitle={subtitle()}
|
subtitle={subtitle()}
|
||||||
>
|
>
|
||||||
{agents > 0 && (
|
{/* The running-agents badge rides in the same row, so it can be
|
||||||
<span
|
benched by anyone who does not want a live counter up there. */}
|
||||||
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"
|
<HeaderActions
|
||||||
title={`${agents} agent ${agents === 1 ? "session" : "sessions"} running across all goals`}
|
name="header:goals"
|
||||||
>
|
actions={[
|
||||||
<Bot className="h-3 w-3 animate-pulse" />
|
...(agents > 0
|
||||||
<Roll value={agents} />
|
? [
|
||||||
</span>
|
{
|
||||||
)}
|
key: "agents",
|
||||||
<Button
|
label: "Agents running",
|
||||||
variant="ghost"
|
node: (
|
||||||
size="icon-sm"
|
<span
|
||||||
onClick={() => goalsQ.refetch()}
|
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"
|
||||||
tooltip="Refresh"
|
title={`${agents} agent ${agents === 1 ? "session" : "sessions"} running across all goals`}
|
||||||
icon={<RefreshCw className={cn("h-4 w-4", goalsQ.isFetching && "animate-spin")} />}
|
>
|
||||||
|
<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>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { useIsFetching } from "@tanstack/react-query";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { queryClient } from "@/lib/queryClient";
|
import { queryClient } from "@/lib/queryClient";
|
||||||
import { useStore } from "@/store";
|
import { useStore } from "@/store";
|
||||||
|
import { useDesktop } from "@/lib/floating";
|
||||||
import { sectionOf, type Section } from "@/nav";
|
import { sectionOf, type Section } from "@/nav";
|
||||||
|
import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions";
|
||||||
import { ConversationsPanel } from "@/business/conversations/components/ConversationsPanel";
|
import { ConversationsPanel } from "@/business/conversations/components/ConversationsPanel";
|
||||||
import { DashboardsPanel } from "@/business/dashboards/components/DashboardsPanel";
|
import { DashboardsPanel } from "@/business/dashboards/components/DashboardsPanel";
|
||||||
import { FilesPanel } from "@/business/files/components/FilesPanel";
|
import { FilesPanel } from "@/business/files/components/FilesPanel";
|
||||||
@@ -37,6 +39,7 @@ const META: Record<Section, { title: string; icon: typeof LayoutDashboard }> = {
|
|||||||
*/
|
*/
|
||||||
export function DrawerContent() {
|
export function DrawerContent() {
|
||||||
const setSidebar = useStore((s) => s.setSidebar);
|
const setSidebar = useStore((s) => s.setSidebar);
|
||||||
|
const isDesktop = useDesktop();
|
||||||
// Refresh = revalidate every mounted React Query (whatever this panel shows).
|
// Refresh = revalidate every mounted React Query (whatever this panel shows).
|
||||||
const fetching = useIsFetching() > 0;
|
const fetching = useIsFetching() > 0;
|
||||||
const refresh = () => queryClient.invalidateQueries();
|
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">
|
<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" />
|
<Icon className="h-4 w-4 shrink-0 text-primary" />
|
||||||
<div className="truncate text-sm font-semibold">{title}</div>
|
<div className="truncate text-sm font-semibold">{title}</div>
|
||||||
<button
|
{/* Hold-to-rearrange row. Close is rendered conditionally rather than
|
||||||
onClick={refresh}
|
with `md:hidden`: a display:none slot measures zero, which would
|
||||||
title="Refresh"
|
leave the drag a dead gap to hit. It is also pinned — on mobile the
|
||||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
drawer covers the app, so benching its only dismiss is a trap. */}
|
||||||
>
|
<HeaderActions
|
||||||
<RefreshCw className={cn("h-4 w-4", fetching && "animate-spin")} />
|
name="header:drawer"
|
||||||
</button>
|
className="ml-auto flex shrink-0 items-center gap-0.5"
|
||||||
<button
|
actions={[
|
||||||
onClick={() => setSidebar(false)}
|
{
|
||||||
title="Close"
|
key: "refresh",
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground md:hidden"
|
label: "Refresh",
|
||||||
>
|
node: (
|
||||||
<X className="h-4 w-4" />
|
<button
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{isDiff ? (
|
{isDiff ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useNotifications, useNotifyLog } from "@/lib/queries";
|
|||||||
import { useLive } from "@/lib/sseBus";
|
import { useLive } from "@/lib/sseBus";
|
||||||
import { buildFeed, mergeLog, matchNotif } from "@/lib/notifications";
|
import { buildFeed, mergeLog, matchNotif } from "@/lib/notifications";
|
||||||
import { PageHeader } from "@/technical/PageHeader";
|
import { PageHeader } from "@/technical/PageHeader";
|
||||||
|
import { HeaderActions } from "@/technical/ui/HeaderActions";
|
||||||
import { NotificationCard } from "@/business/notifications/components/NotificationCard";
|
import { NotificationCard } from "@/business/notifications/components/NotificationCard";
|
||||||
|
|
||||||
/** Group header label for a notification's day. */
|
/** Group header label for a notification's day. */
|
||||||
@@ -71,13 +72,24 @@ export function NotificationsHome() {
|
|||||||
: "loading…"
|
: "loading…"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<HeaderActions
|
||||||
onClick={() => refetch()}
|
name="header:notifications"
|
||||||
title="Refresh"
|
actions={[
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
{
|
||||||
>
|
key: "refresh",
|
||||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
label: "Refresh",
|
||||||
</button>
|
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>
|
</PageHeader>
|
||||||
|
|
||||||
{/* search */}
|
{/* search */}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { fmtTokens, fmtCost, fmtNum, relTime } from "@/lib/utils";
|
|||||||
import { commitDiffHref } from "@/lib/diffRoute";
|
import { commitDiffHref } from "@/lib/diffRoute";
|
||||||
import { useStore } from "@/store";
|
import { useStore } from "@/store";
|
||||||
import { fetchProject, fetchConversations, subscribeEvents } from "@/api";
|
import { fetchProject, fetchConversations, subscribeEvents } from "@/api";
|
||||||
|
import { SectionStack } from "@/technical/ui/SectionStack";
|
||||||
import { Markdown } from "@/technical/Markdown";
|
import { Markdown } from "@/technical/Markdown";
|
||||||
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
|
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
|
||||||
import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
|
import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
|
||||||
@@ -212,142 +213,172 @@ export function Project() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* aggregated conversation cost */}
|
{/* Every block below is hold-to-rearrange and hold-to-hide: hold one
|
||||||
{project.costs && <CostCard costs={project.costs} />}
|
for a moment and drop it on the stash to bench it (SectionStack).
|
||||||
|
Each section carries its own bottom margin, hence a gapless stack. */}
|
||||||
{/* GOAL.md checklist — one Work button per item — above the file */}
|
<SectionStack
|
||||||
{project.goal && (
|
name="sections:project"
|
||||||
<GoalChecklist goal={project.goal} kind="project" dir={project.dir} />
|
className="space-y-0"
|
||||||
)}
|
sections={[
|
||||||
|
{
|
||||||
{/* GOAL.md — the direction the goal-keeper agent pushes forward */}
|
key: "cost",
|
||||||
{project.goal && <GoalSection goal={project.goal} />}
|
label: "Cost",
|
||||||
|
node: project.costs && <CostCard costs={project.costs} />,
|
||||||
{/* key files present */}
|
},
|
||||||
{project.keyFiles.length > 0 && (
|
{
|
||||||
<Section icon={FileText} title="Key files">
|
key: "goal-checklist",
|
||||||
<div className="flex flex-wrap gap-1.5">
|
label: "Goal checklist",
|
||||||
{project.keyFiles.map((f) => (
|
node: project.goal && (
|
||||||
<span
|
<GoalChecklist goal={project.goal} kind="project" dir={project.dir} />
|
||||||
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}
|
key: "goal",
|
||||||
</span>
|
label: "GOAL.md",
|
||||||
))}
|
node: project.goal && <GoalSection goal={project.goal} />,
|
||||||
</div>
|
},
|
||||||
</Section>
|
{
|
||||||
)}
|
key: "key-files",
|
||||||
|
label: "Key files",
|
||||||
{/* npm scripts */}
|
node: project.keyFiles.length > 0 && (
|
||||||
{project.scripts.length > 0 && (
|
<Section icon={FileText} title="Key files">
|
||||||
<Section icon={Terminal} title="Scripts">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
{project.keyFiles.map((f) => (
|
||||||
{project.scripts.map((s) => (
|
<span
|
||||||
<span
|
key={f.name}
|
||||||
key={s}
|
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"
|
||||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
>
|
||||||
>
|
{f.name}
|
||||||
{s}
|
</span>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex justify-end">
|
</Section>
|
||||||
<StatusChecks meta={c.meta} hideEmpty />
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
</div>
|
||||||
</Link>
|
</Section>
|
||||||
))}
|
),
|
||||||
</div>
|
},
|
||||||
)}
|
{
|
||||||
</Section>
|
key: "env",
|
||||||
|
label: "Environment",
|
||||||
{/* README */}
|
node: <EnvEditor slug={project.dir} />,
|
||||||
{project.readme && (
|
},
|
||||||
<Section icon={FileText} title="README.md">
|
{
|
||||||
<Markdown source={project.readme} />
|
key: "commits",
|
||||||
</Section>
|
label: "Commits",
|
||||||
)}
|
node: project.commits.length > 0 && (
|
||||||
|
<Section icon={GitCommit} title="Recent commits">
|
||||||
{/* CLAUDE.md (collapsed) */}
|
<ul className="space-y-0.5">
|
||||||
{project.claudeMd && (
|
{project.commits.map((c) => (
|
||||||
<details className="mt-4 overflow-hidden rounded-xl border border-border bg-card">
|
<li key={c.hash}>
|
||||||
<summary className="cursor-pointer list-none px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground">
|
<Link
|
||||||
CLAUDE.md
|
to={commitDiffHref("project", slug, c.hash)}
|
||||||
</summary>
|
className="flex items-baseline gap-2 rounded-md px-1.5 py-1 text-[13px] hover:bg-accent/60"
|
||||||
<div className="border-t border-border px-3 py-3">
|
>
|
||||||
<Markdown source={project.claudeMd} />
|
<span className="shrink-0 font-mono text-[11px] text-primary">
|
||||||
</div>
|
{c.hash}
|
||||||
</details>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
fetchMemories,
|
fetchMemories,
|
||||||
subscribeEvents,
|
subscribeEvents,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { SectionStack } from "@/technical/ui/SectionStack";
|
||||||
import { Markdown } from "@/technical/Markdown";
|
import { Markdown } from "@/technical/Markdown";
|
||||||
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
|
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
|
||||||
import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
|
import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
|
||||||
@@ -528,245 +529,279 @@ export function Service() {
|
|||||||
</Fact>
|
</Fact>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GOAL.md checklist — one Work button per item — above the file */}
|
{/* Hold-to-rearrange, hold-to-hide: drop a section on the stash
|
||||||
{service.goal && (
|
popover to bench it, drag its tag back to restore it. Sections
|
||||||
<GoalChecklist goal={service.goal} kind="service" dir={service.dir} />
|
carry their own bottom margin, hence a gapless stack. */}
|
||||||
)}
|
<SectionStack
|
||||||
|
name="sections:service"
|
||||||
{/* GOAL.md — the direction the goal-keeper agent pushes forward */}
|
className="space-y-0"
|
||||||
{service.goal && <GoalSection goal={service.goal} />}
|
sections={[
|
||||||
|
{
|
||||||
{/* URLs */}
|
key: "goal-checklist",
|
||||||
{service.urls.length > 0 && (
|
label: "Goal checklist",
|
||||||
<Section icon={Globe} title="URLs" count={service.urls.length}>
|
node: service.goal && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<GoalChecklist goal={service.goal} kind="service" dir={service.dir} />
|
||||||
{service.urls.map((u) =>
|
),
|
||||||
u.startsWith("http") ? (
|
},
|
||||||
<a
|
{
|
||||||
key={u}
|
key: "goal",
|
||||||
href={u}
|
label: "GOAL.md",
|
||||||
target="_blank"
|
node: service.goal && <GoalSection goal={service.goal} />,
|
||||||
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"
|
{
|
||||||
>
|
key: "urls",
|
||||||
<ExternalLink className="h-3 w-3" />
|
label: "URLs",
|
||||||
{hostOf(u)}
|
node: service.urls.length > 0 && (
|
||||||
</a>
|
<Section icon={Globe} title="URLs" count={service.urls.length}>
|
||||||
) : (
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<Chip key={u}>{u}</Chip>
|
{service.urls.map((u) =>
|
||||||
),
|
u.startsWith("http") ? (
|
||||||
)}
|
<a
|
||||||
</div>
|
key={u}
|
||||||
</Section>
|
href={u}
|
||||||
)}
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
{/* containers */}
|
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"
|
||||||
{service.containersDetail.length > 0 && (
|
>
|
||||||
<Section icon={Boxes} title="Containers" count={service.containersDetail.length}>
|
<ExternalLink className="h-3 w-3" />
|
||||||
<div className="space-y-2">
|
{hostOf(u)}
|
||||||
{service.containersDetail.map((c) => (
|
</a>
|
||||||
<ContainerCard key={`${c.composeFile}:${c.key}`} c={c} />
|
) : (
|
||||||
))}
|
<Chip key={u}>{u}</Chip>
|
||||||
</div>
|
),
|
||||||
{service.composeFiles.length > 0 && (
|
)}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
</div>
|
||||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
</Section>
|
||||||
compose:
|
),
|
||||||
</span>
|
},
|
||||||
{service.composeFiles.map((f) => (
|
{
|
||||||
<Chip key={f}>{f}</Chip>
|
key: "containers",
|
||||||
))}
|
label: "Containers",
|
||||||
</div>
|
node: service.containersDetail.length > 0 && (
|
||||||
)}
|
<Section icon={Boxes} title="Containers" count={service.containersDetail.length}>
|
||||||
</Section>
|
<div className="space-y-2">
|
||||||
)}
|
{service.containersDetail.map((c) => (
|
||||||
|
<ContainerCard key={`${c.composeFile}:${c.key}`} c={c} />
|
||||||
{/* 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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<code className="mt-1 block truncate text-[11px] text-muted-foreground" title={r.rule}>
|
{service.composeFiles.length > 0 && (
|
||||||
{r.rule}
|
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||||
</code>
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
{r.middlewares.length > 0 && (
|
compose:
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
</span>
|
||||||
{r.middlewares.map((m) => (
|
{service.composeFiles.map((f) => (
|
||||||
<span
|
<Chip key={f}>{f}</Chip>
|
||||||
key={m}
|
|
||||||
className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-medium text-accent-foreground"
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
))}
|
),
|
||||||
</div>
|
},
|
||||||
{service.traefikServices.length > 0 && (
|
{
|
||||||
<div className="mt-2 space-y-0.5">
|
key: "routers",
|
||||||
{service.traefikServices.map((ts) => (
|
label: "Traefik routers",
|
||||||
<div key={ts.name} className="flex items-center gap-1 font-mono text-[11px] text-muted-foreground">
|
node: (httpRouters.length > 0 || tcpRouters.length > 0) && (
|
||||||
<span className="text-foreground/70">{ts.name}</span>
|
<Section icon={RouteIcon} title="Traefik routers" count={service.routers.length}>
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground/40" />
|
<div className="space-y-1.5">
|
||||||
<span className="truncate">{ts.servers.join(", ")}</span>
|
{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>
|
||||||
))}
|
{service.traefikServices.length > 0 && (
|
||||||
</div>
|
<div className="mt-2 space-y-0.5">
|
||||||
)}
|
{service.traefikServices.map((ts) => (
|
||||||
</Section>
|
<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" />
|
||||||
{/* env keys (union) */}
|
<span className="truncate">{ts.servers.join(", ")}</span>
|
||||||
{service.envKeys.length > 0 && (
|
</div>
|
||||||
<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>
|
|
||||||
</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>
|
</Section>
|
||||||
))}
|
),
|
||||||
</div>
|
},
|
||||||
</Section>
|
{
|
||||||
)}
|
key: "env-keys",
|
||||||
|
label: "Environment keys",
|
||||||
{/* README */}
|
node: service.envKeys.length > 0 && (
|
||||||
{service.readme && (
|
<Section icon={KeyRound} title="Environment keys" count={service.envKeys.length}>
|
||||||
<Section icon={FileText} title="README.md">
|
<p className="mb-2 text-[11px] text-muted-foreground/70">
|
||||||
<Markdown source={service.readme} />
|
Keys only — values are never read.
|
||||||
</Section>
|
</p>
|
||||||
)}
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{service.envKeys.map((k) => (
|
||||||
{/* CLAUDE.md (collapsed) */}
|
<Chip key={k}>{k}</Chip>
|
||||||
{service.claudeMd && (
|
))}
|
||||||
<details className="mt-4 overflow-hidden rounded-xl border border-border bg-card">
|
</div>
|
||||||
<summary className="cursor-pointer list-none px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground">
|
</Section>
|
||||||
CLAUDE.md
|
),
|
||||||
</summary>
|
},
|
||||||
<div className="border-t border-border px-3 py-3">
|
{
|
||||||
<Markdown source={service.claudeMd} />
|
key: "files",
|
||||||
</div>
|
label: "Files",
|
||||||
</details>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { Monitor, CreditCard, Wand2, Clock, Webhook, Rabbit } from "lucide-react";
|
import { Monitor, CreditCard, Wand2, Clock, Webhook, Rabbit } from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSettings, type PromptShortcut } from "@/settings";
|
import { useSettings, type PromptShortcut } from "@/settings";
|
||||||
import { SHORTCUT_ICON_KEYS, shortcutIcon } from "@/lib/shortcutIcons";
|
import { SHORTCUT_ICON_KEYS, shortcutIcon } from "@/lib/shortcutIcons";
|
||||||
import { PageHeader } from "@/technical/PageHeader";
|
import { PageHeader } from "@/technical/PageHeader";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
|
|
||||||
/** A compact text input used across the shortcut editor. */
|
/** A compact text input used across the shortcut editor. */
|
||||||
function TextField({
|
function TextField({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@/settings";
|
} from "@/settings";
|
||||||
import { useAvatarSettings } from "@/lib/avatar";
|
import { useAvatarSettings } from "@/lib/avatar";
|
||||||
import { subscriptionPaid, billingMonths } from "@/lib/analytics";
|
import { subscriptionPaid, billingMonths } from "@/lib/analytics";
|
||||||
|
import { SectionStack } from "@/technical/ui/SectionStack";
|
||||||
import { PageHeader } from "@/technical/PageHeader";
|
import { PageHeader } from "@/technical/PageHeader";
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
@@ -377,165 +378,183 @@ export function Settings() {
|
|||||||
/>
|
/>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
<div className="mx-auto max-w-2xl space-y-8">
|
<div className="mx-auto max-w-2xl space-y-8">
|
||||||
{/* ── Display ─────────────────────────────────────────────────── */}
|
{/* Hold a card to rearrange the page, or drop it on the stash to
|
||||||
<Card
|
bench it — Settings is mostly write-once, and a benched card is
|
||||||
id="display"
|
one less thing to scroll past. */}
|
||||||
icon={<Monitor className="h-4 w-4 text-primary" />}
|
<SectionStack
|
||||||
title="Display"
|
name="sections:settings"
|
||||||
innerRef={(el) => (refs.current.display = el)}
|
className="space-y-8"
|
||||||
>
|
sections={[
|
||||||
<Row title="Theme" help="Color scheme for the interface.">
|
{
|
||||||
<Segmented<ThemePref>
|
key: "display",
|
||||||
value={s.theme}
|
label: "Display",
|
||||||
onChange={s.setTheme}
|
node: <Card
|
||||||
options={[
|
id="display"
|
||||||
{ value: "light", label: "Light", icon: <Sun className="h-3.5 w-3.5" /> },
|
icon={<Monitor className="h-4 w-4 text-primary" />}
|
||||||
{ value: "dark", label: "Dark", icon: <Moon className="h-3.5 w-3.5" /> },
|
title="Display"
|
||||||
{ value: "system", label: "System", icon: <Monitor className="h-3.5 w-3.5" /> },
|
innerRef={(el) => (refs.current.display = el)}
|
||||||
]}
|
>
|
||||||
/>
|
<Row title="Theme" help="Color scheme for the interface.">
|
||||||
</Row>
|
<Segmented<ThemePref>
|
||||||
|
value={s.theme}
|
||||||
<Row title="Currency" help="How all costs are displayed.">
|
onChange={s.setTheme}
|
||||||
<Segmented<Currency>
|
options={[
|
||||||
value={s.currency}
|
{ value: "light", label: "Light", icon: <Sun className="h-3.5 w-3.5" /> },
|
||||||
onChange={s.setCurrency}
|
{ value: "dark", label: "Dark", icon: <Moon className="h-3.5 w-3.5" /> },
|
||||||
options={[
|
{ value: "system", label: "System", icon: <Monitor className="h-3.5 w-3.5" /> },
|
||||||
{ 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"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">/mo</span>
|
</Row>
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row title="Start date" help="When this subscription began — the savings epoch.">
|
<Row title="Currency" help="How all costs are displayed.">
|
||||||
<input
|
<Segmented<Currency>
|
||||||
type="date"
|
value={s.currency}
|
||||||
value={s.subscription.startDate}
|
onChange={s.setCurrency}
|
||||||
onChange={(e) => s.setSubscription({ startDate: e.target.value })}
|
options={[
|
||||||
className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
{ value: "USD", label: "$ USD" },
|
||||||
/>
|
{ value: "EUR", label: "€ EUR" },
|
||||||
</Row>
|
]}
|
||||||
</>
|
/>
|
||||||
)}
|
</Row>
|
||||||
|
|
||||||
{/* live estimate */}
|
{s.currency === "EUR" && (
|
||||||
<div className="border-t border-border py-4">
|
<Row title="USD → EUR rate" help="Exchange rate used to convert dollar costs to euros.">
|
||||||
{configured ? (
|
<input
|
||||||
<div className="rounded-lg border border-primary/30 bg-gradient-to-br from-primary/10 to-transparent p-3">
|
type="number"
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold">
|
step="0.01"
|
||||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
min="0"
|
||||||
Estimate
|
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>
|
</div>
|
||||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
</Card>,
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -54,3 +54,89 @@ export function useListOrder<T>(
|
|||||||
|
|
||||||
return [ordered, commit];
|
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.
|
* renaming or dropping an entry can never strand a list.
|
||||||
*/
|
*/
|
||||||
listOrders: Record<string, string[]>;
|
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
|
* 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
|
* 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;
|
setAvatar: (patch: Partial<AvatarSettings>) => void;
|
||||||
setListOrder: (name: string, order: string[]) => void;
|
setListOrder: (name: string, order: string[]) => void;
|
||||||
resetListOrder: (name: string) => void;
|
resetListOrder: (name: string) => void;
|
||||||
|
setListStash: (name: string, stash: string[]) => void;
|
||||||
|
resetListStash: (name: string) => void;
|
||||||
addPromptShortcut: () => void;
|
addPromptShortcut: () => void;
|
||||||
updatePromptShortcut: (id: string, patch: Partial<PromptShortcut>) => void;
|
updatePromptShortcut: (id: string, patch: Partial<PromptShortcut>) => void;
|
||||||
removePromptShortcut: (id: string) => void;
|
removePromptShortcut: (id: string) => void;
|
||||||
@@ -227,6 +238,7 @@ export const useSettings = create<SettingsState>()(
|
|||||||
subscription: { plan: "none", monthlyCost: 0, startDate: "" },
|
subscription: { plan: "none", monthlyCost: 0, startDate: "" },
|
||||||
promptShortcuts: DEFAULT_PROMPT_SHORTCUTS.map((s) => ({ ...s })),
|
promptShortcuts: DEFAULT_PROMPT_SHORTCUTS.map((s) => ({ ...s })),
|
||||||
listOrders: {},
|
listOrders: {},
|
||||||
|
listStashes: {},
|
||||||
spawnModel: "",
|
spawnModel: "",
|
||||||
spawnHarness: "claude",
|
spawnHarness: "claude",
|
||||||
spawnThinking: true,
|
spawnThinking: true,
|
||||||
@@ -256,6 +268,14 @@ export const useSettings = create<SettingsState>()(
|
|||||||
return { listOrders: rest };
|
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: () =>
|
addPromptShortcut: () =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
promptShortcuts: [
|
promptShortcuts: [
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Eye, EyeOff, Check } from "lucide-react";
|
import { Eye, EyeOff, Check } from "lucide-react";
|
||||||
|
import { HoldEditable } from "@gabvdl/ui";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { VIS_GROUPS, useVisibility, type VisGroup, type VisItem } from "@/lib/visibility";
|
import { VIS_GROUPS, useVisibility, type VisGroup, type VisItem } from "@/lib/visibility";
|
||||||
import { useListOrder } from "@/lib/listOrder";
|
import { useListOrder } from "@/lib/listOrder";
|
||||||
import { HoldEditable } from "@/technical/ui/HoldEditable";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eye button + popover: switches for everything the conversation page renders
|
* 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