feat(dashboard): Add "Pick up where you left off" personalization panel (#935)

Co-authored-by: ved015 <vedant.04.mahajan@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Ishaan Gupta <ishaanxgupta@users.noreply.github.com>
This commit is contained in:
Ishaan Gupta 2026-05-25 10:28:58 +05:30 committed by GitHub
parent ee9c579e39
commit 480f19a6fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 620 additions and 7 deletions

View file

@ -60,6 +60,7 @@ import {
type IntegrationParamValue,
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { getToolDocumentSpace } from "@/lib/plugin-space"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -106,7 +107,7 @@ export default function NewPage() {
const isMobile = useIsMobile()
const { user, session } = useAuth()
const { selectedProject, selectedProjects } = useProject()
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
const selectedProjectTag = selectedProjects[0]
const { allProjects } = useContainerTags()
const dashboardSpaceLabel = useMemo(
@ -408,6 +409,18 @@ export default function NewPage() {
[setDocId],
)
const handleOpenToolDocument = useCallback(
(document: DocumentWithMemories, pluginClientId: string) => {
const documentSpace = getToolDocumentSpace(document, pluginClientId)
if (documentSpace) {
setSelectedProject(documentSpace)
}
handleOpenDocument(document)
void setViewMode("list")
},
[handleOpenDocument, setSelectedProject, setViewMode],
)
// Separate from handleOpenDocument because the graph view only has a document ID,
// not the full document object. The modal will fetch the document via the docId
// query param, so there may be a brief loading state (unlike handleOpenDocument
@ -712,6 +725,7 @@ export default function NewPage() {
onNavigateToMemories={() => void setViewMode("list")}
onNavigateToGraph={() => void setViewMode("graph")}
onOpenDocument={handleOpenDocument}
onOpenToolDocument={handleOpenToolDocument}
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
onResetHighlights={handleResetHighlights}

View file

@ -90,6 +90,17 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
],
icon: "/images/plugins/cursor.svg",
},
codex: {
name: "OpenAI Codex",
description:
"Persistent memory for OpenAI Codex CLI. Remembers your coding context, patterns, and decisions across sessions.",
features: [
"Auto-recalls relevant context before each prompt",
"Captures coding decisions and patterns automatically",
"Builds persistent user profile across projects",
],
icon: "/images/plugins/codex.svg",
},
}
function getPluginName(client: string): string {

View file

@ -7,12 +7,14 @@ import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useQuery } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import Image from "next/image"
import {
ArrowRight,
ExternalLink,
FileText,
Lightbulb,
Link2,
Plug,
RotateCcw,
SearchIcon,
Terminal,
@ -37,6 +39,7 @@ import {
usePersonalization,
type Profession,
} from "@/hooks/use-personalization"
import { normalizePluginClientId } from "@/lib/plugin-catalog"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -329,6 +332,468 @@ const PLUGIN_STATIC = [
},
] as const
// Plugin catalog for tool usage display - maps plugin IDs to display names and icons
const PLUGIN_DISPLAY_CATALOG: Record<
string,
{ name: string; icon: string | null; type: "Plugin" }
> = {
claude_code: {
name: "Claude Code",
icon: "/images/plugins/claude-code.svg",
type: "Plugin",
},
opencode: {
name: "OpenCode",
icon: "/images/plugins/opencode.svg",
type: "Plugin",
},
openclaw: {
name: "OpenClaw",
icon: "/images/plugins/openclaw.svg",
type: "Plugin",
},
hermes: {
name: "Hermes",
icon: "/images/plugins/hermes.svg",
type: "Plugin",
},
codex: {
name: "OpenAI Codex",
icon: "/mcp-supported-tools/codex.png",
type: "Plugin",
},
}
// Types for tool usage
interface ToolUsageItem {
id: string
name: string
type: "Plugin" | "MCP"
icon: string | null
lastUsedAt: Date | null
hasBeenUsed: boolean
connectedAt: Date | null
lastDocumentTitle: string | null
lastDocumentId: string | null
lastDocumentPreview: string | null
lastDocument: DocumentWithMemories | null
}
type ToolUsageApiKey = {
id: string
name: string
createdAt: string
lastRequest: string | null
metadata: string
}
function toValidDate(value: string | Date | null | undefined): Date | null {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function compactText(value: string): string {
return value.replace(/\s+/g, " ").trim()
}
function getDocumentText(document: DocumentWithMemories): string {
return typeof document.content === "string" ? document.content : ""
}
function toMetadataRecord(value: unknown): Record<string, unknown> | null {
if (!value) return null
if (typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== "string") return null
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
}
function getDocumentMetadataRecords(
document: DocumentWithMemories,
): Record<string, unknown>[] {
const records = [toMetadataRecord(document.metadata)]
for (const entry of document.memoryEntries ?? []) {
records.push(
toMetadataRecord(entry.metadata),
toMetadataRecord(entry.sourceMetadata),
)
}
return records.filter((record): record is Record<string, unknown> => !!record)
}
function hasClaudeCodeContainer(document: DocumentWithMemories): boolean {
const containerTags =
(document as { containerTags?: string[] }).containerTags ?? []
if (containerTags.some((tag) => tag.startsWith("claudecode_"))) return true
return (document.memoryEntries ?? []).some((entry) =>
entry.spaceContainerTag?.startsWith("claudecode_"),
)
}
function getPluginClientFromDocument(
document: DocumentWithMemories,
): string | null {
for (const metadata of getDocumentMetadataRecords(document)) {
const metadataClient =
typeof metadata.sm_client === "string"
? metadata.sm_client
: typeof metadata.sm_internal_plugin_client === "string"
? metadata.sm_internal_plugin_client
: typeof metadata.sm_internal_mcp_client_name === "string"
? metadata.sm_internal_mcp_client_name
: null
if (metadataClient) return normalizePluginClientId(metadataClient)
if (metadata.sm_source === "claude-code-plugin") return "claude_code"
}
if (hasClaudeCodeContainer(document)) return "claude_code"
const content = getDocumentText(document)
const title = document.title ?? ""
if (
/\[Session\s+[^\]]+\]/i.test(content) ||
/\[SAVE:[^\]]+\]/i.test(content)
) {
return "codex"
}
if (/\bCodex\b/i.test(title)) return "codex"
return null
}
function getDocumentPreview(document: DocumentWithMemories): string | null {
const summary =
typeof document.summary === "string" ? compactText(document.summary) : ""
if (summary) return summary
const content = getDocumentText(document)
if (!content) return document.title?.trim() || null
const transcriptTurns = Array.from(
content.matchAll(
/\d+\.\s+\[(user|assistant)\]\s*([\s\S]*?)(?=\d+\.\s+\[(?:user|assistant|tool|system)\]|---|\[\/?[A-Za-z]|$)/gi,
),
)
.slice(0, 2)
.map((match) => {
const role = match[1] === "assistant" ? "Assistant" : "You"
const text = compactText(match[2] ?? "")
return text ? `${role}: ${text}` : null
})
.filter(Boolean)
if (transcriptTurns.length > 0) return transcriptTurns.join(" · ")
const cleaned = compactText(
content
.replace(/\[Session\s+[^\]]+\]/gi, "")
.replace(/\[SAVE:[^\]]+\]/gi, "")
.replace(/\[\/SAVE\]/gi, ""),
)
return cleaned || document.title?.trim() || null
}
function getToolDocumentTitle(document: DocumentWithMemories): string {
return document.title?.trim() || "Recent conversation"
}
// Parse API keys to extract tool usage data
function parseToolUsage(
apiKeys: ToolUsageApiKey[],
recentMcpDocuments: DocumentWithMemories[] = [],
): ToolUsageItem[] {
const toolMap = new Map<string, ToolUsageItem>()
let latestMcpClientName: string | null = null
let latestMcpDocumentAt: Date | null = null
const latestDocPerPlugin = new Map<
string,
{
title: string
id: string
at: Date
preview: string | null
document: DocumentWithMemories
}
>()
for (const key of apiKeys) {
let meta: Record<string, unknown> = {}
try {
meta = key.metadata ? JSON.parse(key.metadata) : {}
} catch {
continue
}
const smType = meta.sm_type as string | undefined
const smClient = meta.sm_client as string | undefined
const smSource = meta.sm_source as string | undefined
const smKind = meta.sm_kind as string | undefined
// Plugin keys
if (smType === "plugin_auth" && smClient) {
const normalizedClient = normalizePluginClientId(smClient)
const catalog = PLUGIN_DISPLAY_CATALOG[normalizedClient]
const existingItem = toolMap.get(`plugin_${normalizedClient}`)
const lastUsed =
toValidDate(key.lastRequest) ?? toValidDate(key.createdAt)
const existingLastUsed = existingItem?.lastUsedAt
// Keep the most recent usage
if (
!existingItem ||
(lastUsed &&
(!existingLastUsed ||
lastUsed.getTime() > existingLastUsed.getTime()))
) {
toolMap.set(`plugin_${normalizedClient}`, {
id: `plugin_${normalizedClient}`,
name: catalog?.name ?? smClient,
type: "Plugin",
icon: catalog?.icon ?? null,
lastUsedAt: lastUsed,
hasBeenUsed: !!key.lastRequest,
connectedAt: toValidDate(key.createdAt),
lastDocumentTitle: null,
lastDocumentId: null,
lastDocumentPreview: null,
lastDocument: null,
})
}
}
// MCP keys
if (smSource === "mcp" || smKind === "mcp_oauth_exchange") {
const existingItem = toolMap.get("mcp")
const lastUsed =
toValidDate(key.lastRequest) ?? toValidDate(key.createdAt)
const existingLastUsed = existingItem?.lastUsedAt
// Keep the most recent usage
if (
!existingItem ||
(lastUsed &&
(!existingLastUsed ||
lastUsed.getTime() > existingLastUsed.getTime()))
) {
toolMap.set("mcp", {
id: "mcp",
name: "Supermemory MCP",
type: "MCP",
icon: null,
lastUsedAt: lastUsed,
hasBeenUsed: !!key.lastRequest,
connectedAt: toValidDate(key.createdAt),
lastDocumentTitle: null,
lastDocumentId: null,
lastDocumentPreview: null,
lastDocument: null,
})
}
}
}
for (const doc of recentMcpDocuments) {
const metadataRecords = getDocumentMetadataRecords(doc)
const clientName =
metadataRecords
.map((record) => record.sm_internal_mcp_client_name)
.find((value): value is string => typeof value === "string") ?? null
const pluginClient = getPluginClientFromDocument(doc)
const isMcpDocument =
doc.source === "mcp" ||
metadataRecords.some(
(record) => record.sm_internal_event_from === "mcp",
) ||
!!clientName
const isPluginDocument = !!pluginClient && pluginClient !== "mcp"
if (!isMcpDocument && !isPluginDocument) continue
const createdAt = toValidDate(doc.createdAt)
if (
isMcpDocument &&
clientName &&
clientName !== "unknown" &&
(!latestMcpDocumentAt ||
(createdAt && createdAt.getTime() > latestMcpDocumentAt.getTime()))
) {
latestMcpClientName = clientName
latestMcpDocumentAt = createdAt
}
if (isMcpDocument) {
const existingItem = toolMap.get("mcp")
const existingLastUsed = existingItem?.lastUsedAt
if (
!existingItem ||
(createdAt &&
(!existingLastUsed ||
createdAt.getTime() > existingLastUsed.getTime()))
) {
toolMap.set("mcp", {
id: "mcp",
name:
clientName && clientName !== "unknown"
? clientName
: "Supermemory MCP",
type: "MCP",
icon: null,
lastUsedAt: createdAt,
hasBeenUsed: true,
connectedAt: toolMap.get("mcp")?.connectedAt ?? null,
lastDocumentTitle: doc.title?.trim() || null,
lastDocumentId: doc.id ?? null,
lastDocumentPreview: getDocumentPreview(doc),
lastDocument: doc,
})
}
}
if (pluginClient && createdAt && doc.id) {
const existing = latestDocPerPlugin.get(pluginClient)
if (!existing || createdAt.getTime() > existing.at.getTime()) {
latestDocPerPlugin.set(pluginClient, {
title: getToolDocumentTitle(doc),
id: doc.id,
at: createdAt,
preview: getDocumentPreview(doc),
document: doc,
})
}
}
}
if (latestMcpClientName) {
const existingItem = toolMap.get("mcp")
if (existingItem) {
toolMap.set("mcp", {
...existingItem,
name: latestMcpClientName,
})
}
}
// Attach latest document info to plugin items where available from MCP documents
for (const [, item] of toolMap) {
if (item.type === "Plugin" && !item.lastDocumentTitle) {
const pluginId = item.id.replace(/^plugin_/, "")
const docInfo = latestDocPerPlugin.get(pluginId)
if (docInfo) {
item.lastDocumentTitle = docInfo.title
item.lastDocumentId = docInfo.id
item.lastDocumentPreview = docInfo.preview
item.lastDocument = docInfo.document
}
}
}
// Sort by lastUsedAt (most recent first), then by hasBeenUsed
return Array.from(toolMap.values()).sort((a, b) => {
// Items that have been used come first
if (a.hasBeenUsed !== b.hasBeenUsed) {
return a.hasBeenUsed ? -1 : 1
}
// Then sort by recency
if (!a.lastUsedAt && !b.lastUsedAt) return 0
if (!a.lastUsedAt) return 1
if (!b.lastUsedAt) return -1
return b.lastUsedAt.getTime() - a.lastUsedAt.getTime()
})
}
// Format relative time for tool usage
function formatToolUsageTime(date: Date | null, hasBeenUsed: boolean): string {
if (!hasBeenUsed) return "Never used"
if (!date) return "Connected"
const diffMs = Date.now() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays === 1) return "Yesterday"
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function ToolUsageRecentRow({
item,
onOpenPlugins,
onOpenToolDocument,
}: {
item: ToolUsageItem
onOpenPlugins: () => void
onOpenToolDocument: (
document: DocumentWithMemories,
pluginClientId: string,
) => void
}) {
const pluginClientId = item.id.replace(/^plugin_/, "")
return (
<li>
<button
type="button"
onClick={() => {
if (item.lastDocument) {
onOpenToolDocument(item.lastDocument, pluginClientId)
return
}
onOpenPlugins()
}}
className="group flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-all hover:bg-surface-hover hover:py-2.5"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface-card ring-1 ring-surface-border group-hover:bg-[#182333] transition-colors">
{item.icon ? (
<Image
src={item.icon}
alt={item.name}
width={14}
height={14}
className="size-3.5"
/>
) : item.type === "MCP" ? (
<MCPIcon className="size-3.5" />
) : (
<Plug className="size-3.5 text-fg-subtle" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted group-hover:text-white transition-colors">
{item.lastDocumentTitle ?? "No saved memory yet"}
</span>
<span className="shrink-0 text-[10px] text-fg-faint">
{formatToolUsageTime(item.lastUsedAt, item.hasBeenUsed)}
</span>
</div>
{item.lastDocumentPreview ? (
<p className="mt-0 max-h-0 overflow-hidden text-[11px] leading-snug text-fg-subtle opacity-0 transition-all duration-200 line-clamp-2 group-hover:mt-1 group-hover:max-h-9 group-hover:opacity-100">
{item.lastDocumentPreview}
</p>
) : null}
</div>
</button>
</li>
)
}
function RecommendedPluginsCard({
profession,
setProfession,
@ -643,6 +1108,7 @@ export function DashboardView({
onNavigateToMemories: _onNavigateToMemories,
onNavigateToGraph,
onOpenDocument,
onOpenToolDocument,
onHighlightsChat,
onHighlightsShowRelated,
onResetHighlights,
@ -659,12 +1125,16 @@ export function DashboardView({
onNavigateToMemories: () => void
onNavigateToGraph: () => void
onOpenDocument: (document: DocumentWithMemories) => void
onOpenToolDocument: (
document: DocumentWithMemories,
pluginClientId: string,
) => void
onHighlightsChat: (highlightContent: string, userReply: string) => void
onHighlightsShowRelated: (query: string) => void
onResetHighlights: () => void
memoryOfDay: MemoryOfDay | null
}) {
const { user } = useAuth()
const { user, org } = useAuth()
const { effectiveContainerTags } = useProject()
const _router = useRouter()
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
@ -684,7 +1154,7 @@ export function DashboardView({
return response.data as DocumentsResponse
},
staleTime: 60 * 1000,
enabled: !!user,
enabled: !!user && !!org?.id,
})
const { data: connections = [] } = useQuery({
@ -710,6 +1180,58 @@ export function DashboardView({
enabled: !!user,
})
// Fetch API keys for tool usage tracking
const { data: apiKeysData } = useQuery({
queryKey: ["api-keys-tool-usage", org?.id],
queryFn: async () => {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const res = await fetch(`${API_URL}/v3/auth/keys`, {
credentials: "include",
})
if (!res.ok) return { keys: [] }
return (await res.json()) as {
keys: Array<{
id: string
name: string
createdAt: string
lastRequest: string | null
metadata: string
}>
}
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const { data: recentMcpDocumentsData } = useQuery({
queryKey: ["dashboard-tool-documents", org?.id],
queryFn: async (): Promise<DocumentsResponse> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 50,
sort: "createdAt",
order: "desc",
},
disableValidation: true,
})
if (response.error) throw new Error(response.error?.message)
return response.data as DocumentsResponse
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const toolUsageItems = useMemo(
() =>
parseToolUsage(
apiKeysData?.keys ?? [],
recentMcpDocumentsData?.documents ?? [],
),
[apiKeysData, recentMcpDocumentsData],
)
const {
copy: personalizedCopy,
profession,
@ -717,6 +1239,14 @@ export function DashboardView({
} = usePersonalization()
const recents = recentsData?.documents ?? []
const recentToolUsageItems = toolUsageItems
.filter((item) => item.type === "Plugin" && item.lastDocument)
.sort((a, b) => {
const aTime = toValidDate(a.lastDocument?.createdAt)?.getTime() ?? 0
const bTime = toValidDate(b.lastDocument?.createdAt)?.getTime() ?? 0
return bTime - aTime
})
.slice(0, 3)
const totalMemories = recentsData?.pagination?.totalItems ?? 0
const hasMcp = mcpData?.previousLogin ?? false
const connectedProviders = new Set(connections.map((c) => c.provider))
@ -907,9 +1437,9 @@ export function DashboardView({
className="space-y-2"
>
<div className="flex gap-4">
<div className="flex-[3] min-w-0">
<div className="flex-[4] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Recently saved
Recents
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
@ -920,7 +1450,7 @@ export function DashboardView({
</div>
<div className="flex gap-4 items-start">
<div className="flex-[3] min-w-0">
<div className="flex-[4] min-w-0">
{isRecentsLoading ? (
<ul
className="space-y-0.5"
@ -941,8 +1471,16 @@ export function DashboardView({
</li>
))}
</ul>
) : recents.length > 0 ? (
) : recents.length > 0 || recentToolUsageItems.length > 0 ? (
<ul className="space-y-0.5">
{recentToolUsageItems.map((item) => (
<ToolUsageRecentRow
key={item.id}
item={item}
onOpenPlugins={onOpenPlugins}
onOpenToolDocument={onOpenToolDocument}
/>
))}
{recents.map((doc) => {
const isLink = !!doc.url
return (

View file

@ -147,3 +147,9 @@ const SPACE_TO_CATALOG_ID: Record<string, string> = {
export function spacePluginIdToCatalogId(spacePluginId: string): string | null {
return SPACE_TO_CATALOG_ID[spacePluginId] ?? null
}
/** Normalize plugin client ids from API keys, metadata, and space ids. */
export function normalizePluginClientId(client: string): string {
const trimmed = client.trim().toLowerCase()
return spacePluginIdToCatalogId(trimmed) ?? trimmed.replace(/-/g, "_")
}

View file

@ -1,3 +1,5 @@
import { normalizePluginClientId } from "@/lib/plugin-catalog"
export type PluginSpaceInfo = {
pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp"
label: string
@ -131,6 +133,48 @@ export function detectPluginSource(
}
}
type DocumentSpaceCandidate = {
containerTags?: string[]
memoryEntries?: Array<{ spaceContainerTag?: string | null }> | null
}
function catalogIdForPluginSpace(
pluginId: PluginSpaceInfo["pluginId"],
): string {
return pluginId.replace(/-/g, "_")
}
/** Resolve the best container tag for a tool/plugin document. */
export function getToolDocumentSpace(
document: DocumentSpaceCandidate,
pluginClientId?: string | null,
): string | null {
const containerTags = document.containerTags ?? []
const memorySpaceTags = (document.memoryEntries ?? [])
.map((entry) => entry.spaceContainerTag)
.filter((tag): tag is string => !!tag)
const allTags = [...containerTags, ...memorySpaceTags]
if (pluginClientId) {
const normalizedClient = normalizePluginClientId(pluginClientId)
for (const tag of allTags) {
const pluginSpace = detectPluginSpace(tag)
if (
pluginSpace &&
catalogIdForPluginSpace(pluginSpace.pluginId) === normalizedClient
) {
return tag
}
}
}
for (const tag of allTags) {
if (detectPluginSpace(tag)) return tag
}
return allTags[0] ?? null
}
export function detectPluginSpace(
containerTag: string,
): PluginSpaceInfo | null {