From bf916c4c2bbd29e097bade6585ad2ee43139df42 Mon Sep 17 00:00:00 2001 From: Jalilkhan Faquiri Date: Wed, 16 Sep 2026 11:58:07 +0200 Subject: [PATCH 1/3] feat(cli): open workspace files in desktop app Co-authored-by: Cursor --- apps/server/src/cli/app.test.ts | 4 +++ apps/server/src/cli/app.ts | 11 ++++++-- .../DesktopAppActivationCoordinator.tsx | 25 ++++++++++++++++++- apps/web/src/desktopAppActivation.test.ts | 14 +++++++++++ apps/web/src/desktopAppActivation.ts | 7 +++++- .../contracts/src/desktopAppActivation.ts | 2 ++ 6 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 0dddca4b1bf0..2c7d83383f73 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -191,17 +191,21 @@ describe("t3 app", () => { Effect.gen(function* () { const baseDir = NodePath.join(root, "t3-home"); const explicitPath = NodePath.join(root, "project"); + const workspaceFile = NodePath.join(root, "project.code-workspace"); const platform = yield* HostProcessPlatform; const workingDirectory = yield* HostProcessWorkingDirectory; const desktop = yield* fakeDesktop({ baseDir }); yield* runCli(["app"], { T3CODE_HOME: baseDir }); yield* runCli(["app", explicitPath, "--base-dir", baseDir]); + yield* runCli(["app", workspaceFile, "--base-dir", baseDir]); expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([ workingDirectory, explicitPath, + root, ]); + expect(desktop.received[2]).toMatchObject({ workspaceFile }); expect(desktop.received.every((request) => request.platform === platform)).toBe(true); }).pipe(Effect.scoped), ), diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index 3fc6deedb0ce..de32650c1dff 100644 --- a/apps/server/src/cli/app.ts +++ b/apps/server/src/cli/app.ts @@ -202,7 +202,11 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { const allowDevFallback = Option.isNone(flags.baseDir) && !environment.t3Home?.trim(); const rawWorkspaceRoot = Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcessWorkingDirectory); - const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); + const resolvedInput = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); + const workspaceFile = resolvedInput.toLowerCase().endsWith(".code-workspace") + ? resolvedInput + : undefined; + const workspaceRoot = workspaceFile === undefined ? resolvedInput : path.dirname(workspaceFile); const userId = yield* HostProcessUserId; const resolveAddress = (stateSubdirectory: "userdata" | "dev") => resolveDesktopAppControlAddress({ @@ -217,6 +221,7 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { requestId: NodeCrypto.randomUUID(), type: "open-workspace", workspaceRoot, + ...(workspaceFile === undefined ? {} : { workspaceFile }), platform: hostPlatform, }; const address = resolveAddress("userdata"); @@ -252,7 +257,9 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { export const appCommand = Command.make("app", { baseDir: baseDirFlag, workspaceRoot: Argument.string("path").pipe( - Argument.withDescription("Project directory. Default: current directory."), + Argument.withDescription( + "Project directory or .code-workspace file. Default: current directory.", + ), Argument.optional, ), }).pipe( diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index 3e941e69dd53..4c23275659ff 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -8,14 +8,19 @@ import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectP import { newProjectId } from "../../lib/utils"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; +import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { environmentShell } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; export function DesktopAppActivationCoordinator() { const primaryEnvironment = usePrimaryEnvironment(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const readWorkspaceFile = useAtomQueryRunner(filesystemEnvironment.readWorkspaceFile, { + reportFailure: false, + }); const openThread = useNewThreadHandler(); const queueRef = useRef(Promise.resolve()); const activation = window.desktopBridge?.appActivation; @@ -49,7 +54,24 @@ export function DesktopAppActivationCoordinator() { readProjects().filter((project) => project.environmentId === environmentId), workspaceRoot, ) ?? null, - createProject: async (environmentId, workspaceRoot) => { + createProject: async (environmentId, workspaceRoot, workspaceFile) => { + let workspace = {}; + if (workspaceFile !== undefined) { + const read = await readWorkspaceFile({ + environmentId, + input: { workspaceFilePath: workspaceFile }, + }); + if (read._tag === "Failure") { + const error = squashAtomCommandFailure(read); + throw error instanceof Error + ? error + : new Error("T3 Code could not read the workspace file."); + } + workspace = { + workspaceFile: read.value.workspaceFilePath, + repoRoots: read.value.repoRoots, + }; + } const projectId = newProjectId(); const result = await createProject({ environmentId, @@ -57,6 +79,7 @@ export function DesktopAppActivationCoordinator() { projectId, title: inferProjectTitleFromPath(workspaceRoot), workspaceRoot, + ...workspace, createWorkspaceRootIfMissing: false, defaultModelSelection: null, }, diff --git a/apps/web/src/desktopAppActivation.test.ts b/apps/web/src/desktopAppActivation.test.ts index e362391ed682..7d3451cabbef 100644 --- a/apps/web/src/desktopAppActivation.test.ts +++ b/apps/web/src/desktopAppActivation.test.ts @@ -75,6 +75,20 @@ describe("desktop app activation", () => { expect(response).toMatchObject({ ok: true, projectId: createdProjectId }); }); + it("passes a workspace file through to project creation", async () => { + const workspaceFile = "/workspace/project/project.code-workspace"; + const deps = dependencies({ findProject: () => null }); + + const response = await handleDesktopAppActivationRequest({ ...request, workspaceFile }, deps); + + expect(deps.createProject).toHaveBeenCalledWith( + environmentId, + request.workspaceRoot, + workspaceFile, + ); + expect(response).toMatchObject({ ok: true, projectId: createdProjectId }); + }); + it("rejects a Windows path when the primary environment is WSL", async () => { const response = await handleDesktopAppActivationRequest( { ...request, platform: "win32" }, diff --git a/apps/web/src/desktopAppActivation.ts b/apps/web/src/desktopAppActivation.ts index d291e7ed54e2..338fa5daf266 100644 --- a/apps/web/src/desktopAppActivation.ts +++ b/apps/web/src/desktopAppActivation.ts @@ -29,6 +29,7 @@ export interface DesktopAppActivationDependencies { readonly createProject: ( environmentId: EnvironmentId, workspaceRoot: string, + workspaceFile: string | undefined, ) => Promise; readonly waitForProject: (projectRef: ScopedProjectRef) => Promise; readonly openThread: ( @@ -79,7 +80,11 @@ export async function handleDesktopAppActivationRequest( let projectId = dependencies.findProject(target.environmentId, request.workspaceRoot)?.id ?? null; if (projectId === null) { try { - projectId = await dependencies.createProject(target.environmentId, request.workspaceRoot); + projectId = await dependencies.createProject( + target.environmentId, + request.workspaceRoot, + request.workspaceFile, + ); await dependencies.waitForProject({ environmentId: target.environmentId, projectId }); } catch (error) { return failure( diff --git a/packages/contracts/src/desktopAppActivation.ts b/packages/contracts/src/desktopAppActivation.ts index 020188cf43eb..5b3d43a3e178 100644 --- a/packages/contracts/src/desktopAppActivation.ts +++ b/packages/contracts/src/desktopAppActivation.ts @@ -12,6 +12,8 @@ export const DesktopAppActivationRequest = Schema.Struct({ requestId: TrimmedNonEmptyString, type: Schema.Literal("open-workspace"), workspaceRoot: TrimmedNonEmptyString, + /** Present when the caller opened a multi-root VS Code workspace file. */ + workspaceFile: Schema.optional(TrimmedNonEmptyString), platform: DesktopAppActivationPlatform, }); export type DesktopAppActivationRequest = typeof DesktopAppActivationRequest.Type; From 2578b123d74de1b22f1e8180c5cc1fb46524d0a9 Mon Sep 17 00:00:00 2001 From: Jalilkhan Faquiri <53660999+Tr1Fecta-7@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:17:03 +0200 Subject: [PATCH 2/3] Update desktopAppActivation.ts --- packages/contracts/src/desktopAppActivation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/contracts/src/desktopAppActivation.ts b/packages/contracts/src/desktopAppActivation.ts index 5b3d43a3e178..88f073f67bb2 100644 --- a/packages/contracts/src/desktopAppActivation.ts +++ b/packages/contracts/src/desktopAppActivation.ts @@ -12,7 +12,6 @@ export const DesktopAppActivationRequest = Schema.Struct({ requestId: TrimmedNonEmptyString, type: Schema.Literal("open-workspace"), workspaceRoot: TrimmedNonEmptyString, - /** Present when the caller opened a multi-root VS Code workspace file. */ workspaceFile: Schema.optional(TrimmedNonEmptyString), platform: DesktopAppActivationPlatform, }); From f1012a707cd085d30e5374c308562a1a9633ae27 Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Fri, 18 Sep 2026 10:06:01 +1000 Subject: [PATCH 3/3] fix(web): name CLI workspace projects after the workspace file Opening a .code-workspace with t3 app titled the project after the directory holding the file, which is often a generic parent such as ~/code. The command palette already titles it after the file, so share that rule in one helper and use it on both paths. --- apps/web/src/components/CommandPalette.tsx | 6 +++--- .../desktop/DesktopAppActivationCoordinator.tsx | 12 ++++++++++-- apps/web/src/lib/projectPaths.test.ts | 11 +++++++++++ apps/web/src/lib/projectPaths.ts | 1 + packages/client-runtime/src/state/projects.ts | 13 +++++++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8ff21ae5aa0a..416edc0b0b3c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -95,6 +95,7 @@ import { getBrowseDirectoryPath, hasTrailingPathSeparator, inferProjectTitleFromPath, + inferProjectTitleFromWorkspaceFile, isExplicitRelativeProjectPath, isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, @@ -2799,13 +2800,12 @@ function OpenCommandPaletteDialog(props: { ); } - const fileName = workspaceFilePath.split(/[/\\]/).pop() ?? workspaceFilePath; - const title = fileName.replace(/\.code-workspace$/i, "").trim(); + const title = inferProjectTitleFromWorkspaceFile(resolved.workspaceFilePath); await handleAddProject(resolved.anchorDir, { workspaceFile: resolved.workspaceFilePath, repoRoots: resolved.repoRoots, - ...(title.length > 0 ? { title } : {}), + ...(title === null ? {} : { title }), }); }, [browseEnvironmentId, handleAddProject, readWorkspaceFile], diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index 4c23275659ff..1af254e59771 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -4,7 +4,11 @@ import { useEffect, useEffectEvent, useRef } from "react"; import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; -import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; +import { + findProjectByPath, + inferProjectTitleFromPath, + inferProjectTitleFromWorkspaceFile, +} from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; @@ -56,6 +60,9 @@ export function DesktopAppActivationCoordinator() { ) ?? null, createProject: async (environmentId, workspaceRoot, workspaceFile) => { let workspace = {}; + // The palette names a workspace project after its file, so the CLI has + // to as well; the anchor directory is often a generic parent. + let title = inferProjectTitleFromPath(workspaceRoot); if (workspaceFile !== undefined) { const read = await readWorkspaceFile({ environmentId, @@ -71,13 +78,14 @@ export function DesktopAppActivationCoordinator() { workspaceFile: read.value.workspaceFilePath, repoRoots: read.value.repoRoots, }; + title = inferProjectTitleFromWorkspaceFile(read.value.workspaceFilePath) ?? title; } const projectId = newProjectId(); const result = await createProject({ environmentId, input: { projectId, - title: inferProjectTitleFromPath(workspaceRoot), + title, workspaceRoot, ...workspace, createWorkspaceRootIfMissing: false, diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts index fd2391d571e6..039af43f972d 100644 --- a/apps/web/src/lib/projectPaths.test.ts +++ b/apps/web/src/lib/projectPaths.test.ts @@ -9,6 +9,7 @@ import { getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, + inferProjectTitleFromWorkspaceFile, isExplicitRelativeProjectPath, isFilesystemBrowseQuery, normalizeProjectPathForComparison, @@ -46,6 +47,16 @@ describe("projectPaths", () => { expect(inferProjectTitleFromPath("/home/user\\project/")).toBe("user\\project"); }); + it("titles workspace-file projects after the file, not its directory", () => { + expect(inferProjectTitleFromWorkspaceFile("/home/user/code/platform.code-workspace")).toBe( + "platform", + ); + expect(inferProjectTitleFromWorkspaceFile("C:\\Work\\code\\Platform.CODE-WORKSPACE")).toBe( + "Platform", + ); + expect(inferProjectTitleFromWorkspaceFile("/home/user/code/.code-workspace")).toBe(null); + }); + it("detects browse queries across supported path styles", () => { expect(isFilesystemBrowseQuery(".")).toBe(false); expect(isFilesystemBrowseQuery("..")).toBe(false); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts index 262095c663c7..a91a42925461 100644 --- a/apps/web/src/lib/projectPaths.ts +++ b/apps/web/src/lib/projectPaths.ts @@ -8,6 +8,7 @@ export { getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, + inferProjectTitleFromWorkspaceFile, isExplicitRelativeProjectPath, isFilesystemBrowseQuery, isUnsupportedWindowsProjectPath, diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts index 247daa3581a0..933ff911386a 100644 --- a/packages/client-runtime/src/state/projects.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -153,6 +153,19 @@ export function inferProjectTitleFromPath(value: string): string { return segments.findLast(Boolean) ?? normalized; } +/** + * Titles a `.code-workspace`-backed project after the file rather than the + * directory holding it, which is often a generic parent such as `~/code`. + * Returns null when the file name carries no title, leaving the caller to fall + * back to {@link inferProjectTitleFromPath} on the anchor directory. + */ +export function inferProjectTitleFromWorkspaceFile(workspaceFilePath: string): string | null { + const title = inferProjectTitleFromPath(workspaceFilePath) + .replace(/\.code-workspace$/i, "") + .trim(); + return title.length > 0 ? title : null; +} + export function appendBrowsePathSegment(currentPath: string, segment: string): string { const separator = preferredPathSeparator(currentPath); return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;