From 24106acb12e6b8ff75a9141a26e1295586054d1d Mon Sep 17 00:00:00 2001 From: Gabriel Vidal Date: Sun, 9 Aug 2026 17:23:17 +0200 Subject: [PATCH 1/5] refactor(ui): drop the local HoldEditable fork for @gabvdl/ui's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app shipped its own copy of HoldEditable, frozen at the version that predates the stash. @gabvdl/ui's is a strict superset — stash, grid reflow, two-stage holds, per-item itemClassName — so keeping the fork is 767 lines of drift waiting to happen. Point the call sites at the package (0.30.3, which also keeps the stash popover on screen for tall groups) and delete it. Co-Authored-By: Claude Opus 5 --- frontend/package-lock.json | 8 +- frontend/package.json | 2 +- .../composer/components/BottomBar.tsx | 2 +- .../dashboards/components/DashboardsPanel.tsx | 2 +- .../pages/ConversationsAnalytics.tsx | 2 +- .../business/dashboards/pages/Dashboard.tsx | 2 +- .../business/files/components/FilesPanel.tsx | 2 +- .../src/business/files/pages/FilesHome.tsx | 2 +- .../settings/components/SettingsPanel.tsx | 2 +- .../settings/pages/PromptShortcuts.tsx | 2 +- frontend/src/technical/VisibilityPopover.tsx | 2 +- frontend/src/technical/ui/HoldEditable.tsx | 767 ------------------ 12 files changed, 14 insertions(+), 781 deletions(-) delete mode 100644 frontend/src/technical/ui/HoldEditable.tsx 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/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/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/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/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, - )} - - ); -} -- 2.49.1 From e740b202bf725ba69460efe11c4b450e4c007c9a Mon Sep 17 00:00:00 2001 From: Gabriel Vidal Date: Sun, 9 Aug 2026 17:23:28 +0200 Subject: [PATCH 2/5] feat(settings): persist a stash alongside each list's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useListOrder makes a hard-coded list rearrangeable; useListArrangement adds the other half — which of its entries are benched out of the group entirely — and hands both straight to HoldEditable. Stashed ids live in their own `listStashes` bag next to `listOrders`, reconciled the same way: unknown ids are dropped, and an entry the saved state never heard of is slotted rather than benched, so shipping a new one always shows it. The order bag keeps benched items too, so a tag dragged back lands in its old slot. Co-Authored-By: Claude Opus 5 --- frontend/src/lib/listOrder.ts | 86 +++++++++++++++++++++++++++++++++++ frontend/src/settings.ts | 20 ++++++++ 2 files changed, 106 insertions(+) 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: [ -- 2.49.1 From d6d4786dd1ac06dc821730df35914331be167507 Mon Sep 17 00:00:00 2001 From: Gabriel Vidal Date: Sun, 9 Aug 2026 17:23:28 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(ui):=20HeaderActions=20and=20SectionSt?= =?UTF-8?q?ack=20=E2=80=94=20reorderable,=20stashable=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two thin wrappers over HoldEditable + useListArrangement, one per shape the app repeats: a header's icon-button row, and a page's stack of sections. Both floor the group at one slotted item — a group is only reachable through its own members, so an empty one could never be held again to reopen its stash — and HeaderActions adds `pinned` for the controls whose absence would strand the user (a back link, a mobile close, Interrupt on a live session). Co-Authored-By: Claude Opus 5 --- frontend/src/technical/ui/HeaderActions.tsx | 68 +++++++++++++++++++++ frontend/src/technical/ui/SectionStack.tsx | 62 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 frontend/src/technical/ui/HeaderActions.tsx create mode 100644 frontend/src/technical/ui/SectionStack.tsx 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/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} + + ); +} -- 2.49.1 From 6e481ef61ab9498e303a4659dc149f8392791a7d Mon Sep 17 00:00:00 2001 From: Gabriel Vidal Date: Sun, 9 Aug 2026 17:23:39 +0200 Subject: [PATCH 4/5] feat(nav): hold the header icon buttons to rearrange or hide them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every header action row goes through HeaderActions: the conversation header (interrupt, expand-all, visibility, details), the drawer header, and the page headers on Notifications home, All conversations, Notifications and Goals. Hold one for a moment and the row starts jumping; drop a button on the stash popover to bench it, drag its tag back to bring it home. Goals' running-agents badge rides in the row so it can be benched too. The drawer's close button now renders conditionally instead of via `md:hidden` — a display:none slot measures zero, which would leave the drag a dead gap to hit. Co-Authored-By: Claude Opus 5 --- .../conversations/pages/Conversation.tsx | 152 +++++++++++------- .../conversations/pages/ConversationHome.tsx | 88 ++++++---- .../conversations/pages/Conversations.tsx | 68 +++++--- frontend/src/business/goals/pages/Goals.tsx | 53 ++++-- .../business/navigation/components/Drawer.tsx | 58 +++++-- .../notifications/pages/NotificationsHome.tsx | 26 ++- 6 files changed, 299 insertions(+), 146 deletions(-) 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/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 */} -- 2.49.1 From dc458dc11c37f973dbf4bf2808bb6c747f91ac03 Mon Sep 17 00:00:00 2001 From: Gabriel Vidal Date: Sun, 9 Aug 2026 17:23:39 +0200 Subject: [PATCH 5/5] feat(pages): hold a section to rearrange or hide it Project, Service and Settings render their bodies through SectionStack, so the page is arrangeable the same way its header row is: hold a card, drop it on the stash to bench it. A Service page is twelve sections deep and most visits care about two of them; Settings is mostly write-once. Sections a page cannot render (no GOAL.md, no commits) are filtered inside the stack, so a section that comes back later returns to its saved slot. Co-Authored-By: Claude Opus 5 --- .../src/business/projects/pages/Project.tsx | 299 ++++++----- .../src/business/projects/pages/Service.tsx | 497 ++++++++++-------- .../src/business/settings/pages/Settings.tsx | 329 ++++++------ 3 files changed, 605 insertions(+), 520 deletions(-) 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/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. -

- )} -
- + , + }, + ]} + />
-- 2.49.1