mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(mcp): keep save targets writable
This commit is contained in:
parent
e651045ac5
commit
c6bed23865
5 changed files with 152 additions and 13 deletions
101
apps/mcp/src/widget/lib/writableTag.test.ts
Normal file
101
apps/mcp/src/widget/lib/writableTag.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { createElement, type ReactElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { McpAppPreviewProvider } from "../McpAppProvider"
|
||||
import { Save } from "../views/Save"
|
||||
import { Upload } from "../views/Upload"
|
||||
import {
|
||||
isWritableTag,
|
||||
preferredWritableTag,
|
||||
retainedWritableTag,
|
||||
} from "./writableTag"
|
||||
|
||||
describe("isWritableTag", () => {
|
||||
it("accepts only tags in the writable set", () => {
|
||||
expect(isWritableTag("team", ["personal", "team"])).toBe(true)
|
||||
expect(isWritableTag("read-only", ["personal", "team"])).toBe(false)
|
||||
expect(isWritableTag(null, ["personal", "team"])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function renderWithApp(view: ReactElement): string {
|
||||
return renderToStaticMarkup(createElement(McpAppPreviewProvider, null, view))
|
||||
}
|
||||
|
||||
describe("preferredWritableTag", () => {
|
||||
it("keeps the candidate when it is writable", () => {
|
||||
expect(preferredWritableTag("team", ["personal", "team"])).toBe("team")
|
||||
})
|
||||
|
||||
it("falls back when the candidate is not writable", () => {
|
||||
expect(preferredWritableTag("read-only", ["personal", "team"])).toBe(
|
||||
"personal",
|
||||
)
|
||||
})
|
||||
|
||||
it("uses the first writable tag when there is no candidate", () => {
|
||||
expect(preferredWritableTag(null, ["personal", "team"])).toBe("personal")
|
||||
})
|
||||
|
||||
it("returns null when no writable tags are available", () => {
|
||||
expect(preferredWritableTag("read-only", [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("retainedWritableTag", () => {
|
||||
it("keeps a selection while it remains writable", () => {
|
||||
expect(retainedWritableTag("team", ["personal", "team"])).toBe("team")
|
||||
})
|
||||
|
||||
it("clears a selection instead of silently retargeting it", () => {
|
||||
expect(retainedWritableTag("team", ["personal"])).toBeNull()
|
||||
expect(retainedWritableTag(null, ["personal"])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writable space views", () => {
|
||||
const props = {
|
||||
activeTag: "read-only",
|
||||
writableTags: ["sm_project_personal"],
|
||||
onAdvance: () => {},
|
||||
onError: () => {},
|
||||
}
|
||||
|
||||
it("selects a writable fallback in the save form", () => {
|
||||
const markup = renderWithApp(
|
||||
createElement(Save, { ...props, prefill: "Remember this" }),
|
||||
)
|
||||
|
||||
expect(markup).toContain("Personal")
|
||||
expect(markup).not.toContain("Select space")
|
||||
})
|
||||
|
||||
it("selects a writable fallback in the upload form", () => {
|
||||
const markup = renderWithApp(createElement(Upload, props))
|
||||
|
||||
expect(markup).toContain("Personal")
|
||||
expect(markup).not.toContain("Select space")
|
||||
})
|
||||
|
||||
it("disables saving when no writable spaces are available", () => {
|
||||
const markup = renderWithApp(
|
||||
createElement(Save, {
|
||||
...props,
|
||||
prefill: "Remember this",
|
||||
writableTags: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(markup).not.toContain("Select space")
|
||||
expect(markup).toContain('disabled=""')
|
||||
})
|
||||
|
||||
it("disables uploading when no writable spaces are available", () => {
|
||||
const markup = renderWithApp(
|
||||
createElement(Upload, { ...props, writableTags: [] }),
|
||||
)
|
||||
|
||||
expect(markup).not.toContain("Select space")
|
||||
expect(markup).toContain('disabled=""')
|
||||
})
|
||||
})
|
||||
21
apps/mcp/src/widget/lib/writableTag.ts
Normal file
21
apps/mcp/src/widget/lib/writableTag.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export function isWritableTag(
|
||||
candidate: string | null | undefined,
|
||||
writableTags: readonly string[],
|
||||
): candidate is string {
|
||||
return !!candidate && writableTags.includes(candidate)
|
||||
}
|
||||
|
||||
export function preferredWritableTag(
|
||||
candidate: string | null | undefined,
|
||||
writableTags: readonly string[],
|
||||
): string | null {
|
||||
if (isWritableTag(candidate, writableTags)) return candidate
|
||||
return writableTags[0] ?? null
|
||||
}
|
||||
|
||||
export function retainedWritableTag(
|
||||
candidate: string | null,
|
||||
writableTags: readonly string[],
|
||||
): string | null {
|
||||
return isWritableTag(candidate, writableTags) ? candidate : null
|
||||
}
|
||||
|
|
@ -11,6 +11,11 @@ import {
|
|||
} from "../design/ui"
|
||||
import { useApp } from "../hooks/useApp"
|
||||
import { formatTagLabel } from "../lib/formatTag"
|
||||
import {
|
||||
isWritableTag,
|
||||
preferredWritableTag,
|
||||
retainedWritableTag,
|
||||
} from "../lib/writableTag"
|
||||
|
||||
interface Props {
|
||||
activeTag?: string | null
|
||||
|
|
@ -31,16 +36,14 @@ export function Save({
|
|||
}: Props) {
|
||||
const { callTool, handoffToModel } = useApp()
|
||||
const [content, setContent] = useState(prefill ?? "")
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(
|
||||
activeTag ?? writableTags[0] ?? null,
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(() =>
|
||||
preferredWritableTag(activeTag, writableTags),
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTag && writableTags.length > 0) {
|
||||
setSelectedTag(writableTags[0])
|
||||
}
|
||||
}, [selectedTag, writableTags])
|
||||
setSelectedTag((current) => retainedWritableTag(current, writableTags))
|
||||
}, [writableTags])
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
|
|
@ -53,10 +56,11 @@ export function Save({
|
|||
)
|
||||
|
||||
const trimmed = content.trim()
|
||||
const canSave = trimmed.length > 0 && !!selectedTag && !saving
|
||||
const canSave =
|
||||
trimmed.length > 0 && isWritableTag(selectedTag, writableTags) && !saving
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave || !selectedTag) return
|
||||
if (!canSave || !isWritableTag(selectedTag, writableTags)) return
|
||||
setSaving(true)
|
||||
const result = await callTool(
|
||||
"save-memory",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
uploadPreparationSchema,
|
||||
uploadResponseSchema,
|
||||
|
|
@ -16,6 +16,11 @@ import {
|
|||
import { useApp } from "../hooks/useApp"
|
||||
import { formatTagLabel } from "../lib/formatTag"
|
||||
import { FileText, X } from "../lib/icons"
|
||||
import {
|
||||
isWritableTag,
|
||||
preferredWritableTag,
|
||||
retainedWritableTag,
|
||||
} from "../lib/writableTag"
|
||||
|
||||
interface Props {
|
||||
activeTag?: string | null
|
||||
|
|
@ -43,11 +48,15 @@ export function Upload({
|
|||
}: Props) {
|
||||
const { callTool, handoffToModel } = useApp()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(
|
||||
activeTag ?? writableTags[0] ?? null,
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(() =>
|
||||
preferredWritableTag(activeTag, writableTags),
|
||||
)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTag((current) => retainedWritableTag(current, writableTags))
|
||||
}, [writableTags])
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
writableTags.map((tag) => ({
|
||||
|
|
@ -58,10 +67,11 @@ export function Upload({
|
|||
[writableTags],
|
||||
)
|
||||
|
||||
const canUpload = !!file && !!selectedTag && !uploading
|
||||
const canUpload =
|
||||
!!file && isWritableTag(selectedTag, writableTags) && !uploading
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file || !selectedTag) return
|
||||
if (!file || !isWritableTag(selectedTag, writableTags)) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const preparation = await callTool(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
jsx: "automatic",
|
||||
},
|
||||
test: {
|
||||
include: ["e2e/**/*.test.ts", "src/**/*.test.ts"],
|
||||
fileParallelism: false,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue