diff --git a/apps/mcp/src/widget/lib/writableTag.test.ts b/apps/mcp/src/widget/lib/writableTag.test.ts new file mode 100644 index 00000000..f7199cbb --- /dev/null +++ b/apps/mcp/src/widget/lib/writableTag.test.ts @@ -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=""') + }) +}) diff --git a/apps/mcp/src/widget/lib/writableTag.ts b/apps/mcp/src/widget/lib/writableTag.ts new file mode 100644 index 00000000..3ceb43fc --- /dev/null +++ b/apps/mcp/src/widget/lib/writableTag.ts @@ -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 +} diff --git a/apps/mcp/src/widget/views/Save.tsx b/apps/mcp/src/widget/views/Save.tsx index 355f0036..b3b6e3a5 100644 --- a/apps/mcp/src/widget/views/Save.tsx +++ b/apps/mcp/src/widget/views/Save.tsx @@ -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( - activeTag ?? writableTags[0] ?? null, + const [selectedTag, setSelectedTag] = useState(() => + 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", diff --git a/apps/mcp/src/widget/views/Upload.tsx b/apps/mcp/src/widget/views/Upload.tsx index e388aa51..6b3f6d5a 100644 --- a/apps/mcp/src/widget/views/Upload.tsx +++ b/apps/mcp/src/widget/views/Upload.tsx @@ -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(null) - const [selectedTag, setSelectedTag] = useState( - activeTag ?? writableTags[0] ?? null, + const [selectedTag, setSelectedTag] = useState(() => + 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( diff --git a/apps/mcp/vitest.config.ts b/apps/mcp/vitest.config.ts index 704f98f9..95acfd1b 100644 --- a/apps/mcp/vitest.config.ts +++ b/apps/mcp/vitest.config.ts @@ -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,