diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8bbfa14..65c4b77 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "ai-agent", "version": "0.1.0", "dependencies": { - "@gabvdl/ui": "^0.30.1", + "@gabvdl/ui": "^0.30.3", "@tanstack/query-async-storage-persister": "^5.101.2", "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-persist-client": "^5.101.2", @@ -1473,9 +1473,9 @@ } }, "node_modules/@gabvdl/ui": { - "version": "0.30.1", - "resolved": "http://localhost:4873/@gabvdl/ui/-/ui-0.30.1.tgz", - "integrity": "sha512-rhVZiO/X2YZ21/si5Sk+iPSV5SI5Ydfui2Svyp1WKcwh9+P4YIxB0NvDOl5YUNw3uqE9MIcAiRlQbBPhbLixOA==", + "version": "0.30.3", + "resolved": "http://localhost:4873/@gabvdl/ui/-/ui-0.30.3.tgz", + "integrity": "sha512-qqKtXGk1WkeoglTXRMDXqrEgmj9HKgZpxF64WG+7YnYymtIA95HGPKbOxv/chcU3ACy+hbyLDx1nZixA6GqyfA==", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "^3.14.5", diff --git a/frontend/package.json b/frontend/package.json index c578236..735ab9a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "preview": "vite preview" }, "dependencies": { - "@gabvdl/ui": "^0.30.1", + "@gabvdl/ui": "^0.30.3", "@tanstack/query-async-storage-persister": "^5.101.2", "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-persist-client": "^5.101.2", diff --git a/frontend/src/business/composer/components/BottomBar.tsx b/frontend/src/business/composer/components/BottomBar.tsx index c6b8a20..6d6baee 100644 --- a/frontend/src/business/composer/components/BottomBar.tsx +++ b/frontend/src/business/composer/components/BottomBar.tsx @@ -6,11 +6,11 @@ import { FolderGit2, Settings as SettingsIcon, } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn } from "@/lib/utils"; import { sectionOf, type Section } from "@/nav"; import { useSpawnDock } from "@/lib/spawnDock"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; interface Tab { section: Section; diff --git a/frontend/src/business/conversations/pages/Conversation.tsx b/frontend/src/business/conversations/pages/Conversation.tsx index b5f7d3c..73b314e 100644 --- a/frontend/src/business/conversations/pages/Conversation.tsx +++ b/frontend/src/business/conversations/pages/Conversation.tsx @@ -39,7 +39,7 @@ import { Images, Play, } from "lucide-react"; -import { ProgressiveList, ResizableLayout, useImageViewer, type ViewerMedia } from "@gabvdl/ui"; +import { HoldEditable, ProgressiveList, ResizableLayout, type ViewerMedia, useImageViewer } from "@gabvdl/ui"; import { AnimationSpeedContext } from "@/lib/animationSpeed"; import { cn, fmtTokens, fmtCost, fmtDateTime, fmtLatency, relTime } from "@/lib/utils"; import { @@ -51,9 +51,9 @@ import { import { useStore } from "@/store"; import { useVis, type VisKey } from "@/lib/visibility"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; import { EffortTags, ModelTags, effortsOf, modelFamily } from "@/lib/models"; import { VisibilityPopover } from "@/technical/VisibilityPopover"; +import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions"; import { interruptConversation } from "@/api"; import { useConversation, useConversationCommits, useUncommittedDiffStats } from "@/lib/queries"; import { repoTokenHref, conversationDiffHref, uncommittedDiffHref } from "@/lib/diffRoute"; @@ -425,65 +425,95 @@ export function Conversation() { {convo && (
{metaLine}
- {(serverRunning || serverPaused) && ( - - )} - - {/* No composer toggle: it's a plain bar pinned under the thread. */} - - + {/* Hold any of these to rearrange the row, or drop one on the stash + to bench it — a reader who never touches the details panel can + take it out of the header. Interrupt is pinned: a running + session must always be stoppable from here. */} + void interrupt()} + disabled={interrupting} + title={ + interruptErr + ? `Interrupt failed: ${interruptErr}` + : "Interrupt this running session (sends Esc / SIGINT)" + } + className={cn( + "flex h-8 items-center gap-1.5 rounded-md border px-2 text-[12px] font-medium transition-colors", + interruptErr + ? "border-rose-500/40 bg-rose-500/10 text-rose-500" + : "border-rose-500/30 text-rose-500 hover:bg-rose-500/10", + interrupting && "opacity-60", + )} + > + {interrupting ? ( + + ) : ( + + )} + + {interrupting ? "Stopping…" : "Interrupt"} + + + ), + } satisfies HeaderAction, + ] + : []), + { + key: "expand", + label: "Expand tools", + node: ( + + ), + }, + // No composer toggle: it's a plain bar pinned under the thread. + { key: "visibility", label: "Visibility", node: }, + { + key: "details", + label: "Details", + node: ( + + ), + }, + ]} + />
)} diff --git a/frontend/src/business/conversations/pages/ConversationHome.tsx b/frontend/src/business/conversations/pages/ConversationHome.tsx index d3d8b9d..332b032 100644 --- a/frontend/src/business/conversations/pages/ConversationHome.tsx +++ b/frontend/src/business/conversations/pages/ConversationHome.tsx @@ -9,6 +9,7 @@ import { useStore } from "@/store"; import { useHydrated } from "@/lib/useHydrated"; import { buildFeed } from "@/lib/notifications"; import { PageHeader } from "@/technical/PageHeader"; +import { HeaderActions } from "@/technical/ui/HeaderActions"; import { NotificationCard } from "@/business/notifications/components/NotificationCard"; import { RunningConversations } from "@/business/conversations/components/RunningConversations"; import { SpawnDrawer } from "@/business/composer/components/SpawnDrawer"; @@ -71,34 +72,65 @@ export function ConversationHome() { : "notify-done pushes" } > - - - - - - - - - - + {/* Hold to rearrange, or drop on the stash to bench a shortcut you + never take (see HeaderActions). */} + + + + ), + }, + { + key: "notifications", + label: "All notifications", + node: ( + + + + ), + }, + { + key: "analytics", + label: "Analytics", + node: ( + + + + ), + }, + { + key: "refresh", + label: "Refresh", + node: ( + + ), + }, + ]} + />
diff --git a/frontend/src/business/conversations/pages/Conversations.tsx b/frontend/src/business/conversations/pages/Conversations.tsx index 669964e..0f64d84 100644 --- a/frontend/src/business/conversations/pages/Conversations.tsx +++ b/frontend/src/business/conversations/pages/Conversations.tsx @@ -22,6 +22,7 @@ import { EffortTags, ModelTags, effortsOf } from "@/lib/models"; import { useLive } from "@/lib/sseBus"; import { convTime } from "@/lib/analytics"; import { PageHeader } from "@/technical/PageHeader"; +import { HeaderActions } from "@/technical/ui/HeaderActions"; import { StateBadge, HarnessBadge, @@ -206,27 +207,52 @@ export function Conversations() { ) } > - - - - - - - + {/* Back is pinned — it is this page's way out. */} + + + + ), + }, + { + key: "analytics", + label: "Analytics", + node: ( + + + + ), + }, + { + key: "refresh", + label: "Refresh", + node: ( + + ), + }, + ]} + /> {/* search + controls */} diff --git a/frontend/src/business/dashboards/components/DashboardsPanel.tsx b/frontend/src/business/dashboards/components/DashboardsPanel.tsx index 3c06c0d..76594dc 100644 --- a/frontend/src/business/dashboards/components/DashboardsPanel.tsx +++ b/frontend/src/business/dashboards/components/DashboardsPanel.tsx @@ -1,8 +1,8 @@ import { Link, useLocation } from "react-router-dom"; import { Gauge, MessagesSquare, Leaf, Waypoints } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn } from "@/lib/utils"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; interface Page { to: string; diff --git a/frontend/src/business/dashboards/pages/ConversationsAnalytics.tsx b/frontend/src/business/dashboards/pages/ConversationsAnalytics.tsx index 97fcaa8..49c8d61 100644 --- a/frontend/src/business/dashboards/pages/ConversationsAnalytics.tsx +++ b/frontend/src/business/dashboards/pages/ConversationsAnalytics.tsx @@ -17,11 +17,11 @@ import { BookOpen, ChevronRight, } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn, fmtTokens, fmtCost } from "@/lib/utils"; import { useStore } from "@/store"; import { useConversationsFull } from "@/lib/queries"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; import { useLive } from "@/lib/sseBus"; import { RANGES, diff --git a/frontend/src/business/dashboards/pages/Dashboard.tsx b/frontend/src/business/dashboards/pages/Dashboard.tsx index 3db4cdf..d78efd7 100644 --- a/frontend/src/business/dashboards/pages/Dashboard.tsx +++ b/frontend/src/business/dashboards/pages/Dashboard.tsx @@ -9,11 +9,11 @@ import { Activity, Coins, } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn, fmtTokens, fmtNum, fmtCost, relTime } from "@/lib/utils"; import { useStore } from "@/store"; import { useBundle, useDashboard } from "@/lib/queries"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; import { Button } from "@/technical/ui/button"; import type { SkillStat } from "@/types"; import { Roll } from "@/technical/Roll"; diff --git a/frontend/src/business/files/components/FilesPanel.tsx b/frontend/src/business/files/components/FilesPanel.tsx index 134f2e2..eef4563 100644 --- a/frontend/src/business/files/components/FilesPanel.tsx +++ b/frontend/src/business/files/components/FilesPanel.tsx @@ -1,11 +1,11 @@ import { Link, useLocation } from "react-router-dom"; import { Search, X, Brain, DollarSign, Bell, ClipboardList, Target } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn, fmtTokens, fmtCost } from "@/lib/utils"; import { useStore } from "@/store"; import { useBundle } from "@/lib/queries"; import { useListOrder } from "@/lib/listOrder"; import { buildTree, filterFiles } from "@/tree"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; import { FileTree } from "./FileTree"; interface Shortcut { diff --git a/frontend/src/business/files/pages/FilesHome.tsx b/frontend/src/business/files/pages/FilesHome.tsx index 17e5c8d..f001d2d 100644 --- a/frontend/src/business/files/pages/FilesHome.tsx +++ b/frontend/src/business/files/pages/FilesHome.tsx @@ -1,8 +1,8 @@ import { Link } from "react-router-dom"; import { FolderTree, FileText, Brain, Wrench, Bell, ClipboardList, Target } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { useBundle } from "@/lib/queries"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; import { PageHeader } from "@/technical/PageHeader"; interface JumpDef { diff --git a/frontend/src/business/goals/pages/Goals.tsx b/frontend/src/business/goals/pages/Goals.tsx index 3faf588..5efdc7e 100644 --- a/frontend/src/business/goals/pages/Goals.tsx +++ b/frontend/src/business/goals/pages/Goals.tsx @@ -5,6 +5,7 @@ import { Button, EmptyState, FuzzyList, type FuzzyRenderContext } from "@gabvdl/ import { cn } from "@/lib/utils"; import { useGoals } from "@/lib/queries"; import { PageHeader } from "@/technical/PageHeader"; +import { HeaderActions, type HeaderAction } from "@/technical/ui/HeaderActions"; import { GoalCard } from "../components/GoalCard"; import type { GoalBoardEntry } from "@/types"; import { Roll } from "@/technical/Roll"; @@ -71,21 +72,43 @@ export function Goals() { title="Goals" subtitle={subtitle()} > - {agents > 0 && ( - - - - - )} - - + {/* Hold-to-rearrange row. Close is rendered conditionally rather than + with `md:hidden`: a display:none slot measures zero, which would + leave the drag a dead gap to hit. It is also pinned — on mobile the + drawer covers the app, so benching its only dismiss is a trap. */} + + + + ), + }, + ...(isDesktop + ? [] + : [ + { + key: "close", + label: "Close", + pinned: true, + node: ( + + ), + } satisfies HeaderAction, + ]), + ]} + />
{isDiff ? ( diff --git a/frontend/src/business/notifications/pages/NotificationsHome.tsx b/frontend/src/business/notifications/pages/NotificationsHome.tsx index dd19a34..768cd58 100644 --- a/frontend/src/business/notifications/pages/NotificationsHome.tsx +++ b/frontend/src/business/notifications/pages/NotificationsHome.tsx @@ -5,6 +5,7 @@ import { useNotifications, useNotifyLog } from "@/lib/queries"; import { useLive } from "@/lib/sseBus"; import { buildFeed, mergeLog, matchNotif } from "@/lib/notifications"; import { PageHeader } from "@/technical/PageHeader"; +import { HeaderActions } from "@/technical/ui/HeaderActions"; import { NotificationCard } from "@/business/notifications/components/NotificationCard"; /** Group header label for a notification's day. */ @@ -71,13 +72,24 @@ export function NotificationsHome() { : "loading…" } > - + refetch()} + title="Refresh" + className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" + > + + + ), + }, + ]} + /> {/* search */} diff --git a/frontend/src/business/projects/pages/Project.tsx b/frontend/src/business/projects/pages/Project.tsx index 1454781..f08d8b8 100644 --- a/frontend/src/business/projects/pages/Project.tsx +++ b/frontend/src/business/projects/pages/Project.tsx @@ -17,6 +17,7 @@ import { fmtTokens, fmtCost, fmtNum, relTime } from "@/lib/utils"; import { commitDiffHref } from "@/lib/diffRoute"; import { useStore } from "@/store"; import { fetchProject, fetchConversations, subscribeEvents } from "@/api"; +import { SectionStack } from "@/technical/ui/SectionStack"; import { Markdown } from "@/technical/Markdown"; import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal"; import { GoalChecklist } from "@/business/projects/components/GoalChecklist"; @@ -212,142 +213,172 @@ export function Project() { )} - {/* aggregated conversation cost */} - {project.costs && } - - {/* GOAL.md checklist — one Work button per item — above the file */} - {project.goal && ( - - )} - - {/* GOAL.md — the direction the goal-keeper agent pushes forward */} - {project.goal && } - - {/* key files present */} - {project.keyFiles.length > 0 && ( -
-
- {project.keyFiles.map((f) => ( - - {f.name} - - ))} -
-
- )} - - {/* npm scripts */} - {project.scripts.length > 0 && ( -
-
- {project.scripts.map((s) => ( - - {s} - - ))} -
-
- )} - - {/* .env editor — regular vars + write-only secrets */} - - - {/* recent commits */} - {project.commits.length > 0 && ( -
-
    - {project.commits.map((c) => ( -
  • - - - {c.hash} - - - {c.subject} - - - {relTime(c.date)} - - -
  • - ))} -
-
- )} - - {/* conversations that worked on this project */} -
- {convos == null ? ( -

Loading…

- ) : convos.length === 0 ? ( -

- No conversations tagged with this project yet. -

- ) : ( -
- {convos.map((c) => ( - -
-
-
- - - {c.title} - -
-
- {(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)} -
-
-
-
- -
-
- tok -
-
+ {/* Every block below is hold-to-rearrange and hold-to-hide: hold one + for a moment and drop it on the stash to bench it (SectionStack). + Each section carries its own bottom margin, hence a gapless stack. */} + , + }, + { + key: "goal-checklist", + label: "Goal checklist", + node: project.goal && ( + + ), + }, + { + key: "goal", + label: "GOAL.md", + node: project.goal && , + }, + { + key: "key-files", + label: "Key files", + node: project.keyFiles.length > 0 && ( +
+
+ {project.keyFiles.map((f) => ( + + {f.name} + + ))}
-
- +
+ ), + }, + { + key: "scripts", + label: "Scripts", + node: project.scripts.length > 0 && ( +
+
+ {project.scripts.map((s) => ( + + {s} + + ))}
- - ))} -
- )} -
- - {/* README */} - {project.readme && ( -
- -
- )} - - {/* CLAUDE.md (collapsed) */} - {project.claudeMd && ( -
- - CLAUDE.md - -
- -
-
- )} + + ), + }, + { + key: "env", + label: "Environment", + node: , + }, + { + key: "commits", + label: "Commits", + node: project.commits.length > 0 && ( +
+
    + {project.commits.map((c) => ( +
  • + + + {c.hash} + + + {c.subject} + + + {relTime(c.date)} + + +
  • + ))} +
+
+ ), + }, + { + key: "conversations", + label: "Conversations", + node:
+ {convos == null ? ( +

Loading…

+ ) : convos.length === 0 ? ( +

+ No conversations tagged with this project yet. +

+ ) : ( +
+ {convos.map((c) => ( + +
+
+
+ + + {c.title} + +
+
+ {(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)} +
+
+
+
+ +
+
+ tok +
+
+
+
+ +
+ + ))} +
+ )} +
, + }, + { + key: "readme", + label: "README", + node: project.readme && ( +
+ +
+ ), + }, + { + key: "claude-md", + label: "CLAUDE.md", + node: project.claudeMd && ( +
+ + CLAUDE.md + +
+ +
+
+ ), + }, + ]} + /> )} diff --git a/frontend/src/business/projects/pages/Service.tsx b/frontend/src/business/projects/pages/Service.tsx index 6f73142..3962592 100644 --- a/frontend/src/business/projects/pages/Service.tsx +++ b/frontend/src/business/projects/pages/Service.tsx @@ -42,6 +42,7 @@ import { fetchMemories, subscribeEvents, } from "@/api"; +import { SectionStack } from "@/technical/ui/SectionStack"; import { Markdown } from "@/technical/Markdown"; import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal"; import { GoalChecklist } from "@/business/projects/components/GoalChecklist"; @@ -528,245 +529,279 @@ export function Service() { - {/* GOAL.md checklist — one Work button per item — above the file */} - {service.goal && ( - - )} - - {/* GOAL.md — the direction the goal-keeper agent pushes forward */} - {service.goal && } - - {/* URLs */} - {service.urls.length > 0 && ( -
-
- {service.urls.map((u) => - u.startsWith("http") ? ( - - - {hostOf(u)} - - ) : ( - {u} - ), - )} -
-
- )} - - {/* containers */} - {service.containersDetail.length > 0 && ( -
-
- {service.containersDetail.map((c) => ( - - ))} -
- {service.composeFiles.length > 0 && ( -
- - compose: - - {service.composeFiles.map((f) => ( - {f} - ))} -
- )} -
- )} - - {/* traefik routers */} - {(httpRouters.length > 0 || tcpRouters.length > 0) && ( -
-
- {service.routers.map((r) => ( -
-
- {r.name} - {r.kind === "tcp" && tcp} - {r.priority != null && prio {r.priority}} - {r.entryPoints.map((e) => ( - {e} + {/* Hold-to-rearrange, hold-to-hide: drop a section on the stash + popover to bench it, drag its tag back to restore it. Sections + carry their own bottom margin, hence a gapless stack. */} + + ), + }, + { + key: "goal", + label: "GOAL.md", + node: service.goal && , + }, + { + key: "urls", + label: "URLs", + node: service.urls.length > 0 && ( +
+
+ {service.urls.map((u) => + u.startsWith("http") ? ( + + + {hostOf(u)} + + ) : ( + {u} + ), + )} +
+
+ ), + }, + { + key: "containers", + label: "Containers", + node: service.containersDetail.length > 0 && ( +
+
+ {service.containersDetail.map((c) => ( + ))}
- - {r.rule} - - {r.middlewares.length > 0 && ( -
- {r.middlewares.map((m) => ( - - {m} - + {service.composeFiles.length > 0 && ( +
+ + compose: + + {service.composeFiles.map((f) => ( + {f} ))}
)} -
- ))} -
- {service.traefikServices.length > 0 && ( -
- {service.traefikServices.map((ts) => ( -
- {ts.name} - - {ts.servers.join(", ")} +
+ ), + }, + { + key: "routers", + label: "Traefik routers", + node: (httpRouters.length > 0 || tcpRouters.length > 0) && ( +
+
+ {service.routers.map((r) => ( +
+
+ {r.name} + {r.kind === "tcp" && tcp} + {r.priority != null && prio {r.priority}} + {r.entryPoints.map((e) => ( + {e} + ))} +
+ + {r.rule} + + {r.middlewares.length > 0 && ( +
+ {r.middlewares.map((m) => ( + + {m} + + ))} +
+ )} +
+ ))}
- ))} - - )} -
- )} - - {/* env keys (union) */} - {service.envKeys.length > 0 && ( -
-

- Keys only — values are never read. -

-
- {service.envKeys.map((k) => ( - {k} - ))} -
-
- )} - - {/* file tree */} - {service.tree.length > 0 && ( -
-
    - {service.tree.map((n) => ( - - ))} -
-
- )} - - {/* recent commits */} - {service.commits.length > 0 && ( -
-
    - {service.commits.map((c) => ( -
  • - - {c.hash} - - {c.subject} - - - {relTime(c.date)} - - -
  • - ))} -
-
- )} - - {/* conversations that worked on this service */} -
- {convos == null ? ( -

Loading…

- ) : convos.length === 0 ? ( -

- No conversations tagged with this service yet. -

- ) : ( -
- {convos.map((c) => ( - -
-
-
- - {c.title} -
-
- {(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)} -
+ {service.traefikServices.length > 0 && ( +
+ {service.traefikServices.map((ts) => ( +
+ {ts.name} + + {ts.servers.join(", ")} +
+ ))}
-
-
- -
-
- tok -
-
-
-
- -
- - ))} -
- )} -
- - {/* memories that mention this service */} - {memories != null && memories.length > 0 && ( -
-
- {memories.map((m) => ( - -
- {m.type && ( - - {m.type} - - )} - {m.name} -
- {m.description && ( -

- {m.description} -

)} - - ))} -
-
- )} - - {/* README */} - {service.readme && ( -
- -
- )} - - {/* CLAUDE.md (collapsed) */} - {service.claudeMd && ( -
- - CLAUDE.md - -
- -
-
- )} + + ), + }, + { + key: "env-keys", + label: "Environment keys", + node: service.envKeys.length > 0 && ( +
+

+ Keys only — values are never read. +

+
+ {service.envKeys.map((k) => ( + {k} + ))} +
+
+ ), + }, + { + key: "files", + label: "Files", + node: service.tree.length > 0 && ( +
+
    + {service.tree.map((n) => ( + + ))} +
+
+ ), + }, + { + key: "commits", + label: "Commits", + node: service.commits.length > 0 && ( +
+
    + {service.commits.map((c) => ( +
  • + + {c.hash} + + {c.subject} + + + {relTime(c.date)} + + +
  • + ))} +
+
+ ), + }, + { + key: "conversations", + label: "Conversations", + node:
+ {convos == null ? ( +

Loading…

+ ) : convos.length === 0 ? ( +

+ No conversations tagged with this service yet. +

+ ) : ( +
+ {convos.map((c) => ( + +
+
+
+ + {c.title} +
+
+ {(c.model || "").replace("claude-", "")} · {relTime(c.endedAt)} +
+
+
+
+ +
+
+ tok +
+
+
+
+ +
+ + ))} +
+ )} +
, + }, + { + key: "memories", + label: "Memories", + node: memories != null && memories.length > 0 && ( +
+
+ {memories.map((m) => ( + +
+ {m.type && ( + + {m.type} + + )} + {m.name} +
+ {m.description && ( +

+ {m.description} +

+ )} + + ))} +
+
+ ), + }, + { + key: "readme", + label: "README", + node: service.readme && ( +
+ +
+ ), + }, + { + key: "claude-md", + label: "CLAUDE.md", + node: service.claudeMd && ( +
+ + CLAUDE.md + +
+ +
+
+ ), + }, + ]} + /> )} diff --git a/frontend/src/business/settings/components/SettingsPanel.tsx b/frontend/src/business/settings/components/SettingsPanel.tsx index a10dd3c..43ebca4 100644 --- a/frontend/src/business/settings/components/SettingsPanel.tsx +++ b/frontend/src/business/settings/components/SettingsPanel.tsx @@ -1,8 +1,8 @@ import { Link, useLocation } from "react-router-dom"; import { Monitor, CreditCard, Wand2, Clock, Webhook, Rabbit } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn } from "@/lib/utils"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; interface Category { id: string; diff --git a/frontend/src/business/settings/pages/PromptShortcuts.tsx b/frontend/src/business/settings/pages/PromptShortcuts.tsx index 1d521e4..2dc95de 100644 --- a/frontend/src/business/settings/pages/PromptShortcuts.tsx +++ b/frontend/src/business/settings/pages/PromptShortcuts.tsx @@ -8,11 +8,11 @@ import { ChevronRight, RotateCcw, } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn } from "@/lib/utils"; import { useSettings, type PromptShortcut } from "@/settings"; import { SHORTCUT_ICON_KEYS, shortcutIcon } from "@/lib/shortcutIcons"; import { PageHeader } from "@/technical/PageHeader"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; /** A compact text input used across the shortcut editor. */ function TextField({ diff --git a/frontend/src/business/settings/pages/Settings.tsx b/frontend/src/business/settings/pages/Settings.tsx index 1c97217..1f98f69 100644 --- a/frontend/src/business/settings/pages/Settings.tsx +++ b/frontend/src/business/settings/pages/Settings.tsx @@ -25,6 +25,7 @@ import { } from "@/settings"; import { useAvatarSettings } from "@/lib/avatar"; import { subscriptionPaid, billingMonths } from "@/lib/analytics"; +import { SectionStack } from "@/technical/ui/SectionStack"; import { PageHeader } from "@/technical/PageHeader"; const TIMEZONES = [ @@ -377,165 +378,183 @@ export function Settings() { />
- {/* ── Display ─────────────────────────────────────────────────── */} - } - title="Display" - innerRef={(el) => (refs.current.display = el)} - > - - - value={s.theme} - onChange={s.setTheme} - options={[ - { value: "light", label: "Light", icon: }, - { value: "dark", label: "Dark", icon: }, - { value: "system", label: "System", icon: }, - ]} - /> - - - - - value={s.currency} - onChange={s.setCurrency} - options={[ - { value: "USD", label: "$ USD" }, - { value: "EUR", label: "€ EUR" }, - ]} - /> - - - {s.currency === "EUR" && ( - - 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" - /> - - )} - - - - - - - - {fmtCost(12.3456, { currency: s.currency, rate: s.usdToEur })} - - - - - {/* ── Agent avatar ────────────────────────────────────────────── */} - } - title="Agent avatar" - innerRef={(el) => (refs.current.avatar = el)} - > - - - - {/* ── Notification webhooks ───────────────────────────────────── */} - } - title="Notification webhooks" - innerRef={(el) => (refs.current.webhooks = el)} - > - - - - {/* ── Claude subscription ─────────────────────────────────────── */} - } - title="Claude subscription" - innerRef={(el) => (refs.current.subscription = el)} - > - - - 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 }, - ]} - /> - - - {s.subscription.plan !== "none" && ( - <> - -
- - {s.currency === "EUR" ? "€" : "$"} - - setCostFromDisplay(Number(e.target.value) || 0)} - className="w-20 bg-transparent text-right font-mono text-sm tabular-nums outline-none" + {/* Hold a card to rearrange the page, or drop it on the stash to + bench it — Settings is mostly write-once, and a benched card is + one less thing to scroll past. */} + } + title="Display" + innerRef={(el) => (refs.current.display = el)} + > + + + value={s.theme} + onChange={s.setTheme} + options={[ + { value: "light", label: "Light", icon: }, + { value: "dark", label: "Dark", icon: }, + { value: "system", label: "System", icon: }, + ]} /> - /mo -
-
+ - - 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" - /> - - - )} + + + value={s.currency} + onChange={s.setCurrency} + options={[ + { value: "USD", label: "$ USD" }, + { value: "EUR", label: "€ EUR" }, + ]} + /> + - {/* live estimate */} -
- {configured ? ( -
-
- - Estimate + {s.currency === "EUR" && ( + + 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" + /> + + )} + + + + + + + + {fmtCost(12.3456, { currency: s.currency, rate: s.usdToEur })} + + + , + }, + { + key: "avatar", + label: "Agent avatar", + node: } + title="Agent avatar" + innerRef={(el) => (refs.current.avatar = el)} + > + + , + }, + { + key: "webhooks", + label: "Webhooks", + node: } + title="Notification webhooks" + innerRef={(el) => (refs.current.webhooks = el)} + > + + , + }, + { + key: "subscription", + label: "Claude subscription", + node: } + title="Claude subscription" + innerRef={(el) => (refs.current.subscription = el)} + > + + + 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 }, + ]} + /> + + + {s.subscription.plan !== "none" && ( + <> + +
+ + {s.currency === "EUR" ? "€" : "$"} + + setCostFromDisplay(Number(e.target.value) || 0)} + className="w-20 bg-transparent text-right font-mono text-sm tabular-nums outline-none" + /> + /mo +
+
+ + + 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" + /> + + + )} + + {/* live estimate */} +
+ {configured ? ( +
+
+ + Estimate +
+

+ On {PLAN_LABEL[s.subscription.plan]}{" "} + you've paid{" "} + {fmtCost(subscriptionPaid())}{" "} + 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. +

+
+ ) : ( +

+ Pick a plan to estimate how much value your subscription returns versus paying per token. +

+ )}
-

- On {PLAN_LABEL[s.subscription.plan]}{" "} - you've paid{" "} - {fmtCost(subscriptionPaid())}{" "} - 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. -

-
- ) : ( -

- Pick a plan to estimate how much value your subscription returns versus paying per token. -

- )} -
- + , + }, + ]} + />
diff --git a/frontend/src/lib/listOrder.ts b/frontend/src/lib/listOrder.ts index 8ecfa0a..6a31948 100644 --- a/frontend/src/lib/listOrder.ts +++ b/frontend/src/lib/listOrder.ts @@ -54,3 +54,89 @@ export function useListOrder( return [ordered, commit]; } + +/** What {@link useListArrangement} hands straight to ``. */ +export interface ListArrangement { + /** 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 `` and + * the group becomes hold-to-rearrange *and* hold-to-hide: + * + * ```tsx + * const arr = useListArrangement("nav", TABS, tabKey); + * t.label}>… + * ``` + * + * 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( + name: string, + items: T[], + getKey: (item: T) => string, + minSlotted = 0, +): ListArrangement { + 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 }; +} diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index c808e4c..e24ff04 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -144,6 +144,15 @@ export interface SettingsState { * renaming or dropping an entry can never strand a list. */ listOrders: Record; + /** + * 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; /** * The model a spawned session runs on — the `--model` argument carried by the * composer's model select (a CLI alias like `opus`, or a pinned model id like @@ -187,6 +196,8 @@ export interface SettingsState { setAvatar: (patch: Partial) => void; setListOrder: (name: string, order: string[]) => void; resetListOrder: (name: string) => void; + setListStash: (name: string, stash: string[]) => void; + resetListStash: (name: string) => void; addPromptShortcut: () => void; updatePromptShortcut: (id: string, patch: Partial) => void; removePromptShortcut: (id: string) => void; @@ -227,6 +238,7 @@ export const useSettings = create()( subscription: { plan: "none", monthlyCost: 0, startDate: "" }, promptShortcuts: DEFAULT_PROMPT_SHORTCUTS.map((s) => ({ ...s })), listOrders: {}, + listStashes: {}, spawnModel: "", spawnHarness: "claude", spawnThinking: true, @@ -256,6 +268,14 @@ export const useSettings = create()( return { listOrders: rest }; }), + setListStash: (name, stash) => + set((s) => ({ listStashes: { ...s.listStashes, [name]: stash } })), + resetListStash: (name) => + set((s) => { + const { [name]: _dropped, ...rest } = s.listStashes ?? {}; + return { listStashes: rest }; + }), + addPromptShortcut: () => set((s) => ({ promptShortcuts: [ diff --git a/frontend/src/technical/VisibilityPopover.tsx b/frontend/src/technical/VisibilityPopover.tsx index e563dcb..66cbb27 100644 --- a/frontend/src/technical/VisibilityPopover.tsx +++ b/frontend/src/technical/VisibilityPopover.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { Eye, EyeOff, Check } from "lucide-react"; +import { HoldEditable } from "@gabvdl/ui"; import { cn } from "@/lib/utils"; import { VIS_GROUPS, useVisibility, type VisGroup, type VisItem } from "@/lib/visibility"; import { useListOrder } from "@/lib/listOrder"; -import { HoldEditable } from "@/technical/ui/HoldEditable"; /** * Eye button + popover: switches for everything the conversation page renders diff --git a/frontend/src/technical/ui/HeaderActions.tsx b/frontend/src/technical/ui/HeaderActions.tsx new file mode 100644 index 0000000..6cc07d4 --- /dev/null +++ b/frontend/src/technical/ui/HeaderActions.tsx @@ -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 + * }, + * ]} /> + * ``` + */ +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 ( + !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} + + ); +} diff --git a/frontend/src/technical/ui/HoldEditable.tsx b/frontend/src/technical/ui/HoldEditable.tsx deleted file mode 100644 index b358d87..0000000 --- a/frontend/src/technical/ui/HoldEditable.tsx +++ /dev/null @@ -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 `
` per item. - * - * ```tsx - * t.id} - * onReorder={(next) => save(next.map((t) => t.id))} - * className="flex items-stretch justify-between" - * > - * {(tab, { editing }) => } - * - * ``` - */ - -/** 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 { - /** 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(); - const ys = new Set(); - 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 { - const { dom, home, horizontal } = a; - const sizeOf = new Map(); - dom.forEach((k, i) => sizeOf.set(k, horizontal ? home[i].width : home[i].height)); - - const out = new Map(); - 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({ - items, - getKey, - onReorder, - children, - className, - itemClassName, - holdDelay = 1400, - jumpInterval = 800, - disabled = false, - onEditStart, - onEditEnd, -}: HoldEditableProps) { - useHoldEditableStyles(); - - const [arrangement, setArrangement] = useState(null); - const [pressKey, setPressKey] = useState(null); - const [drag, setDrag] = useState(null); - const [drop, setDrop] = useState(null); - - const byKey = useMemo(() => { - const m = new Map(); - 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(null); - /** Wrapper element per key — the measurable "slot". */ - const slots = useRef(new Map()); - /** Order at pickup, so Escape / pointercancel can put everything back. */ - const orderAtPickup = useRef([]); - /** Timestamp of the last drop — see the click-suppression effect below. */ - const droppedAt = useRef(0); - const holdTimer = useRef(null); - const dropTimer = useRef(null); - const dragRef = useRef(null); - dragRef.current = drag; - const arrangementRef = useRef(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(null); - - /** Per-item jump phase, so the group jumps out of sync. Stable per key. */ - const jumpPhase = useRef(new Map()); - 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(); - 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 ( - <> -
- {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 ( -
{ - 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. */} -
- {children( - item, - held - ? { held: false, editing: false, pressing: false, index: domIndex, count: list.length } - : { held: false, editing, pressing, index, count: list.length }, - )} -
- - {/* Where the held item will land: a jumping outline over its slot. */} - {held && ( -
- )} -
- ); - })} -
- - {/* 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( -
- {children(ghostItem, { - held: true, - editing, - pressing: false, - index: ghostKey ? slotIndex(ghostKey) : -1, - count: list.length, - })} -
, - document.body, - )} - - ); -} diff --git a/frontend/src/technical/ui/SectionStack.tsx b/frontend/src/technical/ui/SectionStack.tsx new file mode 100644 index 0000000..2fc8ebd --- /dev/null +++ b/frontend/src/technical/ui/SectionStack.tsx @@ -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 ( + 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} + + ); +}