Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions apps/mcp/src/widget/lib/writableTag.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 21 additions & 0 deletions apps/mcp/src/widget/lib/writableTag.ts
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 12 additions & 8 deletions apps/mcp/src/widget/views/Save.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
() =>
Expand All @@ -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",
Expand Down
20 changes: 15 additions & 5 deletions apps/mcp/src/widget/views/Upload.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import {
uploadPreparationSchema,
uploadResponseSchema,
Expand All @@ -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
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions apps/mcp/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down