Merge branch 'main' into account

This commit is contained in:
Vedant Mahajan 2026-05-25 10:35:41 +05:30 committed by GitHub
commit f031b18c89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2250 additions and 359 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

@ -90,7 +90,7 @@ export default function ChatModelSelector({
return (
<div
ref={containerRef}
className="relative flex min-w-0 shrink items-center gap-2"
className="relative z-10 flex min-w-0 shrink items-center gap-2"
>
{trigger}

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

@ -394,11 +394,14 @@ export function MemoriesGrid({
return items
}, [documents, isMobile, hasQuickNote, isSelectionMode, selectedDocumentIds])
// Stable key for Masonry based on document IDs, not item values
// Reset Masonry when the actual rendered item set changes. Masonic caches
// positions by index, so mobile removing the quick note must remount it.
const masonryKey = useMemo(() => {
const docIds = documents.map((d) => d.id).join(",")
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}`
}, [documents, isChatOpen, hasQuickNote])
const itemIds = masonryItems.map((item) => item.id).join(",")
return `masonry-${isMobile ? "mobile" : "desktop"}-${masonryItems.length}-${itemIds}-${isChatOpen}`
}, [masonryItems, isChatOpen, isMobile])
const getMasonryItemKey = useCallback((item: MasonryItem) => item.id, [])
const isLoadingMore = isFetchingNextPage
@ -542,9 +545,9 @@ export function MemoriesGrid({
{!isEmpty && !isSelectionMode && (
<div
id="filter-pills"
className="flex items-center justify-between gap-4 mb-3 pr-2"
className="mb-3 flex flex-col gap-2 pr-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div className="flex flex-wrap items-center gap-1.5">
<div className="order-2 flex w-full min-w-0 flex-wrap items-center gap-1.5 sm:order-1">
<Button
className={cn(
dmSansClassName(),
@ -577,7 +580,7 @@ export function MemoriesGrid({
</Button>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="order-1 flex shrink-0 items-center gap-2 self-end sm:order-2 sm:self-start">
{/* View mode toggle — segmented control */}
<div
role="tablist"
@ -638,27 +641,29 @@ export function MemoriesGrid({
id="selection-toolbar"
className={cn(
dmSansClassName(),
"flex items-center justify-between gap-3 mb-3 mr-2 px-3 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40",
"flex items-center justify-between gap-1.5 mb-3 mr-2 px-2.5 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40 sm:gap-3 sm:px-3",
)}
>
<div className="flex items-center gap-2 min-w-0">
<div className="flex min-w-0 shrink items-center gap-1.5 sm:gap-2">
<span className="flex items-center gap-1.5 text-xs text-[#FAFAFA] font-medium shrink-0">
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#369BFD] text-[#0B0F14] text-[11px] font-semibold">
{selectedDocumentIds.size}
</span>
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
<span className="hidden sm:inline">
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
</span>
</span>
{selectedDocumentIds.size === 0 && (
<span className="text-xs text-[#737373] truncate">
<span className="hidden truncate text-xs text-[#737373] sm:inline">
Tap documents to select
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<div className="flex min-w-0 shrink-0 items-center gap-0.5 sm:gap-1">
<button
type="button"
className={cn(
"text-xs px-2.5 h-7 rounded-full transition-colors cursor-pointer",
"h-7 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-2.5",
allVisibleSelected
? "text-[#737373] hover:text-white"
: "text-[#FAFAFA] hover:bg-white/5",
@ -672,7 +677,7 @@ export function MemoriesGrid({
<button
type="button"
className={cn(
"flex items-center gap-1 text-xs px-3 h-7 rounded-full transition-colors cursor-pointer",
"flex h-7 items-center gap-1 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-3",
selectedDocumentIds.size === 0 || isBulkDeleting
? "text-[#737373]/60 cursor-not-allowed"
: "text-red-400 hover:text-red-300 hover:bg-red-500/10",
@ -692,7 +697,7 @@ export function MemoriesGrid({
<button
type="button"
aria-label="Exit selection mode"
className="flex items-center gap-1 text-xs px-3 h-7 rounded-full text-[#737373] hover:text-white hover:bg-white/5 transition-colors cursor-pointer"
className="flex h-7 items-center gap-1 rounded-full px-2 text-xs text-[#737373] transition-colors hover:text-white hover:bg-white/5 cursor-pointer sm:px-3"
onClick={onClearSelection}
>
<XIcon className="size-3" />
@ -775,12 +780,16 @@ export function MemoriesGrid({
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={loadMoreDocuments}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onToggleSelection={onToggleSelection}
/>
) : (
<Masonry
key={masonryKey}
items={masonryItems}
render={renderMasonryItem}
itemKey={getMasonryItemKey}
columnGutter={0}
rowGutter={0}
columnWidth={260}

View file

@ -1,11 +1,14 @@
"use client"
import { useRef, useCallback } from "react"
import { useRef, useCallback, useEffect, useMemo, useState } from "react"
import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Maximize2, Plus, Loader2 } from "lucide-react"
import { Maximize2, Plus, Loader2, X } from "lucide-react"
import { useProject } from "@/stores"
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
import { TextEditor } from "./text-editor"
interface QuickNoteCardProps {
onSave: (content: string) => void
@ -13,33 +16,74 @@ interface QuickNoteCardProps {
isSaving?: boolean
}
type NoteRect = {
left: number
top: number
width: number
height: number
}
function getExpandedRect(source: NoteRect): NoteRect {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const margin = viewportWidth < 768 ? 16 : 32
const availableWidth = viewportWidth - source.left - margin
const availableHeight = viewportHeight - source.top - margin
const width = Math.min(1008, Math.max(source.width, availableWidth))
const height = Math.min(720, Math.max(source.height, availableHeight))
return {
left: source.left,
top: source.top,
width,
height,
}
}
export function QuickNoteCard({
onSave,
onMaximize,
isSaving = false,
}: QuickNoteCardProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const wasSavingRef = useRef(isSaving)
const { selectedProject } = useProject()
const { draft, setDraft } = useQuickNoteDraft(selectedProject)
const [isExpanded, setIsExpanded] = useState(false)
const [sourceRect, setSourceRect] = useState<NoteRect | null>(null)
const [targetRect, setTargetRect] = useState<NoteRect | null>(null)
const [expandedInitialContent, setExpandedInitialContent] = useState<
string | undefined
>(undefined)
const [isMounted, setIsMounted] = useState(false)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraft(e.target.value)
(content: string) => {
setDraft(content)
},
[setDraft],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault()
if (draft.trim() && !isSaving) {
onSave(draft)
}
}
},
[draft, isSaving, onSave],
)
const handleExpand = useCallback(() => {
const rect = cardRef.current?.getBoundingClientRect()
if (!rect) return
const nextSourceRect = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
setSourceRect(nextSourceRect)
setTargetRect(getExpandedRect(nextSourceRect))
setExpandedInitialContent(draft || undefined)
setIsExpanded(true)
}, [draft])
const handleClose = useCallback(() => {
setIsExpanded(false)
}, [])
const handleSaveClick = useCallback(() => {
if (draft.trim() && !isSaving) {
@ -48,112 +92,278 @@ export function QuickNoteCard({
}, [draft, isSaving, onSave])
const handleMaximizeClick = useCallback(() => {
setIsExpanded(false)
onMaximize(draft)
}, [draft, onMaximize])
const canSave = draft.trim().length > 0 && !isSaving
const previewText = useMemo(() => {
const trimmed = draft.trim()
if (!trimmed) return null
return trimmed.replace(/\s+/g, " ")
}, [draft])
useEffect(() => {
setIsMounted(true)
}, [])
useEffect(() => {
if (!isExpanded || !sourceRect) return
const handleResize = () => setTargetRect(getExpandedRect(sourceRect))
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [isExpanded, sourceRect])
useEffect(() => {
if (!isExpanded) return
const previousOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
handleClose()
}
}
document.addEventListener("keydown", handleGlobalKeyDown)
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener("keydown", handleGlobalKeyDown)
}
}, [isExpanded, handleClose])
useEffect(() => {
if (wasSavingRef.current && !isSaving && draft.trim().length === 0) {
setIsExpanded(false)
}
wasSavingRef.current = isSaving
}, [draft, isSaving])
return (
<div
className="bg-[#1B1F24] rounded-[22px] p-1"
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<>
<div
id="quick-note-inner"
className="bg-[#0B1017] rounded-[18px] p-3 relative"
ref={cardRef}
className="bg-[#1B1F24] rounded-[22px] p-1"
style={{
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<button
type="button"
onClick={handleMaximizeClick}
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Expand to full screen"
>
<Maximize2 className="size-[14px]" />
</button>
<textarea
ref={textareaRef}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Start writing..."
disabled={isSaving}
className={cn(
dmSansClassName(),
"w-full h-[120px] bg-transparent resize-none outline-none text-[12px] leading-normal text-white placeholder:text-[#737373] pr-5 disabled:opacity-50",
)}
/>
<div
id="quick-note-action-bar"
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
id="quick-note-inner"
className="bg-[#0B1017] rounded-[18px] p-3 relative"
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
}}
>
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
onClick={handleMaximizeClick}
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Open full screen note"
>
<span className="flex items-center gap-1.5">
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</span>
<Maximize2 className="size-[14px]" />
</button>
<span
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
)}
>
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<button
type="button"
onClick={handleExpand}
disabled={isSaving}
className="w-full h-[120px] cursor-text text-left disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Expand quick note"
>
<span className="flex h-full flex-col pr-5">
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
"line-clamp-4 text-[12px] leading-normal text-[#D7DEE8]",
!previewText && "text-[#737373]",
)}
>
Enter
{previewText ?? "Start writing..."}
</span>
</span>
</button>
<div
id="quick-note-action-bar"
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="flex items-center gap-1.5">
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</span>
<span
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
)}
>
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
)}
>
Enter
</span>
</span>
</button>
</div>
</div>
</div>
</div>
{isMounted &&
createPortal(
<AnimatePresence>
{isExpanded && sourceRect && targetRect && (
<div className="fixed inset-0 z-[100]">
<motion.button
type="button"
aria-label="Close quick note"
className="absolute inset-0 cursor-default bg-[#05080D]/60 backdrop-blur-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
onClick={handleClose}
/>
<motion.div
role="dialog"
aria-modal="true"
aria-label="New quick note"
className="absolute overflow-hidden rounded-[22px] bg-[#1B1F24] p-1"
initial={sourceRect}
animate={targetRect}
exit={sourceRect}
transition={{
type: "spring",
stiffness: 420,
damping: 42,
mass: 0.9,
}}
style={{
boxShadow:
"0 28px 80px rgba(0, 0, 0, 0.55), 0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<div
className="flex size-full flex-col rounded-[18px] bg-[#0B1017]"
style={{
boxShadow:
"inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
}}
>
<header className="flex shrink-0 justify-end border-b border-[#202A36]/70 px-5 py-4 md:px-7">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleMaximizeClick}
className="flex size-8 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
aria-label="Open full screen note"
>
<Maximize2 className="size-4" />
</button>
<button
type="button"
onClick={handleClose}
className="flex size-8 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
aria-label="Close quick note"
>
<X className="size-4" />
</button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-auto px-5 py-5 md:px-7 md:py-6">
<TextEditor
content={expandedInitialContent}
onContentChange={handleChange}
onSubmit={handleSaveClick}
debounceMs={0}
autoFocus
placeholder="Start writing..."
/>
</div>
<footer className="flex shrink-0 justify-center border-t border-[#202A36]/70 px-4 py-4">
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"bg-[#1B1F24] rounded-[8px] px-4 py-2.5 flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[14px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</button>
</footer>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
)}
</>
)
}

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,13 @@ import TaskList from "@tiptap/extension-task-list"
import TaskItem from "@tiptap/extension-task-item"
import { cx } from "class-variance-authority"
const placeholder = Placeholder.configure({
placeholder: 'Write, paste anything or type "/" for commands...',
})
const DEFAULT_PLACEHOLDER = 'Write, paste anything or type "/" for commands...'
function createPlaceholder(placeholderText = DEFAULT_PLACEHOLDER) {
return Placeholder.configure({
placeholder: placeholderText,
})
}
const taskList = TaskList.configure({
HTMLAttributes: {
@ -82,11 +86,15 @@ const starterKit = StarterKit.configure({
gapcursor: false,
})
export const defaultExtensions = [
starterKit,
placeholder,
link,
image,
taskList,
taskItem,
]
export function createDefaultExtensions(placeholderText?: string) {
return [
starterKit,
createPlaceholder(placeholderText),
link,
image,
taskList,
taskItem,
]
}
export const defaultExtensions = createDefaultExtensions()

View file

@ -4,30 +4,36 @@ import { useEditor, EditorContent } from "@tiptap/react"
import { BubbleMenu } from "@tiptap/react/menus"
import type { Editor } from "@tiptap/core"
import { Markdown } from "@tiptap/markdown"
import { useRef, useEffect, useCallback } from "react"
import { defaultExtensions } from "./extensions"
import { useRef, useEffect, useCallback, useMemo } from "react"
import { createDefaultExtensions } from "./extensions"
import { slashCommand } from "./suggestions"
import { Bold, Italic, Code } from "lucide-react"
import { useDebouncedCallback } from "use-debounce"
import { cn } from "@lib/utils"
const extensions = [...defaultExtensions, slashCommand, Markdown]
export function TextEditor({
content: initialContent,
onContentChange,
onSubmit,
debounceMs = 500,
autoFocus = false,
placeholder,
}: {
content: string | undefined
onContentChange: (content: string) => void
onSubmit: () => void
debounceMs?: number
autoFocus?: boolean
placeholder?: string
}) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onSubmitRef = useRef(onSubmit)
const hasUserEditedRef = useRef(false)
const extensions = useMemo(
() => [...createDefaultExtensions(placeholder), slashCommand, Markdown],
[placeholder],
)
useEffect(() => {
onSubmitRef.current = onSubmit
@ -92,6 +98,16 @@ export function TextEditor({
}
}, [editor, initialContent])
useEffect(() => {
if (!editor || !autoFocus) return
const id = window.setTimeout(() => {
editor.commands.focus("end")
}, 0)
return () => window.clearTimeout(id)
}, [editor, autoFocus])
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (target.closest(".ProseMirror")) {

View file

@ -111,7 +111,7 @@ function CommandMenu({
if (!mounted) return null
return createPortal(
<div ref={refs.setFloating} style={floatingStyles} className="z-50">
<div ref={refs.setFloating} style={floatingStyles} className="z-[120]">
<CommandList
items={items}
command={command}

View file

@ -1,13 +1,14 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { AnimatePresence, motion } from "motion/react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { SyncLogoIcon } from "@ui/assets/icons"
import { DocumentIcon } from "@/components/document-icon"
import { ChevronDownIcon } from "lucide-react"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -100,6 +101,39 @@ function getPreviewText(doc: DocumentWithMemories): string {
return doc.summary || doc.content || doc.title || ""
}
function isTemporaryId(id: string | null | undefined): boolean {
if (!id) return false
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
function SelectionBox({
isSelected,
isPartial = false,
}: {
isSelected: boolean
isPartial?: boolean
}) {
return (
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
isSelected
? "border-[#369BFD] bg-[#369BFD]"
: isPartial
? "border-[#369BFD] bg-[#369BFD]/20"
: "border-[#737373] bg-transparent",
)}
aria-hidden
>
{isSelected ? (
<CheckIcon className="size-3 text-white" strokeWidth={3} />
) : isPartial ? (
<span className="h-0.5 w-2 rounded-full bg-[#369BFD]" />
) : null}
</span>
)
}
// ─── Grouped data structures ─────────────────────────────────────────────────
type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
@ -126,7 +160,7 @@ function groupDocuments(
}
return periodOrder.map((label) => {
const docs = periodMap.get(label)!
const docs = periodMap.get(label) ?? []
const categoryMap = new Map<
string,
{ info: CategoryInfo; docs: DocumentWithMemories[] }
@ -144,9 +178,9 @@ function groupDocuments(
return {
label,
typeGroups: categoryOrder.map((key) => {
const entry = categoryMap.get(key)!
return { categoryInfo: entry.info, docs: entry.docs }
typeGroups: categoryOrder.flatMap((key) => {
const entry = categoryMap.get(key)
return entry ? [{ categoryInfo: entry.info, docs: entry.docs }] : []
}),
}
})
@ -157,30 +191,58 @@ function groupDocuments(
function TimelineCard({
doc,
onOpenDocument,
isSelectionMode = false,
isSelected = false,
onToggleSelection,
indent = false,
isLast = false,
}: {
doc: DocumentWithMemories
onOpenDocument: (doc: DocumentWithMemories) => void
isSelectionMode?: boolean
isSelected?: boolean
onToggleSelection?: (doc: DocumentWithMemories) => void
indent?: boolean
isLast?: boolean
}) {
const preview = getPreviewText(doc)
const typeLabel = doc.type
? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ")
: "Document"
const totalMemories = doc.memoryEntries.length
const canSelect = !isTemporaryId(doc.id) && !isTemporaryId(doc.customId)
const handleClick = () => {
if (isSelectionMode && canSelect) {
onToggleSelection?.(doc)
return
}
onOpenDocument(doc)
}
return (
<button
type="button"
className={cn(
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
"relative w-full text-left px-3 py-3 cursor-pointer transition-colors sm:px-4",
indent
? "bg-transparent hover:bg-white/[0.04]"
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
? "bg-[#121820] hover:bg-[#17202A]"
: "rounded-xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D] sm:rounded-2xl",
indent && !isLast && "border-b border-[#252B35]/70",
indent && isLast && "rounded-b-xl sm:rounded-b-2xl",
isSelectionMode && canSelect && "pl-14 sm:pl-16",
isSelectionMode && isSelected && "border-[#369BFD]/70 bg-[#00173C]/45",
dmSansClassName(),
)}
onClick={() => onOpenDocument(doc)}
onClick={handleClick}
aria-pressed={isSelectionMode ? isSelected : undefined}
>
{isSelectionMode && canSelect && (
<span className="absolute left-5 top-4 z-10">
<SelectionBox isSelected={isSelected} />
</span>
)}
{/* Type label */}
<div className="flex items-center gap-1.5 mb-2">
<DocumentIcon
@ -243,15 +305,23 @@ function GroupCard({
isExpanded,
onToggle,
onOpenDocument,
isSelectionMode,
selectedDocumentIds,
onToggleSelection,
expandKey,
}: {
group: TypeGroup
isExpanded: boolean
onToggle: () => void
onOpenDocument: (doc: DocumentWithMemories) => void
isSelectionMode: boolean
selectedDocumentIds: Set<string>
onToggleSelection?: (documentId: string) => void
expandKey: string
}) {
const firstDoc = group.docs[0]!
const firstDoc = group.docs[0]
if (!firstDoc) return null
const preview = getPreviewText(firstDoc)
const count = group.docs.length
const { label, singularLabel } = group.categoryInfo
@ -260,75 +330,153 @@ function GroupCard({
(sum, d) => sum + d.memoryEntries.length,
0,
)
const selectableDocs = group.docs.filter(
(doc) => !isTemporaryId(doc.id) && !isTemporaryId(doc.customId) && doc.id,
)
const selectedCount = selectableDocs.filter(
(doc) => doc.id && selectedDocumentIds.has(doc.id),
).length
const isGroupSelected =
selectableDocs.length > 0 && selectedCount === selectableDocs.length
const isGroupPartial = selectedCount > 0 && !isGroupSelected
const handleGroupSelect = () => {
for (const doc of selectableDocs) {
if (!doc.id) continue
const shouldToggle = isGroupSelected
? selectedDocumentIds.has(doc.id)
: !selectedDocumentIds.has(doc.id)
if (shouldToggle) onToggleSelection?.(doc.id)
}
}
return (
<div>
<button
type="button"
className={cn(
"w-full text-left rounded-2xl px-4 py-3 cursor-pointer transition-colors",
"border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
"flex items-center justify-between gap-3",
isExpanded && "rounded-b-none border-b-transparent",
dmSansClassName(),
)}
onClick={onToggle}
aria-expanded={isExpanded}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<DocumentIcon
type={firstDoc.type}
source={firstDoc.source ?? undefined}
url={firstDoc.url ?? undefined}
className="size-3.5 shrink-0 opacity-60"
/>
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
{countLabel}
</span>
{preview && (
<span className="text-[12px] text-white/35 truncate">
· {preview}
</span>
)}
{totalMemories > 0 && (
<span
className="text-[11px] font-medium shrink-0 ml-auto"
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{totalMemories}
</span>
)}
</div>
<ChevronDownIcon
className={cn(
"size-3.5 text-white/20 shrink-0 transition-transform duration-200",
isExpanded && "rotate-180",
)}
/>
</button>
{isExpanded && (
<div
id={`group-${expandKey}`}
className="border border-t-0 border-[#252B35] rounded-b-2xl overflow-hidden divide-y divide-[#252B35]"
>
{group.docs.map((doc) => (
<TimelineCard
key={doc.id}
doc={doc}
onOpenDocument={onOpenDocument}
indent
/>
))}
</div>
<div
className={cn(
"overflow-hidden rounded-xl border border-[#252B35] bg-[#1B1F24] sm:rounded-2xl",
isSelectionMode &&
(isGroupSelected || isGroupPartial) &&
"border-[#369BFD]/70 bg-[#00173C]/45",
)}
>
<div
className={cn(
"flex w-full items-stretch transition-colors hover:bg-[#21262D]",
isExpanded && "border-b border-[#252B35]",
isSelectionMode &&
(isGroupSelected || isGroupPartial) &&
"bg-[#00173C]/45",
)}
>
{isSelectionMode && selectableDocs.length > 0 && (
<button
type="button"
className="flex w-14 shrink-0 cursor-pointer items-start justify-center py-4 sm:w-16"
onClick={handleGroupSelect}
aria-label={isGroupSelected ? "Deselect group" : "Select group"}
aria-pressed={isGroupSelected}
>
<SelectionBox
isSelected={isGroupSelected}
isPartial={isGroupPartial}
/>
</button>
)}
<button
type="button"
className={cn(
"flex min-w-0 flex-1 cursor-pointer items-start justify-between gap-2 py-3 pr-3 text-left sm:items-center sm:gap-3 sm:pr-4",
isSelectionMode && selectableDocs.length > 0
? "pl-1"
: "pl-3 sm:pl-4",
dmSansClassName(),
)}
onClick={onToggle}
aria-expanded={isExpanded}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2.5 gap-y-1">
<DocumentIcon
type={firstDoc.type}
source={firstDoc.source ?? undefined}
url={firstDoc.url ?? undefined}
className="size-3.5 shrink-0 opacity-60"
/>
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
{countLabel}
</span>
{preview && (
<span className="basis-full text-[12px] leading-relaxed text-white/35 line-clamp-2 sm:basis-auto sm:truncate sm:leading-normal">
· {preview}
</span>
)}
{totalMemories > 0 && (
<span
className="shrink-0 text-[11px] font-medium sm:ml-auto"
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{totalMemories}
</span>
)}
</div>
<ChevronDownIcon
className={cn(
"mt-0.5 size-3.5 text-white/20 shrink-0 transition-transform duration-200 sm:mt-0",
isExpanded && "rotate-180",
)}
/>
</button>
</div>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
id={`group-${expandKey}`}
className="overflow-hidden bg-[#121820] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ height: 0, opacity: 0, y: -4 }}
animate={{ height: "auto", opacity: 1, y: 0 }}
exit={{ height: 0, opacity: 0, y: -3 }}
transition={{
height: { duration: 0.28, ease: [0.4, 0, 0.2, 1] },
opacity: { duration: 0.18 },
y: { duration: 0.22, ease: [0.4, 0, 0.2, 1] },
}}
>
{group.docs.map((doc, index) => (
<motion.div
key={doc.id}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -3 }}
transition={{
duration: 0.18,
delay: Math.min(index * 0.025, 0.12),
ease: [0.4, 0, 0.2, 1],
}}
>
<TimelineCard
doc={doc}
onOpenDocument={onOpenDocument}
isSelectionMode={isSelectionMode}
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
onToggleSelection={(doc) => {
if (doc.id) onToggleSelection?.(doc.id)
}}
indent
isLast={index === group.docs.length - 1}
/>
</motion.div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@ -341,6 +489,9 @@ interface TimelineViewProps {
hasNextPage?: boolean
isFetchingNextPage?: boolean
onLoadMore?: () => void
isSelectionMode?: boolean
selectedDocumentIds?: Set<string>
onToggleSelection?: (documentId: string) => void
}
export function TimelineView({
@ -349,6 +500,9 @@ export function TimelineView({
hasNextPage,
isFetchingNextPage,
onLoadMore,
isSelectionMode = false,
selectedDocumentIds = new Set(),
onToggleSelection,
}: TimelineViewProps) {
const [now] = useState(() => new Date())
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
@ -378,50 +532,131 @@ export function TimelineView({
}, [])
const periodGroups = groupDocuments(documents, now)
const handleTimelineCardSelection = useCallback(
(doc: DocumentWithMemories) => {
if (doc.id) onToggleSelection?.(doc.id)
},
[onToggleSelection],
)
return (
<div
className={cn(
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
"w-full max-w-[820px] mx-auto py-3 pb-12 space-y-5 sm:py-4 sm:space-y-7",
dmSansClassName(),
)}
>
{periodGroups.map((period) => (
<div key={period.label} className="grid grid-cols-[88px_1fr] gap-x-4">
<div className="pt-3 text-right shrink-0">
<span className="text-[10px] text-white/30 font-medium uppercase tracking-[0.15em] leading-none">
{period.label}
</span>
</div>
{periodGroups.map((period, periodIndex) => {
const periodHasExpandedGroup = period.typeGroups.some((group) =>
expandedGroups.has(`${period.label}::${group.categoryInfo.key}`),
)
<div className="space-y-1.5 min-w-0">
{period.typeGroups.map((group) => {
const expandKey = `${period.label}::${group.categoryInfo.key}`
return (
<div
key={period.label}
className="grid grid-cols-[22px_minmax(0,1fr)] gap-x-2 gap-y-2 sm:grid-cols-[96px_24px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
>
<div className="col-start-2 row-start-1 shrink-0 pt-0 sm:col-start-1 sm:row-start-1 sm:pt-3 sm:text-right">
<span className="inline-flex rounded-full border border-[#252B35] bg-[#0D121A] px-2.5 py-1 text-[10px] font-medium uppercase leading-none tracking-[0.15em] text-white/45 sm:border-transparent sm:bg-transparent sm:px-0 sm:py-0 sm:text-white/30">
{period.label}
</span>
</div>
<div className="relative col-start-1 row-start-1 row-span-2 flex justify-center sm:col-start-2 sm:row-start-1 sm:row-span-1">
<div
className={cn(
"absolute top-4 bottom-[-20px] w-px bg-[#1F2835] sm:top-5 sm:bottom-[-28px]",
periodIndex === periodGroups.length - 1 && "bottom-2",
)}
/>
<motion.div
className={cn(
"absolute top-4 bottom-[-20px] w-px origin-top bg-linear-to-b from-[#369BFD] via-[#369BFD]/70 to-transparent sm:top-5 sm:bottom-[-28px]",
periodIndex === periodGroups.length - 1 && "bottom-2",
)}
initial={false}
animate={{
scaleY: periodHasExpandedGroup ? 1 : 0,
opacity: periodHasExpandedGroup ? 1 : 0,
}}
transition={{ duration: 0.34, ease: [0.4, 0, 0.2, 1] }}
/>
<motion.div
className="relative mt-1.5 flex size-3 items-center justify-center rounded-full border border-[#369BFD]/35 bg-[#0D121A] shadow-[0_0_0_4px_rgba(54,155,253,0.06)] sm:mt-3"
initial={false}
animate={{
borderColor: periodHasExpandedGroup
? "rgba(54,155,253,0.8)"
: "rgba(54,155,253,0.35)",
boxShadow: periodHasExpandedGroup
? "0 0 0 7px rgba(54,155,253,0.09)"
: "0 0 0 4px rgba(54,155,253,0.06)",
}}
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
>
<AnimatePresence>
{periodHasExpandedGroup && (
<motion.div
className="absolute inset-[-6px] rounded-full border border-[#369BFD]/35"
initial={{ scale: 0.6, opacity: 0.8 }}
animate={{ scale: 1.45, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.52,
ease: [0.2, 0.8, 0.2, 1],
}}
/>
)}
</AnimatePresence>
<motion.div
className="size-1.5 rounded-full bg-[#369BFD]"
initial={false}
animate={{ scale: periodHasExpandedGroup ? 1.15 : 1 }}
transition={{ duration: 0.24, ease: [0.4, 0, 0.2, 1] }}
/>
</motion.div>
</div>
<div className="col-start-2 row-start-2 min-w-0 space-y-1.5 sm:col-start-3 sm:row-start-1">
{period.typeGroups.map((group) => {
const expandKey = `${period.label}::${group.categoryInfo.key}`
if (group.docs.length === 1) {
const doc = group.docs[0]
if (!doc) return null
return (
<TimelineCard
key={expandKey}
doc={doc}
onOpenDocument={onOpenDocument}
isSelectionMode={isSelectionMode}
isSelected={
doc.id ? selectedDocumentIds.has(doc.id) : false
}
onToggleSelection={handleTimelineCardSelection}
/>
)
}
if (group.docs.length === 1) {
return (
<TimelineCard
<GroupCard
key={expandKey}
doc={group.docs[0]!}
group={group}
expandKey={expandKey}
isExpanded={expandedGroups.has(expandKey)}
onToggle={() => toggleGroup(expandKey)}
onOpenDocument={onOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onToggleSelection={onToggleSelection}
/>
)
}
return (
<GroupCard
key={expandKey}
group={group}
expandKey={expandKey}
isExpanded={expandedGroups.has(expandKey)}
onToggle={() => toggleGroup(expandKey)}
onOpenDocument={onOpenDocument}
/>
)
})}
})}
</div>
</div>
</div>
))}
)
})}
<div ref={sentinelRef} className="h-1" />
</div>

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 {

View file

@ -28,6 +28,7 @@ interface AuthContextType {
setActiveOrg: (orgSlug: string) => Promise<void>
clearActiveOrg: () => void
updateOrgMetadata: (partial: Record<string, unknown>) => void
refetchActiveOrg: () => Promise<Organization | null>
refetchOrganizations: () => Promise<unknown>
}
@ -81,6 +82,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
}, [])
const refetchActiveOrg = useCallback(async () => {
const full = await authClient.organization.getFullOrganization()
const nextOrg = full?.data ?? null
setOrg(nextOrg)
return nextOrg
}, [])
useEffect(() => {
if (isSessionPending) return
@ -198,6 +206,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setActiveOrg,
clearActiveOrg,
updateOrgMetadata,
refetchActiveOrg,
refetchOrganizations,
}}
>

View file

@ -1,6 +1,6 @@
{
"name": "@supermemory/memory-graph",
"version": "0.2.1",
"version": "0.2.2",
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
"type": "module",
"main": "./src/index.tsx",

View file

@ -200,11 +200,7 @@ export const Legend = memo(function Legend({
const connectionCount = edges.length
const outerStyle: React.CSSProperties = {
position: "absolute",
zIndex: 20,
overflow: "hidden",
bottom: 16,
left: 16,
width: 214,
}

View file

@ -642,11 +642,15 @@ export function MemoryGraph({
justifyContent: "center",
}
const navControlsStyle: React.CSSProperties = {
const bottomLeftStackStyle: React.CSSProperties = {
position: "absolute",
bottom: isCompactViewport ? 148 : 72,
bottom: 16,
left: 16,
zIndex: 15,
zIndex: 20,
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 8,
}
return (
@ -703,28 +707,26 @@ export function MemoryGraph({
/>
)}
<div>
{containerSize.width > 0 && (
<div style={navControlsStyle}>
<NavigationControls
nodes={nodes}
compact={isCompactViewport}
onAutoFit={handleAutoFit}
onCenter={handleCenter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
zoomLevel={zoomDisplay}
colors={colors}
/>
</div>
)}
<Legend
colors={colors}
edges={edges}
isLoading={isLoading}
nodes={nodes}
/>
</div>
{containerSize.width > 0 && (
<div style={bottomLeftStackStyle}>
<NavigationControls
nodes={nodes}
compact={isCompactViewport}
onAutoFit={handleAutoFit}
onCenter={handleCenter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
zoomLevel={zoomDisplay}
colors={colors}
/>
<Legend
colors={colors}
edges={edges}
isLoading={isLoading}
nodes={nodes}
/>
</div>
)}
</div>
</div>
)

View file

@ -36,7 +36,7 @@ function DialogOverlay({
return (
<DialogPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/65 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-[4px]",
className,
)}
data-slot="dialog-overlay"