- 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.
107 lines
4.2 KiB
TypeScript
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,
|
|
}),
|
|
},
|
|
),
|
|
);
|