feat(web): add Cursor to Agents (#1361)

Adds Cursor projects to Agents spaces with source filters, legacy labels, icons, and Codex-style structured conversation rendering.

Tests: targeted Agents and plugin-document tests.
This commit is contained in:
ved015 2026-07-25 22:09:28 +00:00
parent 80af8c9043
commit 6f3c835e8f
7 changed files with 221 additions and 13 deletions

View file

@ -95,7 +95,12 @@ type Category = {
count: number
}
const AGENT_CATALOG_IDS = ["claude_code", "codex", "opencode"] as const
const AGENT_CATALOG_IDS = [
"claude_code",
"codex",
"opencode",
"cursor",
] as const
export function SelectSpacesModal({
isOpen,

View file

@ -7,7 +7,7 @@ import {
} from "./agent-space"
describe("Agents spaces", () => {
it("recognizes Claude, Codex, and OpenCode shared and legacy tags", () => {
it("recognizes shared and legacy tags from every unified agent", () => {
expect(isAgentContainerTag("repo_supermemory__0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("user_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("repo_supermemory")).toBe(true)
@ -18,6 +18,8 @@ describe("Agents spaces", () => {
expect(isAgentContainerTag("codex_user_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("opencode_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("opencode_user_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("cursor_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("cursor_user_0123456789abcdef")).toBe(true)
})
it("shows agent filters only for an Agents selection", () => {
@ -40,6 +42,7 @@ describe("Agents spaces", () => {
])
expect(agentSourceValues("codex")).toEqual(["codex"])
expect(agentSourceValues("opencode")).toEqual(["opencode"])
expect(agentSourceValues("cursor")).toEqual(["cursor"])
expect(agentSourceValues(null)).toBeUndefined()
})
@ -148,4 +151,25 @@ describe("Agents spaces", () => {
"claudecode_project_0123456789abcdef",
)
})
it("shows an unambiguous Cursor project label before metadata loads", () => {
const groups = groupAgentSpaces(
[{ containerTag: "cursor_project_0123456789abcdef" }],
new Map(),
)
expect(groups).toHaveLength(1)
expect(groups[0]?.label).toBe("Cursor project · 012345")
})
it("shows old Cursor personal memory without a Legacy prefix", () => {
const groups = groupAgentSpaces(
[{ containerTag: "cursor_user_fedcba9876543210" }],
new Map(),
)
expect(groups).toHaveLength(1)
expect(groups[0]?.label).toBe("Cursor personal · fedcba")
expect(groups[0]?.kind).toBe("legacy-personal")
})
})

View file

@ -5,7 +5,7 @@ export type AgentContainerKind =
| "legacy-personal"
| "legacy-project"
export type AgentSourceFilter = "claude-code" | "codex" | "opencode"
export type AgentSourceFilter = "claude-code" | "codex" | "opencode" | "cursor"
export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
value: AgentSourceFilter
@ -19,6 +19,7 @@ export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
},
{ value: "codex", label: "Codex", sources: ["codex"] },
{ value: "opencode", label: "OpenCode", sources: ["opencode"] },
{ value: "cursor", label: "Cursor", sources: ["cursor"] },
]
export type AgentSpaceMetadata = {
@ -63,6 +64,14 @@ const TAG_PATTERNS: Array<{
kind: "legacy-project",
pattern: /^opencode_project_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-personal",
pattern: /^cursor_user_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-project",
pattern: /^cursor_project_([0-9a-f]{6,64})$/i,
},
]
function matchAgentTag(containerTag: string): {
@ -127,7 +136,7 @@ function tagPriority(containerTag: string): number {
case "personal":
return 1
case "legacy-personal":
return containerTag.startsWith("claudecode_project_") ? 2 : 5
return containerTag.startsWith("claudecode_project_") ? 2 : 6
case "project":
return 3
case "legacy-project":
@ -146,17 +155,25 @@ function legacyGroupIdentity(
return { key: `tag:${containerTag}`, label: containerTag, kind: "project" }
}
// Old Codex and OpenCode personal containers were intentionally global.
// Old Codex, OpenCode, and Cursor personal containers were global.
// Even if the newest document has a project name, assigning the whole
// container to that project would mix memories from historical projects.
if (
containerTag.startsWith("codex_user_") ||
containerTag.startsWith("opencode_user_")
containerTag.startsWith("opencode_user_") ||
containerTag.startsWith("cursor_user_")
) {
const agent = containerTag.startsWith("codex_user_") ? "Codex" : "OpenCode"
const agent = containerTag.startsWith("codex_user_")
? "Codex"
: containerTag.startsWith("opencode_user_")
? "OpenCode"
: "Cursor"
return {
key: `legacy-personal:${containerTag}`,
label: `Legacy ${agent} personal`,
label:
agent === "Cursor"
? `Cursor personal · ${match.id.slice(0, 6)}`
: `Legacy ${agent} personal`,
kind: "legacy-personal",
}
}
@ -169,6 +186,14 @@ function legacyGroupIdentity(
}
}
if (containerTag.startsWith("cursor_project_")) {
return {
key: `cursor-project:${match.id.toLocaleLowerCase()}`,
label: `Cursor project · ${match.id.slice(0, 6)}`,
kind: "project",
}
}
if (
containerTag.startsWith("user_project_") ||
containerTag.startsWith("claudecode_project_") ||
@ -225,7 +250,7 @@ function addProjectToGroup<T extends { containerTag: string }>(
}
/**
* Collapse the physical Claude/Codex/OpenCode containers into one selectable
* Collapse the physical Claude/Codex/OpenCode/Cursor containers into one selectable
* Agents row per project. Every returned container tag remains real; the UI
* never writes to a synthetic "agents" tag.
*/
@ -280,7 +305,9 @@ export function groupAgentSpaces<T extends { containerTag: string }>(
addProjectToGroup(
grouped,
key,
projectName ?? identity.label,
identity.kind === "legacy-personal"
? identity.label
: (projectName ?? identity.label),
identity.kind,
project,
projectName,

View file

@ -46,6 +46,54 @@ describe("parsePluginDocument — session transcripts", () => {
expect(parsed?.pluginIconSrc).toBe("/images/plugins/claude-code.svg")
})
it("renders a new Cursor capture as structured conversation cards", () => {
const parsed = parsePluginDocument({
id: "doc_cursor",
title: "Cursor conversation",
content: [
"[Conversation cursor-session-1]",
"1. [user] Keep the API boundary stable",
"2. [assistant] I will preserve it.",
].join("\n"),
source: "cursor",
metadata: { sm_source: "cursor", type: "conversation" },
containerTags: ["repo_supermemory__0123456789abcdef"],
memoryEntries: [],
} as unknown as PluginDocumentInput)
expect(parsed?.pluginLabel).toBe("Cursor")
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
expect(parsed?.formatLabel).toBe("Conversation")
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[0]?.role).toBe("user")
expect(parsed?.messages[1]?.role).toBe("assistant")
})
it("renders old Cursor tags and transcripts without source metadata", () => {
const parsed = parsePluginDocument({
id: "doc_cursor_legacy",
title: "Cursor session",
content: [
"Cursor IDE session transcript:",
"User: Fix the renderer",
"with the existing card design.",
"Assistant: Implemented the parser.",
].join("\n"),
source: "api",
metadata: {},
containerTags: ["cursor_project_0123456789abcdef"],
memoryEntries: [],
} as unknown as PluginDocumentInput)
expect(parsed?.pluginLabel).toBe("Cursor")
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[0]?.text).toBe(
"Fix the renderer\nwith the existing card design.",
)
expect(parsed?.messages[1]?.text).toBe("Implemented the parser.")
})
it("keeps multi-line message bodies intact", () => {
const parsed = parsePluginDocument(
makeCodexSessionDocument(

View file

@ -89,6 +89,7 @@ function formatClientName(value: string | null | undefined): string | null {
if (lower === "claude desktop") return "Claude Desktop"
if (lower === "claude code") return "Claude Code"
if (lower === "opencode") return "OpenCode"
if (lower === "cursor") return "Cursor"
if (lower === "openclaw") return "OpenClaw"
if (lower === "hermes") return "Hermes"
if (lower === "amp") return "Amp"
@ -136,6 +137,12 @@ function pluginIdentityFromSource(
label: "OpenCode",
iconSrc: "/images/plugins/opencode.svg",
}
case "cursor":
return {
pluginId: "cursor",
label: "Cursor",
iconSrc: "/images/plugins/cursor.png",
}
case "amp":
return {
pluginId: "amp",
@ -164,7 +171,19 @@ function pluginIdentityFromSpace(
.filter((tag): tag is string => typeof tag === "string" && !!tag)
: []
for (const tag of [...containerTags, ...memorySpaceTags]) {
const allTags = [...containerTags, ...memorySpaceTags]
for (const tag of allTags) {
if (/^cursor_(?:user|project)_[0-9a-f]{6,64}$/i.test(tag)) {
return {
pluginId: "cursor",
label: "Cursor",
iconSrc: "/images/plugins/cursor.png",
projectId: tag.split("_").at(-1)?.slice(0, 6),
}
}
}
for (const tag of allTags) {
const plugin = detectPluginSpace(tag)
if (plugin) return plugin
}
@ -380,7 +399,7 @@ function parseSessionTranscript(
content: string,
config: {
kind: "codex-session" | "amp-thread" | "plugin-session"
headerLabel: "Session" | "Amp thread"
headerLabel: "Session" | "Amp thread" | "Conversation"
pluginLabel: string
pluginIconSrc?: string | null
formatLabel: string
@ -424,6 +443,61 @@ function parseSessionTranscript(
}
}
function parseLegacyCursorTranscript(
content: string,
plugin: PluginIdentity | null,
): ParsedPluginDocument | null {
if (
plugin?.pluginId !== "cursor" ||
!/^Cursor IDE session transcript:\s*/i.test(content)
) {
return null
}
const transcript = content.replace(/^Cursor IDE session transcript:\s*/i, "")
const messages: PluginDocumentMessage[] = []
const regex =
/^(User|Assistant):\s*([\s\S]*?)(?=^(?:User|Assistant):\s*|(?![\s\S]))/gim
for (const match of transcript.matchAll(regex)) {
const role =
match[1]?.toLowerCase() === "user"
? ("user" as const)
: ("assistant" as const)
const text = match[2]?.trim()
if (!text) continue
messages.push({
id: `${role}-${messages.length}`,
role,
text,
})
}
if (messages.length === 0) return null
const userCount = messages.filter((message) => message.role === "user").length
const assistantCount = messages.filter(
(message) => message.role === "assistant",
).length
const previewSource =
messages.find((message) => message.role === "user")?.text ??
messages[0]?.text ??
"Conversation"
return {
kind: "plugin-session",
pluginLabel: plugin.label,
pluginIconSrc: plugin.iconSrc ?? undefined,
formatLabel: "Conversation",
title: "Cursor conversation",
preview: takePreview(previewSource, 140),
summary: `${userCount} user message${userCount === 1 ? "" : "s"} and ${assistantCount} assistant message${assistantCount === 1 ? "" : "s"} captured from Cursor.`,
artifacts: [],
messages,
sections: [],
rawContent: content,
}
}
function parseRoleBlockTranscript(
content: string,
plugin: PluginIdentity | null,
@ -710,6 +784,26 @@ export function parsePluginDocument(
}
}
if (plugin?.pluginId === "cursor") {
const cursorSession = parseSessionTranscript(content, {
kind: "plugin-session",
headerLabel: "Conversation",
pluginLabel: plugin.label,
pluginIconSrc: plugin.iconSrc,
formatLabel: "Conversation",
})
if (cursorSession) {
if (clientName) {
cursorSession.clientLabel = "Client"
cursorSession.clientValue = clientName
}
return withIcon(cursorSession)
}
const legacyCursorSession = parseLegacyCursorTranscript(content, plugin)
if (legacyCursorSession) return withIcon(legacyCursorSession)
}
if (plugin?.pluginId === "amp") {
const ampThread = parseSessionTranscript(content, {
kind: "amp-thread",

View file

@ -8,6 +8,7 @@ export type PluginSpaceInfo = {
| "openclaw"
| "opencode"
| "codex"
| "cursor"
| "amp"
| "hermes"
label: string
@ -27,7 +28,14 @@ const PLUGINS: PluginDef[] = [
id: "agents",
label: "Agents",
iconSrc: null,
prefixes: ["user_project", "repo", "claudecode", "codex", "opencode"],
prefixes: [
"user_project",
"repo",
"claudecode",
"codex",
"opencode",
"cursor",
],
},
{
id: "openclaw",
@ -73,6 +81,7 @@ const PLUGIN_ICON_BY_LABEL: Record<string, string> = {
OpenClaw: "/images/plugins/openclaw.svg",
OpenCode: "/images/plugins/opencode.svg",
Codex: "/images/plugins/codex.png",
Cursor: "/images/plugins/cursor.png",
Hermes: "/images/plugins/hermes.svg",
}

View file

@ -63,5 +63,6 @@ export const agentSourceParam = parseAsStringLiteral([
"claude-code",
"codex",
"opencode",
"cursor",
] as const)
export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])