Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 166 additions & 39 deletions apps/web/components/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,149 @@ import { generateId } from "@lib/generate-id"
import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"

// ---------------------------------------------------------------------------
// Suggestions: constants, types, pure helpers, and hook
//
// The chat empty state previously hardcoded content-specific prompts like
// "Summarize the key ideas from My Gita" which a new user won't own. This
// module replaces them with prompts derived from the user's actual memories,
// falling back gracefully when the user has none or the fetch fails.
//
// The pure helpers and the hook below are intentionally decoupled from the
// sidebar UI so they can be reused when the chat interface is refactored.
// ---------------------------------------------------------------------------

/** Generic fallback — never content-specific so it works for any user. */
const DEFAULT_SUGGESTIONS = [
"Show me all content related to Supermemory.",
"Summarize the key ideas from My Gita.",
"Which memories connect design and AI?",
"What are the main themes across my memories?",
"Summarize the most important things I've saved.",
"Which memories are most relevant to each other?",
"What topics appear most often in my memories?",
]

/** Shown when the user has zero memories. Onboarding-oriented. */
const ONBOARDING_SUGGESTIONS = [
"How do I add my first memory?",
"What kinds of content can I save here?",
"How does search work across my memories?",
"What can you help me do once I've added memories?",
]

/** Minimal shape — only the fields the suggestion logic actually reads. */
type Memory = {
id: string
title?: string
content?: string
}

/** Extract a short readable label from a memory, or null if none is usable. */
function extractMemoryLabel(memory: Memory): string | null {
if (memory.title?.trim()) return memory.title.trim()
if (memory.content?.trim()) {
const excerpt = memory.content.trim().split(/[.\n]/)[0]?.trim() ?? ""
if (excerpt.length > 4) return excerpt.slice(0, 60)
}
return null
}

/**
* Produce 4 contextual suggestions from the user's memories.
* Pure function, deterministic, safe to unit-test in isolation.
*/
export function deriveSuggestionsFromMemories(memories: Memory[]): string[] {
const labels = memories
.map(extractMemoryLabel)
.filter((l): l is string => l !== null)
.filter(
(label, i, arr) =>
arr.findIndex((l) => l.toLowerCase() === label.toLowerCase()) === i,
)
.slice(0, 6)

if (labels.length === 0) return DEFAULT_SUGGESTIONS

const t = (i: number) => labels[i % labels.length]

return [
`What are the key ideas in "${t(0)}"?`,
labels.length >= 2
? `How does "${t(0)}" connect to "${t(1)}"?`
: `Summarize everything I've saved about ${t(0)}`,
labels.length >= 3
? `What patterns appear across "${t(1)}" and "${t(2)}"?`
: `What can you find related to ${t(0)} in my memories?`,
labels.length >= 4
? `Compare the main themes in "${t(2)}" and "${t(3)}"`
: `What questions should I be asking about ${t(0)}?`,
]
}

/**
* Fetch the user's memories for the current project and derive prompt
* suggestions from them. Fallback chain:
* derived prompts → ONBOARDING_SUGGESTIONS (0 memories) → DEFAULT_SUGGESTIONS (error)
*/
function useDynamicSuggestions(selectedProject: string | null): {
suggestions: string[]
isLoading: boolean
} {
const [suggestions, setSuggestions] = useState<string[]>(DEFAULT_SUGGESTIONS)
const [isLoading, setIsLoading] = useState(true)

useEffect(() => {
let cancelled = false
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"

async function load() {
setIsLoading(true)
try {
const body: Record<string, unknown> = { limit: 20 }
if (selectedProject) body.containerTags = [selectedProject]

const res = await fetch(`${backendUrl}/v3/documents/documents`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok || cancelled) return

const data = await res.json()
const memories: Memory[] = data.documents ?? []
if (cancelled) return

setSuggestions(
memories.length === 0
? ONBOARDING_SUGGESTIONS
: deriveSuggestionsFromMemories(memories),
)
} catch {
if (!cancelled) setSuggestions(DEFAULT_SUGGESTIONS)
} finally {
if (!cancelled) setIsLoading(false)
}
}

load()
return () => {
cancelled = true
}
}, [selectedProject])

return { suggestions, isLoading }
}

// ---------------------------------------------------------------------------

function ChatEmptyStatePlaceholder({
onSuggestionClick,
suggestions = DEFAULT_SUGGESTIONS,
isLoadingSuggestions = false,
}: {
onSuggestionClick: (suggestion: string) => void
suggestions?: string[]
isLoadingSuggestions?: boolean
}) {
return (
<div
Expand All @@ -80,19 +210,29 @@ function ChatEmptyStatePlaceholder({
"flex flex-col gap-2 justify-center items-center",
)}
>
{suggestions.map((suggestion) => (
<Button
key={suggestion}
variant="default"
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
onClick={() => onSuggestionClick(suggestion)}
>
<SearchIcon className="size-4 text-[#267BF1] shrink-0" />
<span className="text-[#267BF1] text-[12px] truncate">
{suggestion}
</span>
</Button>
))}
{isLoadingSuggestions
? // Skeletons at the same dimensions as the real buttons —
// prevents layout shift when suggestions arrive
Array.from({ length: 4 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable skeleton
<div
key={i}
className="h-10 w-[300px] rounded-full bg-[#041127] border border-[#2261CA33] animate-pulse"
/>
))
: suggestions.map((suggestion) => (
<Button
key={suggestion}
variant="default"
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
onClick={() => onSuggestionClick(suggestion)}
>
<SearchIcon className="size-4 text-[#267BF1] shrink-0" />
<span className="text-[#267BF1] text-[12px] truncate">
{suggestion}
</span>
</Button>
))}
</div>
</div>
</div>
Expand Down Expand Up @@ -173,6 +313,12 @@ export function ChatSidebar({
}),
[selectedProject, selectedModel],
)

// Derive suggestions from the user's actual memories.
// The parent-supplied `emptyStateSuggestions` always wins if provided.
const { suggestions: dynamicSuggestions, isLoading: isSuggestionsLoading } =
useDynamicSuggestions(selectedProject)

const [pendingThreadLoad, setPendingThreadLoad] = useState<{
id: string
messages: UIMessage[]
Expand Down Expand Up @@ -732,28 +878,6 @@ export function ChatSidebar({
</span>
)}
</Button>
{/*<motion.button
onClick={toggleChat}
className={cn(
"flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer",
isMobile && "bg-[#0D121A] border border-[#73737333]",
)}
style={
isMobile
? {
boxShadow:
"1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}
: undefined
}
layoutId="chat-toggle-button"
>
{isMobile ? (
<XIcon className="size-4" />
) : (
<PanelRightCloseIcon className="size-4" />
)}
</motion.button>*/}
</div>
</div>
<div
Expand All @@ -776,7 +900,10 @@ export function ChatSidebar({
analytics.chatMessageSent({ source: "suggested" })
sendMessage({ text: suggestion })
}}
suggestions={emptyStateSuggestions}
suggestions={emptyStateSuggestions ?? dynamicSuggestions}
isLoadingSuggestions={
!emptyStateSuggestions && isSuggestionsLoading
}
/>
)}
<div
Expand Down