@elizaos/ui 2.0.3-beta.3 → 2.0.3-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/App.d.ts.map +1 -1
  2. package/App.js +3 -4
  3. package/app-navigate-view.d.ts.map +1 -1
  4. package/app-navigate-view.js +1 -1
  5. package/cloud/api-keys/ApiKeysView.d.ts +6 -3
  6. package/cloud/api-keys/ApiKeysView.d.ts.map +1 -1
  7. package/cloud/api-keys/ApiKeysView.js +21 -29
  8. package/cloud/api-keys/copy-api-key.d.ts +1 -8
  9. package/cloud/api-keys/copy-api-key.d.ts.map +1 -1
  10. package/cloud/api-keys/copy-api-key.js +1 -10
  11. package/cloud/api-keys/index.d.ts +1 -1
  12. package/cloud/api-keys/index.d.ts.map +1 -1
  13. package/cloud/api-keys/index.js +1 -1
  14. package/cloud/shell/CloudRouterShell.d.ts +12 -0
  15. package/cloud/shell/CloudRouterShell.d.ts.map +1 -1
  16. package/cloud/shell/CloudRouterShell.js +38 -2
  17. package/cloud-ui/components/data-list/api-keys-table.d.ts +1 -2
  18. package/cloud-ui/components/data-list/api-keys-table.d.ts.map +1 -1
  19. package/cloud-ui/components/data-list/api-keys-table.js +3 -9
  20. package/cloud-ui/components/docs/api-route-explorer-client.d.ts.map +1 -1
  21. package/cloud-ui/components/docs/api-route-explorer-client.js +2 -1
  22. package/cloud-ui/components/layout/dashboard-route-page.d.ts.map +1 -1
  23. package/cloud-ui/components/layout/dashboard-route-page.js +3 -1
  24. package/components/chat/widgets/home-widget-card.d.ts.map +1 -1
  25. package/components/chat/widgets/home-widget-card.js +1 -3
  26. package/components/config-ui/config-field.js +1 -1
  27. package/components/pages/Springboard.d.ts.map +1 -1
  28. package/components/pages/Springboard.js +37 -48
  29. package/components/pages/SpringboardSurface.d.ts.map +1 -1
  30. package/components/pages/SpringboardSurface.js +116 -6
  31. package/components/shell/ContinuousChatOverlay.d.ts.map +1 -1
  32. package/components/shell/ContinuousChatOverlay.js +207 -146
  33. package/components/shell/HomeSpringboardSurface.d.ts.map +1 -1
  34. package/components/shell/HomeSpringboardSurface.js +31 -76
  35. package/components/shell/use-pull-gesture.d.ts +6 -0
  36. package/components/shell/use-pull-gesture.d.ts.map +1 -1
  37. package/components/shell/use-pull-gesture.js +66 -27
  38. package/components/shell/useShellController.d.ts.map +1 -1
  39. package/components/shell/useShellController.js +13 -3
  40. package/components/views/ViewTileImage.d.ts +8 -8
  41. package/components/views/ViewTileImage.d.ts.map +1 -1
  42. package/components/views/ViewTileImage.js +38 -9
  43. package/hooks/useHorizontalPager.d.ts +33 -0
  44. package/hooks/useHorizontalPager.d.ts.map +1 -0
  45. package/hooks/useHorizontalPager.js +226 -0
  46. package/hooks/useLayoutShiftMonitor.d.ts +2 -0
  47. package/hooks/useLayoutShiftMonitor.d.ts.map +1 -1
  48. package/hooks/useLayoutShiftMonitor.js +23 -0
  49. package/hooks/view-catalog.d.ts +3 -4
  50. package/hooks/view-catalog.d.ts.map +1 -1
  51. package/hooks/view-catalog.js +4 -6
  52. package/package.json +10 -10
  53. package/state/startup-telemetry.d.ts +6 -0
  54. package/state/startup-telemetry.d.ts.map +1 -1
  55. package/state/startup-telemetry.js +23 -2
  56. package/styles/base.css +6 -6
  57. package/styles/brand-gold.css +6 -5
  58. package/testing/layout-stability.d.ts +11 -3
  59. package/testing/layout-stability.d.ts.map +1 -1
  60. package/testing/layout-stability.js +29 -4
  61. package/testing/run-animation-frames-immediately.d.ts +2 -0
  62. package/testing/run-animation-frames-immediately.d.ts.map +1 -0
  63. package/testing/run-animation-frames-immediately.js +8 -0
  64. package/themes/presets.js +14 -14
  65. package/utils/cloud-agent-base.d.ts.map +1 -1
  66. package/utils/cloud-agent-base.js +6 -0
  67. package/widgets/registry.d.ts.map +1 -1
  68. package/widgets/registry.js +7 -0
  69. package/components/pages/automation-conversations.d.ts +0 -16
  70. package/components/pages/automation-conversations.d.ts.map +0 -1
  71. package/components/pages/automation-conversations.js +0 -204
  72. package/components/pages/automations-missing-creds.d.ts +0 -25
  73. package/components/pages/automations-missing-creds.d.ts.map +0 -1
  74. package/components/pages/automations-missing-creds.js +0 -39
  75. package/components/settings/desktop-workspace-audit-config.d.ts +0 -3
  76. package/components/settings/desktop-workspace-audit-config.d.ts.map +0 -1
  77. package/components/settings/desktop-workspace-audit-config.js +0 -171
  78. package/components/shell/first-run-theme.d.ts +0 -6
  79. package/components/shell/first-run-theme.d.ts.map +0 -1
  80. package/components/shell/first-run-theme.js +0 -31
  81. package/genui/use-json-render-message.d.ts +0 -20
  82. package/genui/use-json-render-message.d.ts.map +0 -1
  83. package/genui/use-json-render-message.js +0 -38
  84. package/state/first-run-restart.d.ts +0 -8
  85. package/state/first-run-restart.d.ts.map +0 -1
  86. package/state/first-run-restart.js +0 -8
@@ -6,8 +6,10 @@ import * as React from "react";
6
6
  import { client } from "../../api/client.js";
7
7
  import { parseSlashDraft, resolveClientShortcutExecution, runSlashExecution, splitLeadingSlashCommand, } from "../../chat/slash-menu.js";
8
8
  import { CHAT_PREFILL_EVENT, TUTORIAL_CHAT_CONTROL_EVENT, } from "../../events/index.js";
9
+ import { LAYOUT_SHIFT_INTENT_ATTR, LAYOUT_SHIFT_INTENT_TRANSIENT, } from "../../hooks/useLayoutShiftMonitor.js";
9
10
  import { Z_SHELL_OVERLAY } from "../../lib/floating-layers.js";
10
11
  import { cn } from "../../lib/utils.js";
12
+ import { goHome, goSpringboard } from "../../state/shell-surface-store.js";
11
13
  import { useViewChatBinding } from "../../state/view-chat-binding.js";
12
14
  import { copyTextToClipboard } from "../../utils/clipboard.js";
13
15
  import { CHAT_UPLOAD_ACCEPT, chatUploadKind, classifyComposerPaste, intakeAttachmentFiles, MAX_CHAT_IMAGES, summarizeDroppedAttachments, } from "../../utils/image-attachment.js";
@@ -44,9 +46,9 @@ const EMPTY_SLASH_CONTROLLER = {
44
46
  * useShellController).
45
47
  *
46
48
  * Layout is a fixed composer at the bottom with a pull-up history SHEET above
47
- * it. At rest the sheet is a slim peek (the grabber + the latest line); pull it
48
- * UP — anywhere on the sheet — or just start typing to spring it open into the
49
- * full transcript; pull the grabber back DOWN, or press Escape, to close.
49
+ * it. At rest the sheet is only the composer + grabber; pull the grabber UP, or
50
+ * just start typing, to spring it open into the full transcript. Pull the
51
+ * grabber back DOWN, or press Escape, to close.
50
52
  * Nothing else dismisses it — clicking or scrolling the view behind does
51
53
  * nothing. The composer never moves; the history slides up over it.
52
54
  *
@@ -224,17 +226,14 @@ function SheetGrabber({ open, onOpen, onClose, binding, glow, opacity, pilled, }
224
226
  }, ...binding, className: cn("appearance-none border-0 bg-transparent text-left",
225
227
  // ABSOLUTELY positioned over the panel top (zero layout height — it
226
228
  // floats slightly on top of the input row, so collapsed height == the
227
- // input bar). The bar sits a touch lower; the BIG invisible `before` hit
228
- // zone reaches UP into the empty space above the panel (3× taller/wider
229
- // than the bar) so it's easy to grab without covering the edge buttons.
229
+ // input bar). Keep the invisible hit target local to the visible handle:
230
+ // it should be forgiving, not register drags far above the bar.
230
231
  // z-20 keeps it above the input row (z-10) so it always wins the drag.
231
- "absolute left-1/2 top-0.5 z-20 -translate-x-1/2 flex cursor-grab touch-none select-none items-center justify-center px-16 py-1 active:cursor-grabbing",
232
- // The hit zone reaches UP into the empty space above the panel (easy to
233
- // grab) and stops at the handle's own bottom it never reaches the
234
- // vertically-centered textarea, so a tap on the composer lands natively
235
- // on the textarea and raises the keyboard (a programmatic focus from the
236
- // handle wouldn't). Pull gestures start from the bar / the upward zone.
237
- "before:absolute before:-inset-x-6 before:-top-16 before:bottom-0 before:content-['']", " "), children: _jsx("span", { "aria-hidden": "true", className: cn(
232
+ "absolute left-1/2 top-0.5 z-20 -translate-x-1/2 flex cursor-grab touch-none select-none items-center justify-center px-16 py-2 active:cursor-grabbing",
233
+ // The hit zone reaches only a small distance above the panel and stops
234
+ // at the handle's own bottom, so the handle does not steal taps intended
235
+ // for the composer or feel like it starts in empty space.
236
+ "before:absolute before:-inset-x-4 before:-top-4 before:bottom-0 before:content-['']", " "), children: _jsx("span", { "aria-hidden": "true", className: cn(
238
237
  // The visible grabber line. Its show/hide is driven by the WRAPPER's
239
238
  // `grabberOpacity` crossfade (fades in over [0.55, 0.95] of the open),
240
239
  // strictly anti-phase with the pill bar so the two are never on screen
@@ -364,7 +363,7 @@ function useDebouncedTurnStatus(status) {
364
363
  function TurnStatusInner({ status, }) {
365
364
  const shown = useDebouncedTurnStatus(status);
366
365
  const speaking = shown?.kind === "speaking";
367
- const label = shown ? turnStatusLabel(shown) : null;
366
+ const label = shown && shown.kind !== "thinking" ? turnStatusLabel(shown) : null;
368
367
  return (_jsxs("span", { className: "inline-flex items-center gap-2", "data-testid": "turn-status-indicator", "data-status-kind": shown?.kind ?? "none", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "flex gap-1.5", "data-testid": "typing-dots", "aria-hidden": "true", children: [0, 1, 2].map((i) => (_jsx("span", { className: cn("h-1.5 w-1.5 animate-pulse rounded-full motion-reduce:animate-none", speaking ? "bg-[rgba(255,190,140,0.9)]" : "bg-white/70"), style: { animationDelay: `${i * 180}ms` } }, i))) }), label ? (_jsx("span", { className: cn("text-[13px] font-medium", speaking ? "text-[rgba(255,200,150,0.95)]" : "text-white/90"), "data-testid": "turn-status-label", children: label })) : null] }));
369
368
  }
370
369
  /**
@@ -403,7 +402,7 @@ function ThreadLineText({ content }) {
403
402
  return content;
404
403
  return (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-bold", "data-testid": "slash-command-token", children: slash.command }), slash.rest] }));
405
404
  }
406
- const ThreadLine = React.memo(function ThreadLine({ message, floating, reduce, onCopy, onOpenSettings, turnStatus, }) {
405
+ const ThreadLine = React.memo(function ThreadLine({ message, floating, reduce, onCopy, onOpenSettings, turnStatus, suppressReasoning, }) {
407
406
  const isUser = message.role === "user";
408
407
  const isAssistant = message.role === "assistant";
409
408
  // Press-and-hold to copy an assistant answer — the only extraction affordance
@@ -481,7 +480,7 @@ const ThreadLine = React.memo(function ThreadLine({ message, floating, reduce, o
481
480
  ? "bg-white/20 text-white"
482
481
  : "bg-white/10 text-white/90"), children: [_jsx("div", { "data-chat-selectable": "true", children: isAssistant &&
483
482
  !message.content.trim() &&
484
- !message.attachments?.length ? (_jsxs(_Fragment, { children: [_jsx(TurnStatusInner, { status: turnStatus ?? null }), message.attachments?.length ? (_jsx(MessageAttachments, { attachments: message.attachments })) : null] })) : isUser ? (_jsxs(_Fragment, { children: [_jsx(ThreadLineText, { content: message.content }), message.attachments?.length ? (_jsx(MessageAttachments, { attachments: message.attachments })) : null] })) : (_jsxs(_Fragment, { children: [_jsx(InlineWidgetText, { content: message.content }), message.attachments?.length ? (_jsx(MessageAttachments, { attachments: message.attachments })) : null, message.secretRequest ? (_jsx("div", { className: "pointer-events-auto", children: _jsx(SensitiveRequestBlock, { request: message.secretRequest }) })) : null, message.reasoning?.trim() ? (_jsx(ThinkingBlock, { reasoning: message.reasoning })) : null] })) }), _jsx(AnimatePresence, { children: copied ? (_jsx(motion.span, { "data-testid": "thread-line-copied", initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0 }, transition: { duration: reduce ? 0 : 0.18 }, className: "pointer-events-none absolute -top-2 right-2 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-medium text-black", children: "Copied" }, "copied")) : null })] }) }));
483
+ !message.attachments?.length ? (_jsxs(_Fragment, { children: [_jsx(TurnStatusInner, { status: turnStatus ?? null }), message.attachments?.length ? (_jsx(MessageAttachments, { attachments: message.attachments })) : null] })) : isUser ? (_jsxs(_Fragment, { children: [_jsx(ThreadLineText, { content: message.content }), message.attachments?.length ? (_jsx(MessageAttachments, { attachments: message.attachments })) : null] })) : (_jsxs(_Fragment, { children: [_jsx(InlineWidgetText, { content: message.content }), message.attachments?.length ? (_jsx(MessageAttachments, { attachments: message.attachments })) : null, message.secretRequest ? (_jsx("div", { className: "pointer-events-auto", children: _jsx(SensitiveRequestBlock, { request: message.secretRequest }) })) : null, !suppressReasoning && message.reasoning?.trim() ? (_jsx(ThinkingBlock, { reasoning: message.reasoning })) : null] })) }), _jsx(AnimatePresence, { children: copied ? (_jsx(motion.span, { "data-testid": "thread-line-copied", initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0 }, transition: { duration: reduce ? 0 : 0.18 }, className: "pointer-events-none absolute -top-2 right-2 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-medium text-black", children: "Copied" }, "copied")) : null })] }) }));
485
484
  });
486
485
  export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash: slashProp, }) {
487
486
  const { messages, phase, responding, turnStatus, send, canSend, recording, startRecording, stopRecording, handsFree, toggleHandsFree, transcriptionMode, toggleTranscriptionMode, stopTranscriptionAndMic, setDictationSink, setTranscriptSessionSink, setComposerHasDraft, transcript, needsAudioUnlock, unlockAudio, openSettings, navigateHome, currentTab, clearConversation, stop, modelStatus, } = controller;
@@ -523,12 +522,12 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
523
522
  const viewChatBinding = useViewChatBinding();
524
523
  // Escape dismisses the slash menu without clearing the draft; typing reopens.
525
524
  const [slashDismissed, setSlashDismissed] = React.useState(false);
526
- // The chat-history sheet: closed (a slim peek + grabber) ↔ open (full
527
- // scrollable history). The ONLY open/close driver — opened by a pull-up drag,
528
- // by focusing the composer, or by sending; closed by a pull-down drag or
529
- // Escape. Never by click-out, scroll, or blur.
525
+ // The chat-history sheet: closed (composer + grabber) ↔ open (full scrollable
526
+ // history). The ONLY open/close driver — opened by a pull-up drag, by focusing
527
+ // the composer, or by sending; closed by a pull-down drag or Escape. Never by
528
+ // click-out, scroll, or blur.
530
529
  // The sheet's vertical position is ONE ordinal — the single source of truth for
531
- // how far the chat is open: `input` (composer-only peek) → `half` (reading
530
+ // how far the chat is open: `input` (composer-only) → `half` (reading
532
531
  // height) → `full` (near-fullscreen). `sheetOpen`/`expanded` are derived
533
532
  // read-only views so the two can never disagree (no impossible "open but not
534
533
  // open" combos). `pilled` sits BELOW input; `maximized` drops the inset at full.
@@ -563,10 +562,6 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
563
562
  // tell a FIRST tap (keyboard up → just dismiss + restore) from a SECOND tap
564
563
  // (keyboard already down → close the chat).
565
564
  const composerFocusedAtPressRef = React.useRef(false);
566
- // Composer focus ⟺ the soft keyboard is up on mobile. This is the reliable
567
- // keyboard signal: Capacitor's resize:"body" shrinks innerHeight too, so a
568
- // visualViewport-derived keyboardInset reads 0 and can't gate the layout.
569
- const [composerFocused, setComposerFocused] = React.useState(false);
570
565
  // The live thread (history) height in px, as a MOTION VALUE — driven directly
571
566
  // by the pointer during a drag and spring-animated to a detent on release.
572
567
  // Keeping it off React state means a drag updates the DOM height every frame
@@ -622,6 +617,11 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
622
617
  window.clearTimeout(delayedNavigationTimerRef.current);
623
618
  delayedNavigationTimerRef.current = null;
624
619
  }
620
+ if (layoutShiftIntentTimerRef.current !== null) {
621
+ window.clearTimeout(layoutShiftIntentTimerRef.current);
622
+ layoutShiftIntentTimerRef.current = null;
623
+ }
624
+ overlayRef.current?.removeAttribute(LAYOUT_SHIFT_INTENT_ATTR);
625
625
  clearPrefillFocusSchedule();
626
626
  }, [stopThreadAnimation, stopOpenProgressAnimation, clearPrefillFocusSchedule]);
627
627
  // Latest `settleDrag` (defined below) exposed to the viewport-resize effect
@@ -629,6 +629,19 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
629
629
  // the morph keeps the pill↔input crossfade from stranding both bars visible.
630
630
  const settleDragRef = React.useRef(null);
631
631
  const draggingRef = React.useRef(false);
632
+ // At rest the collapsed composer should not carry hidden transcript/header
633
+ // DOM. During an upward pull, though, the sheet needs a mounted body so the
634
+ // MotionValue-driven height can follow the finger before the release commits
635
+ // to an open detent. This boolean changes only at gesture boundaries; the
636
+ // per-frame drag still stays outside React.
637
+ const [dragPreviewVisible, setDragPreviewVisible] = React.useState(false);
638
+ const dragPreviewVisibleRef = React.useRef(false);
639
+ const setDragPreviewMounted = React.useCallback((visible) => {
640
+ if (dragPreviewVisibleRef.current === visible)
641
+ return;
642
+ dragPreviewVisibleRef.current = visible;
643
+ setDragPreviewVisible(visible);
644
+ }, []);
632
645
  // Push-to-talk phase (single source of truth) + a label-only mirror.
633
646
  const pttRef = React.useRef({ kind: "idle" });
634
647
  const [pttHolding, setPttHolding] = React.useState(false);
@@ -642,6 +655,20 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
642
655
  const overlayRef = React.useRef(null);
643
656
  const panelRef = React.useRef(null);
644
657
  const threadRef = React.useRef(null);
658
+ const layoutShiftIntentTimerRef = React.useRef(null);
659
+ const markLayoutShiftIntent = React.useCallback(() => {
660
+ const overlay = overlayRef.current;
661
+ if (!overlay || typeof window === "undefined")
662
+ return;
663
+ overlay.setAttribute(LAYOUT_SHIFT_INTENT_ATTR, LAYOUT_SHIFT_INTENT_TRANSIENT);
664
+ if (layoutShiftIntentTimerRef.current !== null) {
665
+ window.clearTimeout(layoutShiftIntentTimerRef.current);
666
+ }
667
+ layoutShiftIntentTimerRef.current = window.setTimeout(() => {
668
+ layoutShiftIntentTimerRef.current = null;
669
+ overlayRef.current?.removeAttribute(LAYOUT_SHIFT_INTENT_ATTR);
670
+ }, 180);
671
+ }, []);
645
672
  // The composer content (textarea + thread). Held so we can imperatively clear
646
673
  // its `inert` (set while pilled) the instant the pill is tapped open, before
647
674
  // React re-renders — iOS only raises the keyboard for a focus() that lands on
@@ -659,6 +686,20 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
659
686
  const visibleMessages = React.useMemo(() => selectVisibleShellMessages(messages, phase), [messages, phase]);
660
687
  const lastId = visibleMessages.at(-1)?.id ?? null;
661
688
  const lastContent = visibleMessages.at(-1)?.content ?? "";
689
+ // biome-ignore lint/correctness/useExhaustiveDependencies: these values are the event keys for transient layout-motion intent.
690
+ React.useEffect(() => {
691
+ markLayoutShiftIntent();
692
+ }, [
693
+ visibleMessages.length,
694
+ lastId,
695
+ lastContent,
696
+ responding,
697
+ turnStatus?.kind,
698
+ turnStatus?.label,
699
+ turnStatus?.actionName,
700
+ turnStatus?.toolName,
701
+ markLayoutShiftIntent,
702
+ ]);
662
703
  // Copy the whole thread as a plain-text transcript from the full-state header
663
704
  // — parity with the desktop ChatView header. Flashes a check on success.
664
705
  const [conversationCopied, setConversationCopied] = React.useState(false);
@@ -724,15 +765,15 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
724
765
  // Render one transcript line; shared by the flat and topic-grouped paths so
725
766
  // the in-flight-turn detection stays identical.
726
767
  const renderThreadLine = React.useCallback((m, index) => {
727
- const isInFlight = index === visibleMessages.length - 1 &&
728
- m.role === "assistant" &&
729
- !m.content.trim();
730
- return (_jsx(ThreadLine, { message: m, floating: true, reduce: reduce, onCopy: handleCopyMessage, onOpenSettings: openSettings, turnStatus: isInFlight ? turnStatus : undefined }, m.id));
768
+ const isLastAssistant = index === visibleMessages.length - 1 && m.role === "assistant";
769
+ const isInFlight = isLastAssistant && !m.content.trim();
770
+ return (_jsx(ThreadLine, { message: m, floating: true, reduce: reduce, onCopy: handleCopyMessage, onOpenSettings: openSettings, turnStatus: isInFlight ? turnStatus : undefined, suppressReasoning: responding && isLastAssistant }, m.id));
731
771
  }, [
732
772
  visibleMessages.length,
733
773
  reduce,
734
774
  handleCopyMessage,
735
775
  openSettings,
776
+ responding,
736
777
  turnStatus,
737
778
  ]);
738
779
  const booting = phase === "booting";
@@ -783,19 +824,18 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
783
824
  if (followRafRef.current != null)
784
825
  cancelAnimationFrame(followRafRef.current);
785
826
  }, []);
786
- // biome-ignore lint/correctness/useExhaustiveDependencies: lastId/lastContent/sheetOpen are the triggers; the body reads refs
827
+ const threadPresented = sheetOpen || dragPreviewVisible;
828
+ // biome-ignore lint/correctness/useExhaustiveDependencies: lastId/lastContent/sheetOpen/dragPreviewVisible are the triggers; the body reads refs
787
829
  React.useLayoutEffect(() => {
788
830
  const el = threadRef.current;
789
831
  if (!el)
790
832
  return;
791
833
  const isNewLine = lastId !== scrollPinnedIdRef.current;
792
834
  scrollPinnedIdRef.current = lastId;
793
- // CLOSED peek: always pin to the bottom so it whispers the LATEST line (the
794
- // one nearest the composer) — even though it can't be user-scrolled, the
795
- // clipped content must show the end of the thread, not the top.
796
835
  if (!sheetOpen) {
797
836
  wasOpenRef.current = false;
798
- el.scrollTop = el.scrollHeight;
837
+ if (dragPreviewVisible)
838
+ el.scrollTop = el.scrollHeight;
799
839
  return;
800
840
  }
801
841
  // OPEN: jump to the bottom on first open; a NEW line re-pins (smooth); while
@@ -841,25 +881,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
841
881
  if (atBottom)
842
882
  node.scrollTop = node.scrollHeight;
843
883
  });
844
- }, [lastId, lastContent, sheetOpen]);
845
- // The closed peek must always whisper the NEWEST line, but closing is an
846
- // animated height collapse: a one-shot scroll set runs before the height
847
- // finishes shrinking, leaving the peek parked mid-thread as clientHeight
848
- // drops. Observe the peek while closed and re-pin to the bottom on every size
849
- // change (animation frames, web-font reflow, viewport resize) until it
850
- // settles. Disconnects the moment the sheet opens.
851
- React.useEffect(() => {
852
- const el = threadRef.current;
853
- if (!el || sheetOpen || typeof ResizeObserver === "undefined")
854
- return;
855
- const pin = () => {
856
- el.scrollTop = el.scrollHeight;
857
- };
858
- pin();
859
- const ro = new ResizeObserver(pin);
860
- ro.observe(el);
861
- return () => ro.disconnect();
862
- }, [sheetOpen]);
884
+ }, [lastId, lastContent, sheetOpen, dragPreviewVisible]);
863
885
  // Send `text` (and optional images) through the normal chat pipeline, clearing
864
886
  // the composer. Shared by the send button, the slash menu (agent commands),
865
887
  // and suggestion taps.
@@ -887,7 +909,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
887
909
  // Sending COMMITS to the open chat: a deliberate message means this is now
888
910
  // an active conversation, so dismissing the keyboard afterwards keeps the
889
911
  // thread open (preFocusCollapsedRef gates that) instead of collapsing the
890
- // whole conversation back to the bare input peek — even when the chat was
912
+ // whole conversation back to the bare input bar — even when the chat was
891
913
  // opened by tapping the collapsed input.
892
914
  preFocusCollapsedRef.current = false;
893
915
  detentHaptic();
@@ -1023,6 +1045,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1023
1045
  stopTranscriptionAndMic,
1024
1046
  ]);
1025
1047
  const hasThread = visibleMessages.length > 0;
1048
+ const hasRevealableThread = hasThread || conversationLoading;
1026
1049
  // Track the VISUAL viewport so the chat sizes to — and sits above — whatever
1027
1050
  // the mobile keyboard leaves visible. `height` shrinks when the keyboard opens
1028
1051
  // (on iOS innerHeight does not, so read visualViewport); `keyboardInset` is how
@@ -1077,28 +1100,27 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1077
1100
  commit();
1078
1101
  });
1079
1102
  };
1080
- // A viewport SIZE change (rotation) must never strand the pill↔input morph
1081
- // mid-crossfade — rotation often cancels the in-flight pointer with no
1082
- // pointerup, leaving the drag orphaned (openProgress frozen mid-range = BOTH
1083
- // the grabber bar and the pill bar visible). Re-settle to a clean 0/1 end so
1084
- // the crossfade always resolves to exactly one bar. (No-op at rest; a live
1085
- // legit drag rotating is rare and settling is the right call there too.)
1086
- // Plain `sync` (no settle) stays on vv `scroll` — that fires constantly while
1087
- // the keyboard animates and must not interrupt an open sheet.
1088
- const syncAndSettle = () => {
1103
+ // A real WINDOW resize (rotation/desktop resize) must never strand the
1104
+ // pill↔input morph mid-crossfade — rotation often cancels the in-flight
1105
+ // pointer with no pointerup, leaving the drag orphaned. Re-settle to a clean
1106
+ // 0/1 end there. `visualViewport.resize`, however, fires continuously during
1107
+ // soft-keyboard animation; settling on those events fights typing, detent
1108
+ // drags, and keyboard open/close. For vv resize/scroll, update measurements
1109
+ // only and let the current sheet state remain authoritative.
1110
+ const syncAndSettleWindow = () => {
1089
1111
  sync();
1090
1112
  settleDragRef.current?.();
1091
1113
  };
1092
- syncAndSettle();
1114
+ syncAndSettleWindow();
1093
1115
  const vv = window.visualViewport;
1094
- window.addEventListener("resize", syncAndSettle);
1095
- vv?.addEventListener("resize", syncAndSettle);
1116
+ window.addEventListener("resize", syncAndSettleWindow);
1117
+ vv?.addEventListener("resize", sync);
1096
1118
  vv?.addEventListener("scroll", sync, { passive: true });
1097
1119
  return () => {
1098
1120
  if (rafId !== 0)
1099
1121
  cancelAnimationFrame(rafId);
1100
- window.removeEventListener("resize", syncAndSettle);
1101
- vv?.removeEventListener("resize", syncAndSettle);
1122
+ window.removeEventListener("resize", syncAndSettleWindow);
1123
+ vv?.removeEventListener("resize", sync);
1102
1124
  vv?.removeEventListener("scroll", sync);
1103
1125
  };
1104
1126
  }, [readViewport]);
@@ -1171,6 +1193,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1171
1193
  const layoutShrink = Math.max(0, baseInnerHeightRef.current - viewport.innerHeight);
1172
1194
  const nativeLift = Math.max(0, nativeKeyboardHeight - layoutShrink);
1173
1195
  const effectiveKeyboardInset = Math.max(keyboardInset, nativeLift);
1196
+ const keyboardLiftActive = effectiveKeyboardInset > 0;
1174
1197
  // FULL-SCREEN derived gate: maximized only takes effect AT the full detent, so
1175
1198
  // a stale flag can never leak into half/collapsed/pill. Drives the edge-to-edge
1176
1199
  // panel styles + a zero top margin.
@@ -1228,9 +1251,10 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1228
1251
  // bug). They show only when the panel actually renders at/over half (or is
1229
1252
  // full-bleed), tracking the finger frame-by-frame; the prev===next guard keeps
1230
1253
  // re-renders to the two threshold crossings.
1231
- const evalHeaderVisible = React.useCallback((h) => !pilled && (fullBleed || h >= halfH - 1), [pilled, fullBleed, halfH]);
1254
+ const evalHeaderVisible = React.useCallback((h) => threadPresented && !pilled && (fullBleed || h >= halfH - 1), [threadPresented, pilled, fullBleed, halfH]);
1232
1255
  const [headerVisible, setHeaderVisible] = React.useState(false);
1233
1256
  useMotionValueEvent(threadHeight, "change", (h) => {
1257
+ markLayoutShiftIntent();
1234
1258
  const next = evalHeaderVisible(h);
1235
1259
  setHeaderVisible((prev) => (prev === next ? prev : next));
1236
1260
  });
@@ -1252,10 +1276,12 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1252
1276
  const scrimVisibility = useTransform(threadHeight, (h) => h > 0 ? "visible" : "hidden");
1253
1277
  const suggestionsOpacity = useTransform(threadHeight, (h) => Math.max(0, 1 - h / Math.max(1, openH * 0.5)));
1254
1278
  const threadFlexBasis = useTransform(threadHeight, (h) => `${h}px`);
1255
- // Corner radius tracks the live height so it can't flash as a tall full-pill
1256
- // mid-pull: a perfect pill at rest (collapsed input, matching the round
1257
- // buttons) relaxing to a calm 24px the instant the thread starts opening.
1258
- const panelRadius = useTransform(threadHeight, [0, 12], [9999, 24], {
1279
+ // Corner radius tracks the live height with real pixel radii. `9999px` works
1280
+ // for a static pill, but while the panel grows the browser keeps reclamping it
1281
+ // against the changing box, so the corners visibly swim before snapping to the
1282
+ // sheet radius. A 32px radius still renders as a capsule for the collapsed
1283
+ // composer, then relaxes gradually into the open sheet.
1284
+ const panelRadius = useTransform(threadHeight, [0, 160], [32, 24], {
1259
1285
  clamp: true,
1260
1286
  });
1261
1287
  // --- Liquid-glass pill → input morph (driven by openProgress) ---------------
@@ -1303,16 +1329,23 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1303
1329
  // is hidden, so the thread viewport would start at the panel's very top —
1304
1330
  // tucking the topmost line under the floating drag handle (a partial bubble
1305
1331
  // pinned beneath the grabber at a small free-rest height). Inset the thread
1306
- // down by the grabber's height in that window only: 0 at the collapsed peek
1332
+ // down by the grabber's height in that window only: 0 in the collapsed state
1307
1333
  // (threadHeight ~0, so the closed input bar stays exactly its own height),
1308
1334
  // ramping to the inset once a thread is actually open, then back to 0 as the
1309
1335
  // header reveals at half+ (it provides the clearance itself).
1310
1336
  const threadGrabberClearance = useTransform(threadHeight, [0, 40, halfH - 64, halfH], [0, 20, 20, 0], { clamp: true });
1337
+ // The glass should lead the gesture; transcript content fades in only after
1338
+ // there is enough vertical space to avoid clipped bubble slivers during the
1339
+ // first few pixels of a pull.
1340
+ const threadContentOpacity = useTransform(threadHeight, [72, 128], [0, 1], {
1341
+ clamp: true,
1342
+ });
1311
1343
  // Sub-threshold release: spring back to the current detent (no state change).
1312
1344
  // Also settles the pill→input morph to its resting end (0 while pilled, 1 once
1313
1345
  // open) so a half-finished pill drag springs cleanly back to the capsule.
1314
1346
  const settleDrag = React.useCallback(() => {
1315
1347
  draggingRef.current = false;
1348
+ setDragPreviewMounted(false);
1316
1349
  const open = pilled ? 0 : 1;
1317
1350
  if (reduce) {
1318
1351
  stopThreadAnimation();
@@ -1334,6 +1367,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1334
1367
  stopOpenProgressAnimation,
1335
1368
  animateThreadHeight,
1336
1369
  animateOpenProgress,
1370
+ setDragPreviewMounted,
1337
1371
  ]);
1338
1372
  // Keep the ref the (earlier-declared) viewport-resize effect calls pointing at
1339
1373
  // the latest settleDrag, so a rotation re-settles with current pilled/baseH.
@@ -1433,13 +1467,22 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1433
1467
  setFreeH(null);
1434
1468
  if (to !== "full")
1435
1469
  setMaximized(false);
1436
- // "collapsed" is the input peek (sheet closed); half/full open the thread.
1470
+ // "collapsed" is the input bar (sheet closed); half/full open the thread.
1437
1471
  setMode(to === "collapsed" ? "input" : to);
1438
1472
  // Stepping all the way down closes the keyboard (the chat is dismissed).
1439
1473
  if (to === "collapsed")
1440
1474
  inputRef.current?.blur();
1441
1475
  detentHaptic();
1442
1476
  }, [stopThreadAnimation]);
1477
+ const openFromGrabber = React.useCallback(() => {
1478
+ if (hasRevealableThread) {
1479
+ preFocusCollapsedRef.current = false;
1480
+ focusThreadRef.current = true;
1481
+ goToDetent("half");
1482
+ return;
1483
+ }
1484
+ inputRef.current?.focus();
1485
+ }, [goToDetent, hasRevealableThread]);
1443
1486
  // Collapsing always drops input focus, so the mobile keyboard goes away the
1444
1487
  // moment the chat is dismissed (pull-down, Escape, or click-out) — the chat is
1445
1488
  // no longer "focused". Blurring (rather than the old refocus dance) also means
@@ -1501,13 +1544,13 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1501
1544
  // handle) can return to that prior resting state. Clears any free-rest so the
1502
1545
  // height matches the detent (no stale freeH pinning it below half).
1503
1546
  const expand = React.useCallback(() => {
1504
- if (!hasThread)
1547
+ if (!hasRevealableThread)
1505
1548
  return;
1506
1549
  preFocusCollapsedRef.current = !sheetOpen;
1507
1550
  setFreeH(null);
1508
1551
  // Open to at least HALF; if already at half/full, keep the taller mode.
1509
1552
  setMode((m) => (m === "half" || m === "full" ? m : "half"));
1510
- }, [hasThread, sheetOpen]);
1553
+ }, [hasRevealableThread, sheetOpen]);
1511
1554
  // Interactive tour control: the tutorial drives the chat into a clean, known
1512
1555
  // state at the start of each frame (so the spotlight always lands on the right
1513
1556
  // control) and pre-fills the composer for the guided "ask to navigate" demo.
@@ -1742,15 +1785,16 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1742
1785
  // this (focus may be gone by the time the click fires) to tell a first
1743
1786
  // "dismiss the keyboard" tap from a second "close the chat" tap.
1744
1787
  composerFocusedAtPressRef.current = focused;
1745
- // Keyboard already down outside taps do nothing here (the chat only
1746
- // closes via a pull-down, the scrim, or Escape).
1788
+ // Keyboard already down -> outside taps do nothing here; the grabber,
1789
+ // scrim, Escape key, and pull-down gesture own disclosure/collapse.
1747
1790
  if (!focused)
1748
1791
  return;
1749
1792
  const target = event.target;
1750
1793
  if (target && panelRef.current?.contains(target))
1751
1794
  return;
1752
- // Leave a tap on the GRABBER to onTap, which both drops the keyboard AND
1753
- // returns to the pre-focus resting state blurring here would preempt it.
1795
+ // Leave a tap on the GRABBER to the gesture onTap; blurring here would
1796
+ // preempt the disclosure toggle and make press-time focus impossible to
1797
+ // distinguish from click-time focus.
1754
1798
  if (target instanceof Element &&
1755
1799
  target.closest('[data-testid="chat-sheet-grabber"]')) {
1756
1800
  return;
@@ -1797,13 +1841,13 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1797
1841
  const openFromPill = React.useCallback(() => {
1798
1842
  draggingRef.current = false;
1799
1843
  // A pill tap OPENS the chat. With a conversation to show, go straight to the
1800
- // HALF detent — a tap reveals the thread exactly like a flick-up, so a SINGLE
1801
- // tap always opens the chat (never the old "tap lands on a bare input bar,
1802
- // tap again to actually open" two-step). Mark it deliberately open so
1844
+ // HALF detent — a tap reveals the thread/loader exactly like a flick-up, so a
1845
+ // SINGLE tap always opens the chat (never the old "tap lands on a bare input
1846
+ // bar, tap again to actually open" two-step). Mark it deliberately open so
1803
1847
  // dismissing the keyboard then KEEPS it at half (preFocusCollapsedRef gates
1804
1848
  // that). With no thread yet, there's nothing to open into — just form the
1805
1849
  // bare input bar, and treat a later keyboard dismiss as a re-collapse.
1806
- if (hasThread) {
1850
+ if (hasRevealableThread) {
1807
1851
  goToDetent("half");
1808
1852
  preFocusCollapsedRef.current = false;
1809
1853
  }
@@ -1834,7 +1878,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1834
1878
  }, [
1835
1879
  openProgress,
1836
1880
  reduce,
1837
- hasThread,
1881
+ hasRevealableThread,
1838
1882
  goToDetent,
1839
1883
  stopOpenProgressAnimation,
1840
1884
  animateOpenProgress,
@@ -1859,6 +1903,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1859
1903
  const up = Math.max(0, offset);
1860
1904
  openProgress.set(Math.min(1, up / PILL_OPEN_DISTANCE));
1861
1905
  const excess = up - PILL_OPEN_DISTANCE;
1906
+ setDragPreviewMounted(excess > 0 && hasRevealableThread);
1862
1907
  threadHeight.set(excess > 0 ? clampHeight(excess) : 0);
1863
1908
  return;
1864
1909
  }
@@ -1869,11 +1914,15 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1869
1914
  // on release (the dead, unresponsive collapse gesture). The thread stays at
1870
1915
  // 0 (nothing to size below the input).
1871
1916
  if (!sheetOpen && offset < 0) {
1917
+ setDragPreviewMounted(false);
1872
1918
  const down = -offset;
1873
1919
  openProgress.set(Math.max(0, 1 - down / PILL_OPEN_DISTANCE));
1874
1920
  threadHeight.set(0);
1875
1921
  return;
1876
1922
  }
1923
+ if (!sheetOpen) {
1924
+ setDragPreviewMounted(offset > 0 && hasRevealableThread);
1925
+ }
1877
1926
  // Pin the dead direction at each end so the panel feels held: collapsed →
1878
1927
  // only upward (positive); full → only downward (negative); half → both.
1879
1928
  const off = !sheetOpen
@@ -1884,6 +1933,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1884
1933
  threadHeight.set(clampHeight(baseH + off));
1885
1934
  }, [
1886
1935
  pilled,
1936
+ hasRevealableThread,
1887
1937
  sheetOpen,
1888
1938
  expanded,
1889
1939
  baseH,
@@ -1892,20 +1942,35 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1892
1942
  openProgress,
1893
1943
  stopThreadAnimation,
1894
1944
  stopOpenProgressAnimation,
1945
+ setDragPreviewMounted,
1895
1946
  ]);
1896
1947
  const pullBinding = usePullGesture({
1897
1948
  onDrag: onDragOffset,
1898
- // Pulls STEP one detent at a time (peek→half→full and back) rather than
1899
- // jumping straight to the ends — the iOS sheet feel. The inline closures are
1900
- // rebuilt every render, so they always read the current detent.
1949
+ onDragReset: settleDrag,
1950
+ swipeEnabled: !sheetOpen,
1951
+ onSwipeLeft: () => {
1952
+ settleDrag();
1953
+ if (!sheetOpen)
1954
+ goSpringboard();
1955
+ },
1956
+ onSwipeRight: () => {
1957
+ settleDrag();
1958
+ if (!sheetOpen)
1959
+ goHome();
1960
+ },
1961
+ // Flicks step one detent; released drags from the collapsed input honor the
1962
+ // live height so a long pull can land full instead of snapping back to half.
1963
+ // The inline closures are rebuilt every render, so they always read the
1964
+ // current detent.
1901
1965
  onPullUp: () => {
1966
+ setDragPreviewMounted(false);
1902
1967
  if (pilled) {
1903
1968
  // PILL → INPUT, or straight into the chat when there's history: a flick
1904
1969
  // up opens. Mirror the slow-drag path so a flick and a slow drag BOTH
1905
1970
  // reach the chat (no hard stop at the bare input). Releasing draggingRef
1906
1971
  // first lets the pilled→openProgress effect spring the morph 0→1.
1907
1972
  draggingRef.current = false;
1908
- if (hasThread) {
1973
+ if (hasRevealableThread) {
1909
1974
  focusThreadRef.current = true;
1910
1975
  goToDetent("half");
1911
1976
  }
@@ -1923,9 +1988,15 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1923
1988
  return;
1924
1989
  }
1925
1990
  if (!sheetOpen) {
1926
- if (!hasThread)
1991
+ if (!hasRevealableThread)
1927
1992
  return settleDrag();
1928
- goToDetent("half");
1993
+ const releasedH = Math.max(0, Math.min(threadHeight.get(), panelMaxH));
1994
+ if (releasedH >= halfH + SHEET_DETENT_MAGNET) {
1995
+ goToDetent("full");
1996
+ }
1997
+ else {
1998
+ goToDetent("half");
1999
+ }
1929
2000
  focusThreadRef.current = true;
1930
2001
  }
1931
2002
  else if (!expanded) {
@@ -1937,6 +2008,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1937
2008
  }
1938
2009
  },
1939
2010
  onPullDown: () => {
2011
+ setDragPreviewMounted(false);
1940
2012
  if (pilled)
1941
2013
  return settleDrag(); // already the lowest detent
1942
2014
  // Step down ONE detent based on the EFFECTIVE height (so a free-rest above
@@ -1955,39 +2027,31 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
1955
2027
  setMode("pill");
1956
2028
  setMaximized(false);
1957
2029
  draggingRef.current = false;
2030
+ setDragPreviewMounted(false);
1958
2031
  inputRef.current?.blur();
1959
2032
  detentHaptic();
1960
2033
  }
1961
2034
  },
1962
2035
  // A tap (no drag) on the handle. A tap on the PILL brings the input back.
1963
- // When OPEN, the handle is the bar ABOVE the thread, so tapping it with the
1964
- // keyboard up dismisses it and returns to the pre-focus resting state.
1965
- // When COLLAPSED the handle's hit zone OVERLAPS the composer, so a tap there
1966
- // is just "focus to type" — it must only focus the input, never dismiss or
1967
- // collapse (the native focus already raised the keyboard, and the tap pierces
1968
- // through to the input). Tapping OUTSIDE the panel is what drops the keyboard.
2036
+ // When OPEN, the grabber acts as a disclosure toggle: tap once to close.
2037
+ // When COLLAPSED, tap opens the thread or its loader; thread-less chats focus
2038
+ // the composer because there is nothing above the input to reveal.
1969
2039
  onTap: () => {
1970
2040
  if (pilled) {
1971
2041
  openFromPill();
1972
2042
  return;
1973
2043
  }
1974
2044
  if (sheetOpen) {
1975
- const composerFocused = typeof document !== "undefined" &&
1976
- document.activeElement === inputRef.current;
1977
- // Keyboard up → drop it and return to the pre-focus resting state (an
1978
- // already-open sheet stays open; an auto-opened one re-collapses).
1979
- // Keyboard down → the grabber is just the open chat's top bar; a tap
1980
- // there does nothing (collapse is a pull-down / Escape / scrim tap).
1981
- if (composerFocused)
1982
- dismissKeyboardToPriorState();
2045
+ collapse();
1983
2046
  return;
1984
2047
  }
1985
- inputRef.current?.focus();
2048
+ openFromGrabber();
1986
2049
  },
1987
2050
  // A deliberate (slow) drag: REST exactly where released instead of snapping
1988
2051
  // to a detent — drag the sheet to any size and it stays.
1989
2052
  onSettleFree: (direction) => {
1990
2053
  draggingRef.current = false;
2054
+ setDragPreviewMounted(false);
1991
2055
  if (pilled) {
1992
2056
  // From the pill: a slow drag under the halfway-open mark (openProgress
1993
2057
  // < 0.5) springs back to the capsule; past it we commit to LEAVING the
@@ -2007,15 +2071,21 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2007
2071
  // Leaving the pill: fall through to the magnetism below, which sets the
2008
2072
  // mode (input / half / full) from where the drag was released — so pill →
2009
2073
  // input → chat reads as one continuum.
2010
- if (hasThread)
2074
+ if (hasRevealableThread)
2011
2075
  focusThreadRef.current = true;
2012
2076
  }
2013
2077
  // From the collapsed input, a downward drag has nothing to "size" below
2014
- // it collapse straight to the pill (matches the flick-down path).
2078
+ // it. Require the input→pill morph to cross halfway before committing;
2079
+ // small thumb drift should spring back to the input, not collapse the chat.
2015
2080
  if (!sheetOpen && direction === "down") {
2016
- setMode("pill");
2017
- inputRef.current?.blur();
2018
- detentHaptic();
2081
+ if (openProgress.get() <= 0.5) {
2082
+ setMode("pill");
2083
+ inputRef.current?.blur();
2084
+ detentHaptic();
2085
+ }
2086
+ else {
2087
+ settleDrag();
2088
+ }
2019
2089
  return;
2020
2090
  }
2021
2091
  const h = Math.max(0, Math.min(threadHeight.get(), panelMaxH));
@@ -2025,7 +2095,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2025
2095
  // between them keep the free-drag rest height. goToDetent commits the
2026
2096
  // honest flags so data-detent + the maximize header match the height.
2027
2097
  if (h <= SHEET_DETENT_MAGNET) {
2028
- // Near the bottom → collapse to the input peek.
2098
+ // Near the bottom → collapse to the input bar.
2029
2099
  closeSheet();
2030
2100
  return;
2031
2101
  }
@@ -2050,12 +2120,11 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2050
2120
  // Full-bleed (maximized) removes the side inset so the chat is edge-to-edge.
2051
2121
  fullBleed ? "px-0" : "px-3 sm:px-4"),
2052
2122
  // Lift the whole overlay above the on-screen keyboard (`bottom`); padding
2053
- // below the composer is conditional: when the composer is FOCUSED (keyboard
2054
- // up), only a small gap (0.75rem, matching the side margin) sits between the
2055
- // composer and the keyboard the home-gesture clearance isn't needed
2056
- // because the keyboard covers it. At rest, clear the home-gesture zone (max
2057
- // safe-area / android inset) plus a hair, keeping the chat low without
2058
- // touching that zone.
2123
+ // below the composer is conditional on an actual keyboard lift, not focus
2124
+ // alone. With the keyboard up, only a small gap (0.75rem, matching the side
2125
+ // margin) sits between composer and keyboard. At rest, clear the
2126
+ // home-gesture zone (max safe-area / android inset) plus a hair, keeping the
2127
+ // chat low without touching that zone.
2059
2128
  style: {
2060
2129
  zIndex: Z_SHELL_OVERLAY,
2061
2130
  bottom: effectiveKeyboardInset,
@@ -2066,7 +2135,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2066
2135
  // chat lifted off the gesture zone as before.
2067
2136
  paddingBottom: fullBleed
2068
2137
  ? 0
2069
- : composerFocused
2138
+ : keyboardLiftActive
2070
2139
  ? "0.75rem"
2071
2140
  : "calc(var(--eliza-mobile-nav-offset, 0px) + max(var(--safe-area-bottom, 0px), var(--android-gesture-inset-bottom, 0px)) + 0.25rem)",
2072
2141
  }, "data-testid": "continuous-chat-overlay", "data-open": sheetOpen ? "true" : undefined, children: [_jsx(motion.div, { "aria-hidden": "true", "data-testid": "chat-sheet-backdrop", "data-active": sheetOpen ? "true" : "false", onClick: sheetOpen
@@ -2090,12 +2159,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2090
2159
  pointerEvents: sheetOpen ? "auto" : "none",
2091
2160
  } }), recording && transcript ? (_jsxs("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", className: cn("pointer-events-none relative mb-2 w-full max-w-3xl text-center text-sm italic text-white/85", FLOAT_SHADOW), children: [transcript, _jsx("span", { "aria-hidden": "true", children: "\u2026" })] })) : null, needsAudioUnlock ? (_jsx("div", { role: "status", "aria-live": "polite", className: "pointer-events-none relative mb-2 flex w-full justify-center", children: _jsxs("button", { type: "button", onClick: unlockAudio, "data-testid": "overlay-voice-audio-unlock", className: cn("pointer-events-auto inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors", "border-warn/40 bg-warn/15 text-warn hover:bg-warn/25", " ", FLOAT_SHADOW), children: [_jsx(Glyph, { d: SPEAKER_MUTED_GLYPH }), _jsx("span", { children: "Tap to enable sound" })] }) })) : null, modelStatus.kind === "downloading" || modelStatus.kind === "loading" ? (_jsx("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", "data-testid": "overlay-model-download-status", className: "pointer-events-none relative mb-2 flex w-full justify-center", children: _jsxs("span", { className: cn("inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-sm font-medium text-white/85", FLOAT_SHADOW), children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin text-[#FF5800]" }), modelStatus.kind === "downloading" ? (_jsxs("span", { children: ["Downloading ", modelStatus.modelName ?? "local model", typeof modelStatus.percent === "number"
2092
2161
  ? ` — ${Math.round(modelStatus.percent)}%`
2093
- : "…"] })) : (_jsxs("span", { children: ["Loading ", modelStatus.modelName ?? "local model", "\u2026"] }))] }) })) : null, suggestionsVisible ? (_jsx(motion.fieldset, { "aria-label": "Suggested prompts", className: cn("pointer-events-auto relative m-0 mb-2 flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 border-0 p-0"), style: { order: -1, opacity: suggestionsOpacity }, "data-testid": "chat-suggestions", children: suggestions.map((s, i) => (_jsx("button", { type: "button", "data-testid": `chat-suggestion-${i}`, "aria-label": s, onClick: () => pickSuggestion(s), className: cn("max-w-full truncate rounded-full border border-white/15 bg-black/40 px-3 py-1.5", "text-[12px] text-white/80 transition-colors", "hover:border-white/30 hover:bg-white/15 hover:text-white", " "), children: s }, s))) })) : null, _jsxs("div", { className: cn("pointer-events-none relative flex w-full flex-col items-center", fullBleed ? "max-w-none" : "max-w-3xl"), children: [!fullBleed ? (_jsx(SheetGrabber, { open: sheetOpen, onOpen: () => {
2094
- if (!hasThread)
2095
- return;
2096
- goToDetent("half");
2097
- focusThreadRef.current = true;
2098
- }, onClose: collapse, binding: pullBinding, glow: listening || responding, opacity: grabberOpacity, pilled: pilled })) : null, _jsxs(motion.fieldset, { ref: panelRef, "aria-label": "Chat composer", "data-testid": "chat-sheet", "data-variant": sheetOpen ? "open" : "closed", "data-detent": pilled
2162
+ : "…"] })) : (_jsxs("span", { children: ["Loading ", modelStatus.modelName ?? "local model", "\u2026"] }))] }) })) : null, suggestionsVisible ? (_jsx(motion.fieldset, { "aria-label": "Suggested prompts", className: cn("pointer-events-auto relative m-0 mb-2 flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 border-0 p-0"), style: { order: -1, opacity: suggestionsOpacity }, "data-testid": "chat-suggestions", children: suggestions.map((s, i) => (_jsx("button", { type: "button", "data-testid": `chat-suggestion-${i}`, "aria-label": s, onClick: () => pickSuggestion(s), className: cn("max-w-full truncate rounded-full border border-white/15 bg-black/40 px-3 py-1.5", "text-[12px] text-white/80 transition-colors", "hover:border-white/30 hover:bg-white/15 hover:text-white", " "), children: s }, s))) })) : null, _jsxs("div", { className: cn("pointer-events-none relative flex w-full flex-col items-center", fullBleed ? "max-w-none" : "max-w-3xl"), children: [!fullBleed ? (_jsx(SheetGrabber, { open: sheetOpen, onOpen: openFromGrabber, onClose: collapse, binding: pullBinding, glow: listening || responding, opacity: grabberOpacity, pilled: pilled })) : null, _jsxs(motion.fieldset, { ref: panelRef, "aria-label": "Chat composer", "data-testid": "chat-sheet", "data-variant": sheetOpen ? "open" : "closed", "data-detent": pilled
2099
2163
  ? "pill"
2100
2164
  : !sheetOpen
2101
2165
  ? "collapsed"
@@ -2105,7 +2169,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2105
2169
  : "half"
2106
2170
  : expanded
2107
2171
  ? "full"
2108
- : "half", "data-maximized": fullBleed ? "true" : undefined, "data-revealed": sheetOpen ? "true" : "false", "data-chat-state": chatState, "data-header-shown": headerVisible ? "true" : "false",
2172
+ : "half", "data-maximized": fullBleed ? "true" : undefined, "data-revealed": threadPresented ? "true" : "false", "data-chat-state": chatState, "data-header-shown": headerVisible ? "true" : "false",
2109
2173
  // ONE persistent element across pill ↔ input ↔ chat (never remounts —
2110
2174
  // that pop was the core jank). It's a transparent scale/position
2111
2175
  // container; the liquid glass lives in an inner layer faded by
@@ -2152,19 +2216,17 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2152
2216
  opacity: glassOpacity,
2153
2217
  pointerEvents: pilled ? "none" : "auto",
2154
2218
  borderRadius: fullBleed ? 0 : panelRadius,
2155
- }, children: [sheetOpen ? (_jsxs(_Fragment, { children: [_jsx(SwipeEdgeHint, { side: "left", active: swipeDx < 0 && conversationNav.hasPrev, amount: -swipeDx }), _jsx(SwipeEdgeHint, { side: "right", active: swipeDx > 0 && conversationNav.hasNext, amount: swipeDx })] })) : null, _jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-x-0 top-0 z-0 h-20 bg-gradient-to-b from-white/[0.07] to-transparent" }), !pilled ? (_jsxs(motion.div
2156
- // Always mounted (when not pilled) so it can FADE + LERP its
2157
- // space open/closed with the live height instead of popping in
2158
- // on a mount. `headerVisible` (live-height boolean) gates
2159
- // interactivity + the a11y tree so the faded/collapsed header
2160
- // can't be clicked or read.
2219
+ }, children: [sheetOpen ? (_jsxs(_Fragment, { children: [_jsx(SwipeEdgeHint, { side: "left", active: swipeDx < 0 && conversationNav.hasPrev, amount: -swipeDx }), _jsx(SwipeEdgeHint, { side: "right", active: swipeDx > 0 && conversationNav.hasNext, amount: swipeDx })] })) : null, _jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-x-0 top-0 z-0 h-20 bg-gradient-to-b from-white/[0.07] to-transparent" }), threadPresented ? (_jsxs(motion.div
2220
+ // Mounted while the sheet is open, or while an upward drag is
2221
+ // previewing the sheet before release. It can FADE + LERP its
2222
+ // space as the live height crosses the header threshold.
2223
+ // `headerVisible` gates interactivity + the a11y tree.
2161
2224
  , {
2162
- // Always mounted (when not pilled) so it can FADE + LERP its
2163
- // space open/closed with the live height instead of popping in
2164
- // on a mount. `headerVisible` (live-height boolean) gates
2165
- // interactivity + the a11y tree so the faded/collapsed header
2166
- // can't be clicked or read.
2167
- inert: !headerVisible || undefined, style: {
2225
+ // Mounted while the sheet is open, or while an upward drag is
2226
+ // previewing the sheet before release. It can FADE + LERP its
2227
+ // space as the live height crosses the header threshold.
2228
+ // `headerVisible` gates interactivity + the a11y tree.
2229
+ inert: !sheetOpen || !headerVisible || undefined, style: {
2168
2230
  // Full-bleed is always fully open: show the header at full
2169
2231
  // opacity and UNCAP its height. The reveal lerp tops out at
2170
2232
  // 100px, but the safe-area top padding (status-bar height +
@@ -2185,11 +2247,11 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2185
2247
  : headerPadTop,
2186
2248
  }, className: cn("relative z-10 flex shrink-0 items-center justify-between gap-1.5 overflow-hidden px-3"), children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(HeaderButton, { icon: maximized ? Minimize2 : Maximize2, label: maximized ? "exit full screen" : "full screen", active: maximized, onClick: toggleMaximize, testId: "chat-full-maximize" }), hasThread ? (_jsx(HeaderButton, { icon: conversationCopied ? Check : Copy, label: conversationCopied
2187
2249
  ? "conversation copied"
2188
- : "copy conversation", onClick: handleCopyConversation, testId: "chat-full-copy-conversation" })) : null, _jsx(HeaderButton, { icon: RotateCcw, label: "clear conversation", onClick: () => clearConversation(), testId: "chat-full-clear" })] }), transcriptionMode ? (_jsx("div", { "data-testid": "chat-transcribing-badge", className: "pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-full bg-[var(--brand-orange,#ff6a00)]/15 px-2.5 py-0.5 text-[11px] font-medium text-[var(--brand-orange,#ff6a00)]", children: "Transcribing \u2014 say \u201Cexit transcription mode\u201D to stop" })) : null, _jsx("div", { className: "flex items-center gap-1.5", children: _jsx(HeaderButton, { icon: LayoutGrid, label: "springboard", onClick: () => navigateAndClose(() => navigateHome?.()), testId: "chat-full-springboard" }) })] })) : null, hasThread || sheetOpen ? (_jsx(motion.div, { "data-testid": "chat-thread", className: cn("relative z-10 min-h-0 w-full shrink grow-0 overflow-hidden",
2250
+ : "copy conversation", onClick: handleCopyConversation, testId: "chat-full-copy-conversation" })) : null, _jsx(HeaderButton, { icon: RotateCcw, label: "clear conversation", onClick: () => clearConversation(), testId: "chat-full-clear" })] }), transcriptionMode ? (_jsx("div", { "data-testid": "chat-transcribing-badge", className: "pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-full bg-[var(--brand-orange,#ff6a00)]/15 px-2.5 py-0.5 text-[11px] font-medium text-[var(--brand-orange,#ff6a00)]", children: "Transcribing \u2014 say \u201Cexit transcription mode\u201D to stop" })) : null, _jsx("div", { className: "flex items-center gap-1.5", children: _jsx(HeaderButton, { icon: LayoutGrid, label: "springboard", onClick: () => navigateAndClose(() => navigateHome?.()), testId: "chat-full-springboard" }) })] })) : null, threadPresented ? (_jsx(motion.div, { "data-testid": "chat-thread", className: cn("relative z-10 min-h-0 w-full shrink grow-0 overflow-hidden",
2189
2251
  // When open, fade the top edge into the glass so the topmost
2190
2252
  // message dissolves under the drag handle instead of butting
2191
2253
  // against it.
2192
- sheetOpen &&
2254
+ threadPresented &&
2193
2255
  "[mask-image:linear-gradient(to_bottom,transparent_0,#000_34px)] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0,#000_34px)]"),
2194
2256
  // Flex-basis IS the motion value (px string) — set 1:1 during a drag,
2195
2257
  // spring-animated to a detent on release; no `animate`/`transition`,
@@ -2201,12 +2263,12 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2201
2263
  style: {
2202
2264
  flexBasis: threadFlexBasis,
2203
2265
  paddingTop: threadGrabberClearance,
2204
- }, children: _jsxs("div", { id: "continuous-thread", ref: threadRef, role: "log", "aria-label": "conversation history", "aria-live": "polite", "aria-hidden": !sheetOpen ? true : undefined, tabIndex: sheetOpen ? 0 : -1, onKeyDown: (e) => {
2266
+ }, children: _jsxs(motion.div, { id: "continuous-thread", ref: threadRef, role: "log", "aria-label": "conversation history", "aria-live": "polite", "aria-hidden": !sheetOpen ? true : undefined, tabIndex: sheetOpen ? 0 : -1, onKeyDown: (e) => {
2205
2267
  if (e.key === "Escape") {
2206
2268
  e.preventDefault();
2207
2269
  collapse();
2208
2270
  }
2209
- }, ...(sheetOpen ? conversationSwipe : {}), className: "relative flex h-full w-full touch-pan-y flex-col overflow-y-auto px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", children: [visibleMessages.length === 0 && conversationLoading ? (_jsx("div", { "data-testid": "chat-thread-loading", className: "pointer-events-none absolute inset-0 grid place-items-center", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-[#FF5800]" }) })) : null, hasTopics ? (_jsx(TopicChipsBar, { topics: channelTopics, onSelectTopic: scrollToTopic, className: "sticky top-0 z-[2] -mx-5 mb-1 bg-gradient-to-b from-black/40 to-transparent px-5" })) : null, _jsxs("div", { className: "mt-auto flex flex-col pb-3 pt-1", children: [hasTopics
2271
+ }, ...(sheetOpen ? conversationSwipe : {}), className: "relative flex h-full w-full touch-pan-y flex-col overflow-y-auto px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", style: { opacity: threadContentOpacity }, children: [visibleMessages.length === 0 && conversationLoading ? (_jsx("div", { "data-testid": "chat-thread-loading", className: "pointer-events-none absolute inset-0 grid place-items-center", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-[#FF5800]" }) })) : null, hasTopics ? (_jsx(TopicChipsBar, { topics: channelTopics, onSelectTopic: scrollToTopic, className: "sticky top-0 z-[2] -mx-5 mb-1 bg-gradient-to-b from-black/40 to-transparent px-5" })) : null, _jsxs("div", { className: "mt-auto flex flex-col pb-3 pt-1", children: [hasTopics
2210
2272
  ? // Topic-grouped transcript: each cluster collapses via a
2211
2273
  // gesture on its header (no visible buttons).
2212
2274
  (() => {
@@ -2257,7 +2319,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2257
2319
  // edge-to-edge), so the composer carries the home-gesture
2258
2320
  // clearance itself — except while the keyboard is up, which
2259
2321
  // already covers that zone.
2260
- style: fullBleed && !composerFocused
2322
+ style: fullBleed && !keyboardLiftActive
2261
2323
  ? {
2262
2324
  paddingBottom: "calc(0.5rem + max(var(--safe-area-bottom, 0px), var(--android-gesture-inset-bottom, 0px)))",
2263
2325
  }
@@ -2268,7 +2330,6 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2268
2330
  if (e.target.value.trim().length > 0)
2269
2331
  expand();
2270
2332
  }, onFocus: () => {
2271
- setComposerFocused(true);
2272
2333
  // A pill-open focus only raises the keyboard; it must not
2273
2334
  // expand a history thread (see suppressExpandOnFocusRef).
2274
2335
  if (suppressExpandOnFocusRef.current) {
@@ -2277,7 +2338,7 @@ export function ContinuousChatOverlay({ controller, agentName = "Eliza", slash:
2277
2338
  else {
2278
2339
  expand();
2279
2340
  }
2280
- }, onBlur: () => setComposerFocused(false), onPaste: (e) => {
2341
+ }, onPaste: (e) => {
2281
2342
  // Shared with the desktop composer: a pasted image/file
2282
2343
  // attaches, a large plain-text paste becomes a collapsed
2283
2344
  // text-attachment chip, and small text falls through to the