mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Merge branch 'main' into account
This commit is contained in:
commit
f031b18c89
18 changed files with 2250 additions and 359 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, "_")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue