Files
ai-agent/frontend/src/lib/spawnDock.ts
Gabriel Vidal 4bcef9d79d feat(composer): swipeable sent card, tap-to-open instead of auto-open, collapsible spawn box
- The optimistic sent card above the spawn box is swipeable left/right
  (useSwipeDismiss from @gabvdl/ui 0.29.0): while queued a swipe un-sends
  (text restored to the composer), after the API fired it just dismisses
  the card — the session keeps running.
- No auto-redirect once the spawned transcript syncs: the card flips to
  'session ready — tap to open' and navigation is the user's tap. A sync
  timeout now surfaces as an error instead of silently dropping the card.
- The error notification is dismissible (X or swipe), and useSpawn gained
  dismiss()/readyId.
- New collapse chevron in the composer toolbar folds the docked spawn box
  into a slim 'Start a new session…' strip (persisted in useSpawnDock);
  collapsing dismisses the send loader. openWith un-collapses.
- @gabvdl/ui ^0.29.0 also keeps the disabled composer opaque (contents dim,
  surface stays solid) — the busy spawn box no longer shows the feed
  through.
2026-08-09 16:44:16 +02:00

107 lines
4.2 KiB
TypeScript

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { clampGeom, seedGeom, type Geom } from "./floating";
/**
* App-level state for the "start a new session" composer's floating panel. Lifted
* out of the home page so the panel can live at the app root ({@link GlobalSpawn})
* and stay open — with its draft intact — while you navigate to any other page.
*
* `floating` is the single source of truth for whether the composer is popped
* out; the home {@link SpawnBox} hides its docked composer while it's on, and the
* global panel renders it instead (exactly one composer is ever mounted).
* Geometry is persisted so the panel reopens where you left it.
*/
interface SpawnDockState {
floating: boolean;
/**
* Whether the docked spawn drawer is swiped down out of view. While hidden the
* bottom navbar's Conversation icon renders filled, and tapping it on home
* brings the drawer back ({@link show}).
*/
hidden: boolean;
/**
* Whether the docked composer is collapsed into a slim "Start a new session…"
* strip. Unlike {@link hidden} the strip stays visible, so expanding it back
* is one tap. Collapsing also dismisses the sent-message card (the collapse
* button wires that through {@link useSpawn}'s `dismiss`).
*/
collapsed: boolean;
geom: Geom;
/**
* A one-shot draft to drop into the composer the next time it mounts / is
* shown. The {@link SpawnComposer} consumes it via {@link consumePrefill} and
* clears it, so it seeds the input exactly once — this is how the Goals
* checklist's Work button hands a ready-made prompt to the composer instead of
* spawning a session outright. Not persisted (transient, per-session).
*/
prefill: string | null;
/** Open the floating panel (seeding a default spot on first float). */
openFloat: () => void;
/** Dock it back down. */
dock: () => void;
/** Swipe the docked drawer down out of view. */
hide: () => void;
/** Bring the docked drawer back up. */
show: () => void;
/** Collapse the docked composer to its slim strip / expand it back. */
setCollapsed: (collapsed: boolean) => void;
/**
* Surface the composer with `text` queued as its next draft, on whichever
* surface this device uses: float it on desktop, un-hide the drawer on mobile.
* The composer picks the text up on its next render via {@link consumePrefill}.
*/
openWith: (text: string, isDesktop: boolean) => void;
/** Take (and clear) the pending prefill draft, or null if none is queued. */
consumePrefill: () => string | null;
setGeom: (g: Geom) => void;
}
export const useSpawnDock = create<SpawnDockState>()(
persist(
(set, get) => ({
floating: false,
hidden: false,
collapsed: false,
geom: { x: 0, y: 0, w: 460, h: 320 },
prefill: null,
openFloat: () => {
const g = get().geom;
// A never-positioned (or off-screen) panel drops bottom-right; otherwise
// reuse the saved spot, re-clamped in case the viewport changed.
const next =
g.x === 0 && g.y === 0 ? seedGeom(g.w, g.h) : clampGeom(g);
set({ floating: true, geom: next });
},
dock: () => set({ floating: false }),
hide: () => set({ hidden: true }),
show: () => set({ hidden: false }),
setCollapsed: (collapsed) => set({ collapsed }),
openWith: (text, isDesktop) => {
// A prefill needs a usable composer — un-collapse alongside un-hiding.
set({ prefill: text, collapsed: false });
// Desktop: pop the floating panel (it hosts the only composer off-home).
// Mobile: the drawer is always mounted at the app root — just un-hide it.
if (isDesktop) get().openFloat();
else set({ hidden: false });
},
consumePrefill: () => {
const p = get().prefill;
if (p != null) set({ prefill: null });
return p;
},
setGeom: (geom) => set({ geom }),
}),
{
name: "ai-agent:spawn-dock",
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({
floating: s.floating,
hidden: s.hidden,
collapsed: s.collapsed,
geom: s.geom,
}),
},
),
);