supermemory/apps/web/components/chat/input/index.tsx
MaheshtheDev 1706752668 mobile responsiveness pass + connections reauth fix (#959)
Nova mobile pass
- viewport: viewportFit cover for iOS safe-area-inset
- safe-area utilities (pb-safe, pt-safe, bottom-safe-5, scroll-fade-x) in globals.css
- chat FAB pinned above iPhone home indicator; chat sidebar widths responsive across sm/md/lg with min() clamps
- chat input CoT panel max-h capped via min(60dvh, 420px)
- header tab strip swapped from visible scrollbar to scroll-fade-x mask + snap-x
- nova empty state uses svh on mobile, dvh from sm up

Add-memory modal rebuilt for mobile
- mobile shell switched from fullscreen Dialog to vaul Drawer at 85svh with swipe-down dismissal and scaled background
- in-modal header removed; tabs moved to the bottom of the sheet for thumb reach
- four tab compactLabels: Note, Links, Files, Connections
- desktop tabs now render only when !isMobile (no DOM duplication)
- note/link content state lifted to parent so switching tabs preserves typed input
- NoteContent snapshots initialContent via lazy useState so the editor isn't reset on every keystroke
- shared Drawer base uses rounded-t-xl
- removed legacy pt-4 on tab content for mobile

Connections — replace expiresAt with sync-run health
- new useConnectionHealth hook reads the latest sync run and matches auth-failure patterns; backend errorKind field still needed (TODO)
- regex tightened so 401/403 require co-occurring auth/token/grant context; refresh_token requires expired/revoked/invalid/missing qualifier
- badge label changed Disconnected -> Needs reauth
- Reconnect button replaces the sync action when needsReauth, kicks off the same OAuth flow
- per-row reconnect tracking via mutation.variables instead of a single shared id (no race when multiple rows clicked)
- fallback toast when authLink is missing so the spinner can't get stuck
- sync history panel timeline capped at max-h-260 with internal scroll
- useSyncRuns no longer refetches on mount; cache (30s) actually applies, cutting N requests per modal open
2026-05-17 21:49:23 +00:00

186 lines
5.5 KiB
TypeScript

"use client"
import { ChevronUpIcon } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
interface ChatInputProps {
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onSend: () => void
onStop: () => void
onKeyDown?: (e: React.KeyboardEvent) => void
isResponding?: boolean
activeStatus?: string
chainOfThoughtComponent?: React.ReactNode
onExpandedChange?: (expanded: boolean) => void
/** Model + space controls on one row with send; textarea full-width above */
stackedToolbar?: ReactNode
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
showStatusStrip?: boolean
}
export default function ChatInput({
value,
onChange,
onSend,
onStop,
onKeyDown,
isResponding = false,
activeStatus,
chainOfThoughtComponent,
onExpandedChange,
stackedToolbar,
showStatusStrip = true,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!showStatusStrip && isExpanded) {
setIsExpanded(false)
onExpandedChange?.(false)
}
}, [isExpanded, onExpandedChange, showStatusStrip])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
const textarea = e.target
textarea.style.height = "auto"
// Set height based on scrollHeight, with a max of ~96px (4-5 lines)
const newHeight = Math.min(textarea.scrollHeight, 96)
textarea.style.height = `${newHeight}px`
setIsMultiline(textarea.scrollHeight > 52)
}
return (
<motion.div
className={cn("relative z-20!")}
animate={{
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
borderRadius: showStatusStrip
? isExpanded
? "0 0 12px 12px"
: "12px"
: "0",
backgroundColor: showStatusStrip
? isExpanded
? "#000B1B"
: "#01173C"
: "transparent",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
{showStatusStrip ? (
<>
<div
className={cn(
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[min(60dvh,420px)] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
disabled={!chainOfThoughtComponent}
>
<div className="flex items-center gap-3">
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
)}
</button>
</>
) : null}
{stackedToolbar ? (
<div className="flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{stackedToolbar}
</div>
<div className="shrink-0">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
</div>
) : (
<div
className={cn(
"flex items-end gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
isMultiline && "flex-col",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
)}
</motion.div>
)
}