diff --git a/packages/app/assets/images/editor-apps/webstorm.png b/packages/app/assets/images/editor-apps/webstorm.png new file mode 100644 index 000000000..7837d566e Binary files /dev/null and b/packages/app/assets/images/editor-apps/webstorm.png differ diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx index a29341678..a0aef6c01 100644 --- a/packages/app/src/components/icons/editor-app-icons.tsx +++ b/packages/app/src/components/icons/editor-app-icons.tsx @@ -1,5 +1,10 @@ +import { SquareTerminal } from "lucide-react-native"; import { Image, type ImageSourcePropType } from "react-native"; -import type { EditorTargetId } from "@server/shared/messages"; +import { + isKnownEditorTargetId, + type EditorTargetId, + type KnownEditorTargetId, +} from "@server/shared/messages"; interface EditorAppIconProps { editorId: EditorTargetId; @@ -8,9 +13,10 @@ interface EditorAppIconProps { } /* eslint-disable @typescript-eslint/no-require-imports */ -const EDITOR_APP_IMAGES: Record = { +const EDITOR_APP_IMAGES: Record = { cursor: require("../../../assets/images/editor-apps/cursor.png"), vscode: require("../../../assets/images/editor-apps/vscode.png"), + webstorm: require("../../../assets/images/editor-apps/webstorm.png"), zed: require("../../../assets/images/editor-apps/zed.png"), finder: require("../../../assets/images/editor-apps/finder.png"), explorer: require("../../../assets/images/editor-apps/file-explorer.png"), @@ -18,10 +24,21 @@ const EDITOR_APP_IMAGES: Record = { }; /* eslint-enable @typescript-eslint/no-require-imports */ +export function hasBundledEditorAppIcon( + editorId: EditorTargetId, +): editorId is KnownEditorTargetId { + return isKnownEditorTargetId(editorId); +} + export function EditorAppIcon({ editorId, size = 16, + color, }: EditorAppIconProps) { + if (!hasBundledEditorAppIcon(editorId)) { + return ; + } + return ( { expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer"); }); + it("keeps unknown editor ids when they are still available", () => { + expect(resolvePreferredEditorId(["unknown-editor", "cursor"], "unknown-editor")).toBe( + "unknown-editor", + ); + }); + it("returns null when no editors are available", () => { expect(resolvePreferredEditorId([], "cursor")).toBeNull(); }); diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts index 0b0f68a31..c63191783 100644 --- a/packages/server/src/server/editor-targets.test.ts +++ b/packages/server/src/server/editor-targets.test.ts @@ -3,7 +3,7 @@ import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets describe("editor-targets", () => { it("lists available editors in deterministic order", async () => { - const available = new Set(["code", "cursor", "explorer"]); + const available = new Set(["code", "cursor", "explorer", "webstorm"]); const editors = await listAvailableEditorTargets({ platform: "win32", @@ -13,6 +13,7 @@ describe("editor-targets", () => { expect(editors).toEqual([ { id: "cursor", label: "Cursor" }, { id: "vscode", label: "VS Code" }, + { id: "webstorm", label: "WebStorm" }, { id: "explorer", label: "Explorer" }, ]); }); @@ -97,4 +98,19 @@ describe("editor-targets", () => { ), ).rejects.toThrow("Editor target unavailable: Finder"); }); + + it("rejects unknown editor ids", async () => { + await expect( + openInEditorTarget( + { + editorId: "unknown-editor", + path: "/tmp/repo", + }, + { + existsSync: () => true, + findExecutable: () => null, + }, + ), + ).rejects.toThrow("Unknown editor target: unknown-editor"); + }); }); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts index f5e388c0b..b2549dd7f 100644 --- a/packages/server/src/server/editor-targets.ts +++ b/packages/server/src/server/editor-targets.ts @@ -1,7 +1,11 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { posix, win32 } from "node:path"; -import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js"; +import type { + EditorTargetDescriptorPayload, + EditorTargetId, + KnownEditorTargetId, +} from "../shared/messages.js"; import { findExecutable, quoteWindowsArgument, @@ -9,7 +13,7 @@ import { } from "../utils/executable.js"; type EditorTargetDefinition = { - id: EditorTargetId; + id: KnownEditorTargetId; label: string; command: string; platforms?: readonly NodeJS.Platform[]; @@ -29,6 +33,7 @@ type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & { const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [ { id: "cursor", label: "Cursor", command: "cursor" }, { id: "vscode", label: "VS Code", command: "code" }, + { id: "webstorm", label: "WebStorm", command: "webstorm" }, { id: "zed", label: "Zed", command: "zed" }, { id: "finder", label: "Finder", command: "open", platforms: ["darwin"] }, { id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] }, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8e38b840a..48e8ec35a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -8,6 +8,7 @@ import { homedir } from "node:os"; import { z } from "zod"; import type { ToolSet } from "ai"; import { + isLegacyEditorTargetId, serializeAgentStreamEvent, type AgentSnapshotPayload, type SessionInboundMessage, @@ -29,6 +30,7 @@ import { type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, + type EditorTargetDescriptorPayload, type EditorTargetId, type ProjectPlacementPayload, type WorkspaceSetupSnapshot, @@ -189,6 +191,30 @@ const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHAR const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; +const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50"; + +function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean { + if (!appVersion) return false; + // Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45" + const base = appVersion.replace(/-.*$/, ""); + const parts = base.split(".").map(Number); + const minParts = minVersion.split(".").map(Number); + for (let i = 0; i < minParts.length; i++) { + const a = parts[i] ?? 0; + const b = minParts[i] ?? 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + +function clientSupportsAllProviders(appVersion: string | null): boolean { + return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS); +} + +function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { + return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS); +} const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; @@ -224,20 +250,6 @@ function deriveInitialAgentTitle(prompt: string): string | null { return clamped.length > 0 ? clamped : null; } -function clientSupportsAllProviders(appVersion: string | null): boolean { - if (!appVersion) return false; - const base = appVersion.replace(/-.*$/, ""); - const parts = base.split(".").map(Number); - const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number); - for (let i = 0; i < minParts.length; i++) { - const current = parts[i] ?? 0; - const minimum = minParts[i] ?? 0; - if (current > minimum) return true; - if (current < minimum) return false; - } - return true; -} - export function resolveCreateAgentTitles(options: { configTitle?: string | null; initialPrompt?: string | null; @@ -1213,6 +1225,15 @@ export class Session { return LEGACY_PROVIDER_IDS.has(provider); } + private filterEditorsForClient( + editors: EditorTargetDescriptorPayload[], + ): EditorTargetDescriptorPayload[] { + if (clientSupportsFlexibleEditorIds(this.appVersion)) { + return editors; + } + return editors.filter((editor) => isLegacyEditorTargetId(editor.id)); + } + private matchesAgentFilter(options: { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; @@ -6196,7 +6217,7 @@ export class Session { } async getAvailableEditorTargets() { - return listAvailableEditorTargets(); + return this.filterEditorsForClient(await listAvailableEditorTargets()); } async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise { @@ -7670,7 +7691,9 @@ export class Session { listStoredAgents: () => this.agentStorage.list(), listLiveAgents: () => this.agentManager.listAgents(), resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier), - sendAgentMessage: (agentId, text) => this.handleSendAgentMessage(agentId, text), + sendAgentMessage: async (agentId, text) => { + await this.handleSendAgentMessage(agentId, text); + }, }); } catch (error) { this.emitChatRpcError(request, error); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index fda83e65e..f0e01f727 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -66,7 +66,7 @@ function makeAgent(input: { }; } -function createSessionForWorkspaceTests(): { +function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): { session: Session; emitted: Array<{ type: string; payload: unknown }>; projects: Map>; @@ -91,6 +91,7 @@ function createSessionForWorkspaceTests(): { const session = new Session({ clientId: "test-client", + appVersion: options.appVersion ?? null, onMessage: (message) => emitted.push(message as any), logger: logger as any, downloadTokenStore: {} as any, @@ -100,6 +101,9 @@ function createSessionForWorkspaceTests(): { subscribe: () => () => {}, listAgents: () => [], getAgent: () => null, + archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, } as any, agentStorage: { list: async () => [], @@ -1266,19 +1270,51 @@ describe("workspace aggregation", () => { }); test("list_available_editors_request returns available targets", async () => { - const { session, emitted } = createSessionForWorkspaceTests(); + const { session, emitted } = createSessionForWorkspaceTests({ appVersion: "0.1.50" }); const sessionAny = session as any; - sessionAny.getAvailableEditorTargets = async () => [ - { id: "cursor", label: "Cursor" }, - { id: "finder", label: "Finder" }, - ]; + sessionAny.getAvailableEditorTargets = async () => + sessionAny.filterEditorsForClient([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "finder", label: "Finder" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ]); await sessionAny.handleMessage({ type: "list_available_editors_request", requestId: "req-editors", }); + const response = emitted.find( + (message) => message.type === "list_available_editors_response", + ) as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.editors).toEqual([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "finder", label: "Finder" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ]); + }); + + test("list_available_editors_request filters unsupported ids for legacy clients", async () => { + const { session, emitted } = createSessionForWorkspaceTests({ appVersion: "0.1.49" }); + const sessionAny = session as any; + + sessionAny.getAvailableEditorTargets = async () => + sessionAny.filterEditorsForClient([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "unknown-editor", label: "Unknown Editor" }, + { id: "finder", label: "Finder" }, + ]); + + await sessionAny.handleMessage({ + type: "list_available_editors_request", + requestId: "req-editors-legacy", + }); + const response = emitted.find( (message) => message.type === "list_available_editors_response", ) as any; diff --git a/packages/server/src/shared/literal-union.ts b/packages/server/src/shared/literal-union.ts new file mode 100644 index 000000000..ad05ccd9b --- /dev/null +++ b/packages/server/src/shared/literal-union.ts @@ -0,0 +1,3 @@ +// Adapted from type-fest's LiteralUnion: +// https://github.com/sindresorhus/type-fest/blob/main/source/literal-union.d.ts +export type LiteralUnion = T | (U & Record); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 24db1c24f..88edd33c7 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -47,6 +47,7 @@ import { LoopLogsResponseSchema, LoopStopResponseSchema, } from "../server/loop/rpc-schemas.js"; +import type { LiteralUnion } from "./literal-union.js"; import type { AgentCapabilityFlags, AgentModelDefinition, @@ -1129,14 +1130,32 @@ export const WorkspaceSetupStatusRequestSchema = z.object({ requestId: z.string(), }); -export const EditorTargetIdSchema = z.enum([ +// TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids. +export const LEGACY_EDITOR_TARGET_IDS = [ "cursor", "vscode", "zed", "finder", "explorer", "file-manager", -]); +] as const; + +export const KNOWN_EDITOR_TARGET_IDS = [...LEGACY_EDITOR_TARGET_IDS, "webstorm"] as const; + +export const KnownEditorTargetIdSchema = z.enum(KNOWN_EDITOR_TARGET_IDS); +export const LegacyEditorTargetIdSchema = z.enum(LEGACY_EDITOR_TARGET_IDS); +export const EditorTargetIdSchema = z.string().trim().min(1); + +const KNOWN_EDITOR_TARGET_ID_SET = new Set(KNOWN_EDITOR_TARGET_IDS); +const LEGACY_EDITOR_TARGET_ID_SET = new Set(LEGACY_EDITOR_TARGET_IDS); + +export function isKnownEditorTargetId(value: string): value is KnownEditorTargetId { + return KNOWN_EDITOR_TARGET_ID_SET.has(value); +} + +export function isLegacyEditorTargetId(value: string): value is LegacyEditorTargetId { + return LEGACY_EDITOR_TARGET_ID_SET.has(value); +} export const EditorTargetDescriptorPayloadSchema = z.object({ id: EditorTargetIdSchema, @@ -2799,7 +2818,9 @@ export type WorkspaceDescriptorPayload = z.infer; export type WorkspaceScriptHealth = z.infer; export type WorkspaceScriptPayload = z.infer; -export type EditorTargetId = z.infer; +export type KnownEditorTargetId = z.infer; +export type LegacyEditorTargetId = z.infer; +export type EditorTargetId = LiteralUnion; export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 239ffa14f..a837d1139 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -38,6 +38,24 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("list_available_editors_request"); }); + test("parses open_in_editor_request with flexible editor ids", () => { + const knownEditor = SessionInboundMessageSchema.parse({ + type: "open_in_editor_request", + requestId: "req-open-webstorm", + editorId: "webstorm", + path: "/tmp/repo", + }); + const unknownEditor = SessionInboundMessageSchema.parse({ + type: "open_in_editor_request", + requestId: "req-open-custom", + editorId: "unknown-editor", + path: "/tmp/repo", + }); + + expect(knownEditor.type).toBe("open_in_editor_request"); + expect(unknownEditor.type).toBe("open_in_editor_request"); + }); + test("parses open_in_editor_response", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "open_in_editor_response", @@ -50,6 +68,33 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("open_in_editor_response"); }); + test("parses list_available_editors_response with unknown editor ids", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "list_available_editors_response", + payload: { + requestId: "req-editors", + editors: [ + { id: "cursor", label: "Cursor" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ], + error: null, + }, + }); + + expect(parsed.type).toBe("list_available_editors_response"); + }); + + test("rejects empty editor ids", () => { + const result = SessionInboundMessageSchema.safeParse({ + type: "open_in_editor_request", + requestId: "req-open-empty", + editorId: "", + path: "/tmp/repo", + }); + + expect(result.success).toBe(false); + }); + test("rejects invalid workspace update payload", () => { const result = SessionOutboundMessageSchema.safeParse({ type: "workspace_update",