Files
aiturk-hermes-ide/apps/desktop/src/store/composer-popout.ts
T

288 lines
11 KiB
TypeScript

import { atom, computed, type ReadableAtom } from 'nanostores'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
const POPOUT_STORAGE_KEY = 'hermes.desktop.composerPopout.zones.v1'
const POPOUT_GESTURES_ENABLED_STORAGE_KEY = 'hermes.desktop.composerPopout.gesturesEnabled'
// Pre-zone keys: one flag + one position for the whole window. Read at load to
// seed the first zone the user touches (see `legacySeed`), never written again.
const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
const LEGACY_POSITION_KEY = 'hermes.desktop.composerPopout.position'
const gesturesEnabledAtLoad = storedBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, true)
/** Where the floating composer's bottom-right corner sits, measured as an inset
* from the viewport's bottom/right edges. Anchoring to the bottom-right keeps
* the box visually pinned to its default corner as the window resizes and as
* the box grows upward while typing (the corner stays put, height climbs). */
export interface PopoutPosition {
bottom: number
right: number
}
/** One layout zone's pop-out state. */
export interface PopoutZoneState {
poppedOut: boolean
/** The user's intended placement for this zone, UNCLAMPED — every surface in
* the zone renders `clampPopoutPosition` of it against its own rect. */
position: PopoutPosition
}
// Floating composer width (rem). Shared by the inline style that sets
// --composer-popout-width and the peel-off drag math.
export const POPOUT_WIDTH_REM = 19.5
// Default pop-out placement: tucked into the bottom-right of the thread, clear
// of the window chrome. Matches the brief's "default to the right bottom".
const DEFAULT_POSITION: PopoutPosition = { bottom: 24, right: 24 }
const DEFAULT_ZONE: PopoutZoneState = { poppedOut: false, position: DEFAULT_POSITION }
const isPosition = (value: unknown): value is PopoutPosition => {
const r = value as null | Partial<PopoutPosition>
return typeof r?.bottom === 'number' && typeof r?.right === 'number'
}
/** The pre-zone position, if the user had one. */
function legacyPosition(): PopoutPosition {
try {
const parsed = JSON.parse(storedString(LEGACY_POSITION_KEY) || 'null') as unknown
return isPosition(parsed) ? { bottom: parsed.bottom, right: parsed.right } : DEFAULT_POSITION
} catch {
return DEFAULT_POSITION
}
}
function load(): Record<string, PopoutZoneState> {
const out: Record<string, PopoutZoneState> = {}
try {
const parsed = JSON.parse(storedString(POPOUT_STORAGE_KEY) || 'null') as unknown
for (const [id, value] of Object.entries((parsed as Record<string, unknown>) ?? {})) {
const zone = value as null | Partial<PopoutZoneState>
if (typeof zone?.poppedOut === 'boolean' && isPosition(zone.position)) {
out[id] = { poppedOut: gesturesEnabledAtLoad && zone.poppedOut, position: { ...zone.position } }
}
}
} catch {
// Treat unparseable persisted state as missing.
}
return out
}
/** Every zone's pop-out state, keyed by layout-tree group id.
*
* A GROUP is one stack of tabs, so this is the scope the user experiences:
* tabs in the same zone share a float (switch tabs, the box stays where you
* put it), while a split zone beside them keeps its own — popping out on the
* left doesn't fling a composer out of the right. */
export const $composerPopoutZones = atom<Record<string, PopoutZoneState>>(load())
export const $composerPopoutGesturesEnabled = atom(gesturesEnabledAtLoad)
/** Write-through to storage. Called explicitly — NOT on every store change: a
* drag updates the position once per frame, and serializing every zone to
* localStorage at 60Hz is exactly the IO the drag path was built to avoid. */
const persistZones = () => persistString(POPOUT_STORAGE_KEY, JSON.stringify($composerPopoutZones.get()))
/** Whether the user had a float before zones existed. Seeds a zone's first read
* so an upgrade doesn't silently dock someone who left their composer floating
* — but ONLY until they touch any zone. Once real per-zone state exists, that
* is the truth, and a zone split later starts docked like any other. */
let legacySeed: PopoutZoneState | null =
gesturesEnabledAtLoad && storedString(LEGACY_ENABLED_KEY) === 'true'
? { poppedOut: true, position: legacyPosition() }
: null
const zoneState = (zones: Record<string, PopoutZoneState>, groupId: string): PopoutZoneState =>
zones[groupId] ?? legacySeed ?? DEFAULT_ZONE
// Cached per-zone derived atoms keep useStore subscriptions referentially stable
// (and keep one zone's drag from re-rendering the composers in another).
const zoneCache = new Map<string, ReadableAtom<PopoutZoneState>>()
export function $composerPopoutZone(groupId: string): ReadableAtom<PopoutZoneState> {
let cached = zoneCache.get(groupId)
if (!cached) {
cached = computed($composerPopoutZones, zones => zoneState(zones, groupId))
zoneCache.set(groupId, cached)
}
return cached
}
export const getComposerPopoutZone = (groupId: string): PopoutZoneState =>
zoneState($composerPopoutZones.get(), groupId)
export interface PopoutSize {
height: number
width: number
}
/** Viewport-space rect the floating composer is confined to. Defaults to the
* whole window; pass the thread area so the box can't slide under a pinned
* sidebar or behind the header. */
export interface PopoutBounds {
bottom: number
left: number
right: number
top: number
}
interface SetPositionOptions {
/** Thread-area rect to confine the box to; falls back to the full window. */
area?: PopoutBounds
persist?: boolean
/** Measured box size; falls back to the compact width + a min height so the
* box stays grabbable even when the caller can't measure it. */
size?: PopoutSize
}
// Keep at least this much between the box and every edge of its bounds, so the
// floating composer can never be dragged (or restored) out of reach.
const EDGE_MARGIN = 8
// Height floor used when the real box height is unknown (init / load / peel-off).
export const POPOUT_ESTIMATED_HEIGHT = 56
const MIN_VISIBLE_HEIGHT = POPOUT_ESTIMATED_HEIGHT
const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max(value, lo), Math.max(lo, hi))
const rootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
/** The chat surface a composer belongs to — the element whose rect bounds its
* floating box. Resolved from the composer's OWN surface root, so each mounted
* chat surface (primary, session tile, background tab) gets its own area
* instead of a document-wide first match. */
export const popoutBoundsElement = (composer: Element | null): Element | null =>
(composer?.closest('[data-chat-surface]') ?? document).querySelector('[data-slot="composer-bounds"]')
/** The thread area's viewport rect (excludes a pinned sidebar + the header), or
* undefined before it mounts — callers then fall back to the full window. */
export function readPopoutBounds(composer: Element | null): PopoutBounds | undefined {
const el = popoutBoundsElement(composer)
if (!el) {
return undefined
}
const { bottom, height, left, right, top, width } = el.getBoundingClientRect()
// Pre-layout (mount before first layout) the rect is empty — fall back to the
// window rather than clamping the box into a collapsed area.
return width > 0 && height > 0 ? { bottom, left, right, top } : undefined
}
/**
* Bound the bottom/right inset so the WHOLE box stays inside `area` (the chat
* surface's region, or the window by default) — the corner anchor alone would
* let the box's width/height push it past the opposite edges.
*
* PURE and per-surface: a zone stores the user's INTENT, and each mounted
* composer in it runs that through here against its own rect to get the
* placement it actually renders. Tabs in a zone are keep-alive mounted, so
* clamping into the store instead would have every tab overwrite the others
* with a value bounded by ITS geometry.
*/
export function clampPopoutPosition(
{ bottom, right }: PopoutPosition,
size?: PopoutSize,
area?: PopoutBounds
): PopoutPosition {
const width = size?.width || POPOUT_WIDTH_REM * rootFontSize()
const height = size?.height || MIN_VISIBLE_HEIGHT
const { innerHeight: vh, innerWidth: vw } = window
const a = area ?? { bottom: vh, left: 0, right: vw, top: 0 }
return {
bottom: clampRange(bottom, vh - a.bottom + EDGE_MARGIN, vh - a.top - height - EDGE_MARGIN),
right: clampRange(right, vw - a.right + EDGE_MARGIN, vw - a.left - width - EDGE_MARGIN)
}
}
function patchZone(groupId: string, patch: Partial<PopoutZoneState>) {
const zones = $composerPopoutZones.get()
const stored = zones[groupId]
const next = { ...zoneState(zones, groupId), ...patch }
if (
stored?.poppedOut === next.poppedOut &&
stored.position.bottom === next.position.bottom &&
stored.position.right === next.position.right
) {
return
}
// The user has now expressed per-zone intent; the pre-zone value stops
// standing in for zones they haven't touched.
legacySeed = null
$composerPopoutZones.set({ ...zones, [groupId]: next })
}
export function setComposerPoppedOut(groupId: string, value: boolean) {
patchZone(groupId, { poppedOut: value })
persistZones()
}
export function setComposerPopoutGesturesEnabled(value: boolean) {
$composerPopoutGesturesEnabled.set(value)
persistBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, value)
if (value) {
return
}
// Turning the feature off is an immediate lock-to-dock action for every
// layout zone, not merely a promise to ignore the next gesture. Keep each
// zone's resting position so opting back in restores where the user put it.
const zones = $composerPopoutZones.get()
const docked = Object.fromEntries(
Object.entries(zones).map(([groupId, zone]) => [groupId, { ...zone, poppedOut: false }])
)
legacySeed = null
$composerPopoutZones.set(docked)
persistZones()
// Neutralize the pre-zone singleton migration seed too, otherwise a reload
// with no materialized zones could revive an old floating composer.
persistBoolean(LEGACY_ENABLED_KEY, false)
}
/** Move this zone's box. Used per-frame during a drag, so it only writes the
* in-memory store by default; pass `persist` for the resting position on
* release. Returns the clamped position so callers can sync their live ref. */
export function setComposerPopoutPosition(
groupId: string,
position: PopoutPosition,
{ area, persist, size }: SetPositionOptions = {}
): PopoutPosition {
const next = clampPopoutPosition(position, size, area)
patchZone(groupId, { position: next })
if (persist) {
persistZones()
}
return next
}
/** Drop state for zones that no longer exist, so a long-lived install doesn't
* accumulate an entry per zone the user ever split and closed. */
export function pruneComposerPopoutZones(liveGroupIds: Iterable<string>) {
const live = new Set(liveGroupIds)
const zones = $composerPopoutZones.get()
const next = Object.fromEntries(Object.entries(zones).filter(([id]) => live.has(id)))
if (Object.keys(next).length !== Object.keys(zones).length) {
$composerPopoutZones.set(next)
persistZones()
}
}