{"slug":"quick-chat-dock","category":"composer","job":"ask","added":"2026-11-04T13:15Z","title":"Quick Chat Dock","description":"A hidden bottom-edge handle that wakes into an action dock on hover and summons a floating prompt bar.","url":"https://ai-patterns.app/patterns/composer/quick-chat-dock","markdown":"https://ai-patterns.app/patterns/composer/quick-chat-dock.md","json":"https://ai-patterns.app/api/patterns/composer/quick-chat-dock","seenIn":[{"team":"mixpanel","source":"https://docs.mixpanel.com/docs/mixpanel-agent"}],"spec":"# Quick Chat Dock\n\n## Summary\nA near-invisible handle pinned to the bottom edge of a workspace that wakes into a small action dock on hover. Its main action summons a floating prompt bar for a quick question without leaving the current screen; a second action opens the full agent panel. AI stays one gesture away while taking almost no space.\n\n## When to use\n- Canvas-heavy or content-dense apps (editors, boards, dashboards) where a permanent composer or sidebar would steal room, but users still want to ask the assistant something mid-task.\n- Products that offer both a lightweight \"ask once\" flow and a full, persistent agent conversation.\n\n## When not to use\n- Chat-first products where the composer is the primary surface — use a docked Prompt Bar instead.\n- Touch-first layouts with no hover: the collapsed handle is too subtle to discover. Show the expanded dock permanently or use a floating action button.\n- When the bottom edge is already busy (toolbars, status bars, timelines) — the hot zone will fight with them.\n\n## Anatomy\n- Collapsed handle: a small, low-contrast pill centered on the bottom edge.\n- Hot zone: an invisible strip around the handle, larger than the handle, that wakes the dock.\n- Expanded dock: two icon buttons — Quick chat (primary) and Open agent.\n- Tooltips: label plus keyboard shortcut for each action.\n- Floating prompt bar: single-line input, \"continue in agent panel\" button, send button.\n\n## Behavior\n- Hovering the hot zone morphs the handle into the dock with a quick spring; leaving it collapses the dock after a short grace delay (~150–200ms) so grazing the edge doesn't flicker.\n- Hovering or focusing a dock button shows its tooltip with the shortcut.\n- Quick chat replaces the dock with the floating prompt bar and focuses its input. Enter sends; Escape or a click outside closes it and discards the draft.\n- Open agent opens the full agent panel. From the prompt bar, the panel button carries the current draft into the agent so nothing typed is lost.\n- Global shortcuts (e.g. ⇧Enter for quick chat, ⇧A for the agent) work from anywhere except while typing in an editable field.\n- After sending, the bar closes and the answer appears where it belongs (inline, a toast, or the agent panel) — the bar itself is not a conversation.\n\n## Content guidelines\n- Placeholder invites both search and questions in a few words: \"Search or ask anything…\".\n- Tooltip labels are short verbs or nouns (\"Quick chat\", \"Open agent\"); shortcuts are shown as keycaps, not prose.\n\n## Accessibility\n- The collapsed handle is a real, focusable button; focusing it expands the dock and moves focus to the first action, so keyboard users never depend on hover.\n- The dock is a `toolbar`; icon buttons have `aria-label`s and expose shortcuts via `aria-keyshortcuts`.\n- Opening the prompt bar moves focus to its input; closing it should return focus sensibly (to the page, not a removed element).\n- Tooltips also appear on focus, not only hover.\n- Respect `prefers-reduced-motion`: crossfade instead of morphing and sliding.\n\n## Related patterns\n- **Prompt Bar** — Use it instead on chat-first surfaces where the composer is the main thing on the page.\n- **Command Palette** — Use it instead when the input is a keyboard-first launcher for search and commands rather than a docked chat.\n- **Selection Actions** — Use it instead when the toolbar should be anchored to selected content rather than the screen edge.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUp, PanelRight, Sparkle } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface QuickChatDockProps {\n  placeholder?: string;\n  /** Called with the question when the floating prompt bar is submitted. */\n  onSubmit?: (value: string) => void;\n  /** Called when the user asks to open the full agent panel (with any draft typed so far). */\n  onOpenAgent?: (draft: string) => void;\n  /** Listen for ⇧Enter (quick chat) and ⇧A (open agent) anywhere on the page. */\n  shortcuts?: boolean;\n  /** Positioning is up to the host: `absolute` inside a relative canvas, or `fixed` app-wide. */\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 520, damping: 38 } as const;\n\nfunction isEditable(target: EventTarget | null) {\n  return (\n    target instanceof HTMLElement &&\n    (target.isContentEditable || [\"INPUT\", \"TEXTAREA\", \"SELECT\"].includes(target.tagName))\n  );\n}\n\nexport function QuickChatDock({\n  placeholder = \"Search or ask anything...\",\n  onSubmit,\n  onOpenAgent,\n  shortcuts = true,\n  className,\n}: QuickChatDockProps) {\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [open, setOpen] = React.useState(false);\n  const [value, setValue] = React.useState(\"\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const leaveTimer = React.useRef<number | undefined>(undefined);\n  const reduceMotion = useReducedMotion();\n\n  const expanded = !open && (hovered || focused);\n\n  const openChat = React.useCallback(() => {\n    setOpen(true);\n    setHovered(false);\n    setFocused(false);\n  }, []);\n\n  const closeChat = React.useCallback(() => {\n    setOpen(false);\n    setValue(\"\");\n  }, []);\n\n  const openAgent = React.useCallback(\n    (draft = \"\") => {\n      onOpenAgent?.(draft);\n      closeChat();\n      setFocused(false);\n    },\n    [onOpenAgent, closeChat]\n  );\n\n  React.useEffect(() => {\n    if (open) inputRef.current?.focus();\n  }, [open]);\n\n  React.useEffect(() => {\n    if (!shortcuts) return;\n    function onKey(e: KeyboardEvent) {\n      if (!e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || isEditable(e.target)) return;\n      if (e.key === \"Enter\") {\n        e.preventDefault();\n        openChat();\n      } else if (e.key.toLowerCase() === \"a\") {\n        e.preventDefault();\n        openAgent();\n      }\n    }\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcuts, openChat, openAgent]);\n\n  React.useEffect(() => {\n    if (!open) return;\n    function onDown(e: PointerEvent) {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node)) closeChat();\n    }\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [open, closeChat]);\n\n  React.useEffect(() => () => window.clearTimeout(leaveTimer.current), []);\n\n  function handleEnter() {\n    window.clearTimeout(leaveTimer.current);\n    setHovered(true);\n  }\n\n  function handleLeave() {\n    // A short grace period so the dock doesn't flicker shut when the pointer grazes its edge.\n    leaveTimer.current = window.setTimeout(() => setHovered(false), 180);\n  }\n\n  function handleSubmit(e: React.FormEvent) {\n    e.preventDefault();\n    const text = value.trim();\n    if (!text) return;\n    onSubmit?.(text);\n    closeChat();\n  }\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center\", className)}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {open ? (\n          <motion.form\n            key=\"bar\"\n            onSubmit={handleSubmit}\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\") {\n                e.preventDefault();\n                closeChat();\n              }\n            }}\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }}\n            transition={spring}\n            className=\"pointer-events-auto mx-3 mb-3 flex w-full max-w-md items-center gap-1 rounded-2xl border bg-card p-2 pl-3.5 shadow-md\"\n          >\n            <input\n              ref={inputRef}\n              value={value}\n              onChange={(e) => setValue(e.target.value)}\n              placeholder={placeholder}\n              aria-label={placeholder}\n              className=\"min-w-0 flex-1 bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-foreground\"\n            />\n            <button\n              type=\"button\"\n              onClick={() => openAgent(value.trim())}\n              aria-label=\"Continue in agent panel\"\n              title=\"Continue in agent panel\"\n              className=\"flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            >\n              <PanelRight className=\"size-4\" />\n            </button>\n            <button\n              type=\"submit\"\n              disabled={!value.trim()}\n              aria-label=\"Ask\"\n              className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-40\"\n            >\n              <ArrowUp className=\"size-4\" />\n            </button>\n          </motion.form>\n        ) : (\n          <motion.div\n            key=\"dock\"\n            role=\"toolbar\"\n            aria-label=\"Assistant\"\n            onPointerEnter={handleEnter}\n            onPointerLeave={handleLeave}\n            onFocus={() => setFocused(true)}\n            onBlur={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);\n            }}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.12 }}\n            // The invisible hot zone: a generous strip along the bottom edge that wakes the dock.\n            className=\"pointer-events-auto flex h-16 w-64 max-w-full items-end justify-center pb-3\"\n          >\n            <motion.div\n              layout={!reduceMotion}\n              transition={spring}\n              style={{ borderRadius: 999 }}\n              className={cn(\n                \"flex items-center overflow-visible\",\n                expanded\n                  ? \"gap-0.5 border bg-popover p-1 shadow-md\"\n                  : \"h-1.5 w-10 bg-muted-foreground/40 hover:bg-muted-foreground/60\"\n              )}\n            >\n              {expanded ? (\n                <>\n                  <DockButton label=\"Quick chat\" keys={[\"⇧\", \"Enter\"]} onClick={openChat} autoFocus={focused}>\n                    <Sparkle className=\"size-4\" />\n                  </DockButton>\n                  <DockButton label=\"Open Agent\" keys={[\"⇧\", \"A\"]} onClick={() => openAgent()}>\n                    <PanelRight className=\"size-4\" />\n                  </DockButton>\n                </>\n              ) : (\n                // Keeps the collapsed handle reachable by keyboard; focusing it expands the dock.\n                <button type=\"button\" aria-label=\"Show assistant actions\" className=\"size-full rounded-[inherit] outline-none\" />\n              )}\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction DockButton({\n  label,\n  keys,\n  onClick,\n  autoFocus,\n  children,\n}: {\n  label: string;\n  keys: string[];\n  onClick: () => void;\n  autoFocus?: boolean;\n  children: React.ReactNode;\n}) {\n  const [tip, setTip] = React.useState(false);\n  return (\n    <div className=\"relative\">\n      <motion.button\n        type=\"button\"\n        onClick={onClick}\n        onPointerEnter={() => setTip(true)}\n        onPointerLeave={() => setTip(false)}\n        onFocus={() => setTip(true)}\n        onBlur={() => setTip(false)}\n        aria-label={label}\n        autoFocus={autoFocus}\n        aria-keyshortcuts={keys.join(\"+\").replace(\"⇧\", \"Shift\")}\n        initial={{ opacity: 0, scale: 0.6 }}\n        animate={{ opacity: 1, scale: 1 }}\n        transition={{ ...spring, delay: 0.04 }}\n        className=\"flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none\"\n      >\n        {children}\n      </motion.button>\n      <AnimatePresence>\n        {tip && (\n          <motion.div\n            role=\"tooltip\"\n            initial={{ opacity: 0, y: 4, x: \"-50%\" }}\n            animate={{ opacity: 1, y: 0, x: \"-50%\" }}\n            exit={{ opacity: 0, y: 4, x: \"-50%\" }}\n            transition={{ duration: 0.12 }}\n            className=\"pointer-events-none absolute bottom-full left-1/2 mb-2.5 flex items-center gap-2 rounded-xl border bg-popover py-1.5 pr-1.5 pl-2.5 text-xs font-medium whitespace-nowrap text-foreground shadow-md\"\n          >\n            {label}\n            <span className=\"flex gap-1\">\n              {keys.map((k) => (\n                <kbd\n                  key={k}\n                  className=\"flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 font-sans text-[10px] font-medium text-muted-foreground\"\n                >\n                  {k}\n                </kbd>\n              ))}\n            </span>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}