From 71a90ae70e3ff880396363809bd9bfcfd520f30c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 30 Sep 2026 23:40:17 -0700 Subject: [PATCH 1/9] fix(web): hotkey settings record plain keys and Tab (#14548) Co-authored-by: Claude Opus 5.5 (1M context) --- .../KeybindingsSettings.logic.test.ts | 25 +++++++++++++++++++ .../settings/KeybindingsSettings.logic.ts | 4 +-- .../settings/KeybindingsSettings.tsx | 6 +++-- .../settings/useSnapShotShortcutRecorder.tsx | 2 ++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 791ebccfa932..15561e06be83 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -113,6 +113,31 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); + it.each([ + ["k", "KeyK", "k"], + ["Tab", "Tab", "tab"], + ["F5", "F5", "f5"], + ])("captures %s without a modifier", (key, code, expected) => { + const noModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; + expect(keybindingFromKeyboardEvent({ key, code, ...noModifiers }, "MacIntel")).toBe(expected); + }); + + it("waits for a key when only a modifier is pressed", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "Meta", + code: "MetaLeft", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toBeNull(); + }); + it.each([ ["@", "Digit2", "mod+shift+2"], ['"', "Digit2", "mod+shift+2"], diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a1f445b5db82..4f41e3755382 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -358,6 +358,7 @@ function normalizeShortcutKeyToken(key: string): string | null { return null; } +/** Turns a keydown into a binding such as `mod+shift+k` or `tab`. Null for modifier-only presses. */ export function keybindingFromKeyboardEvent( event: Pick, platform: string, @@ -375,9 +376,6 @@ export function keybindingFromKeyboardEvent( } if (event.altKey) parts.push("alt"); if (event.shiftKey) parts.push("shift"); - if (parts.length === 0) { - return null; - } parts.push(keyToken); return parts.join("+"); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 9d768d7fd41c..bf14e709dda1 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -768,7 +768,8 @@ function useKeybindingRowEditor({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: row.key, isRecording: false }); @@ -1083,7 +1084,8 @@ function useNewKeybindingDraft({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: "", isRecording: false }); diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx index 8f377842b68d..50fd3b338210 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx @@ -106,6 +106,8 @@ export function useSnapShotShortcutRecorder({ } return; } + // A global shortcut without a modifier would take that key from every app. + if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) return; const input = keybindingFromKeyboardEvent(event, navigator.platform); if (!input) return; const next = parseKeybindingShortcut(input); From 41a8239849d7e6523c08edb363efdeccfb150be5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 1 Oct 2026 00:01:39 -0700 Subject: [PATCH 2/9] fix(release): Windows CLI smoke test no longer fails on temp dir cleanup (#14553) Co-authored-by: Claude Opus 5.5 (1M context) --- scripts/smoke-cli-archive.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index cce14d5654f1..0af024c0ec92 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -15,6 +15,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { Command, Flag } from "effect/unstable/cli"; @@ -73,7 +74,18 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { const path = yield* Path.Path; const platform = yield* HostProcessPlatform; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-smoke-" }); + const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" }); + // Windows can keep t3.exe locked (EBUSY) for a moment after the server + // exits. A leftover scratch directory on a CI runner is harmless, so + // cleanup retries briefly and never fails a smoke test that passed. + yield* Effect.addFinalizer(() => + fs.remove(scratch, { recursive: true }).pipe( + Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }), + Effect.catch((error) => + Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`), + ), + ), + ); // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU // tar; use the bsdtar Windows ships, which reads both formats. From 148e6deea046658639aae9fef5b349781cec39d1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 1 Oct 2026 00:15:28 -0700 Subject: [PATCH 3/9] feat: start a new project from just a name (#14527) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/Stack.tsx | 5 + .../features/projects/AddProjectNewRoute.tsx | 12 + .../features/projects/AddProjectScreen.tsx | 261 +++++++++++- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/project/NewProject.test.ts | 140 ++++++ apps/server/src/project/NewProject.ts | 179 ++++++++ apps/server/src/server.test.ts | 59 +++ apps/server/src/ws.ts | 50 +++ apps/web/src/components/CommandPalette.tsx | 399 ++++++++++++++++-- .../src/components/CommandPaletteResults.tsx | 2 +- .../src/components/chat/DraftHeroHeadline.tsx | 2 +- .../sidebar/SidebarThreadHeader.tsx | 2 +- apps/web/src/hooks/useNewProject.ts | 147 +++++++ docs/user/source-control.md | 10 +- .../client-runtime/src/operations/projects.ts | 31 ++ .../src/state/projectCommands.ts | 7 + packages/contracts/src/project.ts | 14 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.ts | 5 + packages/shared/src/path.test.ts | 14 + packages/shared/src/path.ts | 21 + 21 files changed, 1334 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/features/projects/AddProjectNewRoute.tsx create mode 100644 apps/server/src/project/NewProject.test.ts create mode 100644 apps/server/src/project/NewProject.ts create mode 100644 apps/web/src/hooks/useNewProject.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7f508f486ce3..4baeadb94238 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -53,6 +53,7 @@ import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewR import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; +import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; @@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({ screen: AddProjectLocalRoute, linking: "add-project/local", }), + AddProjectNew: createNativeStackScreen({ + screen: AddProjectNewRoute, + linking: "add-project/new", + }), }, }); diff --git a/apps/mobile/src/features/projects/AddProjectNewRoute.tsx b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx new file mode 100644 index 000000000000..8bbd26f2e114 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx @@ -0,0 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { AddProjectNewScreen } from "./AddProjectScreen"; + +type AddProjectNewRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectNewRoute({ + route, +}: StaticScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5b38cfa7bdd8..7cdcd0d33493 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -15,6 +15,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -61,6 +64,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; @@ -77,6 +81,8 @@ interface EnvironmentOption { readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; + /** Folder for projects started from just a name; null on servers without it. */ + readonly newProjectsRoot: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; @@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray { platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, + newProjectsRoot: config?.newProjectsRoot ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, @@ -578,6 +585,28 @@ export function AddProjectSourceScreen() { {selectedEnvironment ? ( <> + {selectedEnvironment.newProjectsRoot !== null ? ( + + } + isFirst + onPress={() => + navigation.dispatch( + StackActions.push("AddProjectNew", { + environmentId: selectedEnvironment.environmentId, + }), + ) + } + /> + ) : null} } - isFirst + isFirst={selectedEnvironment.newProjectsRoot === null} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -898,6 +927,236 @@ function FolderBrowser(props: { ); } +/** + * New project: a name, then the server makes the folder, README, icon, and + * first commit. Optionally publishes it to GitHub as a private repository. + */ +export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const navigation = useNavigation(); + // Starts on the machine picked in Add project; the rows below switch it. + const environmentOptions = useEnvironmentOptions().filter( + (option) => option.newProjectsRoot !== null, + ); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + () => stringParam(props.environmentId) as EnvironmentId | null, + ); + const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const discoveryState = useEnvironmentQuery( + environment === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: environment.environmentId, + input: {}, + }), + ); + const githubTarget = getNewProjectGitHubTarget(discoveryState.data); + const [name, setName] = useState(""); + const [publishesToGitHub, setPublishesToGitHub] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const trimmedName = name.trim(); + const pathPreview = + environment?.newProjectsRoot != null + ? getNewProjectPathPreview(environment.newProjectsRoot, trimmedName) + : null; + + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showMachines = + environmentOptions.length > 1 || (environment === null && environmentOptions.length > 0); + const machineRows = showMachines ? ( + + {environmentOptions.map((option, index) => { + const selected = option.environmentId === environment?.environmentId; + return ( + + } + selected={selected} + // The create in flight keeps the machine it started on. + disabled={isSubmitting} + isFirst={index === 0} + right={ + selected ? ( + + ) : null + } + onPress={() => setSelectedEnvironmentId(option.environmentId)} + /> + ); + })} + + ) : null; + + // State lags a render behind, so a double tap could start a second create. + const submittingRef = useRef(false); + const submit = async () => { + if (!environment || trimmedName.length === 0 || submittingRef.current) return; + submittingRef.current = true; + setError(null); + setIsSubmitting(true); + try { + const result = await createNew({ + environmentId: environment.environmentId, + input: { name: trimmedName }, + }); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + return; + } + const { projectId, workspaceRoot, commitError } = result.value; + if (commitError !== undefined) { + Alert.alert("Created without a first commit", commitError); + } + if (publishesToGitHub && githubTarget !== null) { + void publishRepository({ + environmentId: environment.environmentId, + input: { + cwd: workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), + visibility: "private", + }, + }).then((publishResult) => { + if (AsyncResult.isFailure(publishResult)) { + Alert.alert( + "Could not create the GitHub repository", + errorMessage(Cause.squash(publishResult.cause)), + ); + } + }); + } + // The draft screen resolves its project from the client store, so it + // must not open before the create event has arrived. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + // The project exists, so clearing the name keeps Create from making a `-2` copy. + setName(""); + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return; + } + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }); + } finally { + submittingRef.current = false; + setIsSubmitting(false); + } + }; + + return ( + + {error ? : null} + {environment ? ( + <> + void submit()} + /> + {pathPreview !== null ? ( + + {trimmedName.length > 0 + ? `Creates ${pathPreview}` + : `Goes in ${environment.newProjectsRoot}`} + {showMachines ? ` on ${environment.label}` : null} + + ) : null} + {machineRows} + {githubTarget !== null ? ( + + 0 && pathPreview !== null + ? getNewProjectGitHubRepository(githubTarget, pathPreview) + : githubTarget.account + } + icon={ + + } + isFirst + right={ + + } + onPress={() => setPublishesToGitHub((publishes) => !publishes)} + /> + + ) : null} + void submit()} + loading={isSubmitting} + /> + + + } + isFirst + // New project opens from Add project, so going back shows the + // other sources. A deep link has nothing behind it. + onPress={() => + navigation.canGoBack() + ? navigation.goBack() + : navigation.dispatch(StackActions.replace("AddProject")) + } + /> + + + ) : environmentOptions.length > 0 ? ( + machineRows + ) : ( + + )} + + ); +} + export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 61f70aac2836..27fde35b9e90 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -116,6 +116,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, diff --git a/apps/server/src/project/NewProject.test.ts b/apps/server/src/project/NewProject.test.ts new file mode 100644 index 000000000000..3286aa784c29 --- /dev/null +++ b/apps/server/src/project/NewProject.test.ts @@ -0,0 +1,140 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { createNewProjectFolder } from "./NewProject.ts"; + +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-new-project-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const GIT_ENV_KEYS = [ + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "EMAIL", +] as const; + +// Git reads the developer's own config (signing, default branch, identity) +// unless the test replaces it, so each test runs against an empty one. +const withGitEnv = ( + env: Partial>, + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" }); + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const saved = GIT_ENV_KEYS.map((key) => [key, process.env[key]] as const); + for (const key of GIT_ENV_KEYS) delete process.env[key]; + Object.assign(process.env, { + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + ...env, + }); + return saved; + }), + () => effect, + (saved) => + Effect.sync(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }), + ); + }); + +const gitOutput = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ operation: "NewProject.test", cwd, args }); + return result.stdout.trim(); + }); + +it.layer(TestLayer)("createNewProjectFolder", (it) => { + it.effect("makes the folder, starter files, and first commit, and suffixes a taken name", () => + Effect.scoped( + withGitEnv( + { + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@test.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@test.com", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }), + "projects", + ); + + const first = yield* createNewProjectFolder({ root, name: "Pinball Stats" }); + const second = yield* createNewProjectFolder({ root, name: "pinball stats" }); + + assert.equal(first.workspaceRoot, path.join(root, "pinball-stats")); + assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2")); + assert.isUndefined(first.commitError); + const readme = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "README.md"), + ); + assert.include(readme, "# Pinball Stats"); + assert.include(readme, `src="assets/icon.svg"`); + const icon = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "assets", "icon.svg"), + ); + assert.include(icon, ">PS"); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["log", "--format=%s"]), + "Initial commit", + ); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["rev-parse", "--abbrev-ref", "HEAD"]), + "main", + ); + assert.equal(yield* gitOutput(first.workspaceRoot, ["status", "--porcelain"]), ""); + }), + ), + ), + ); + + it.effect("keeps the folder and reports why when Git cannot commit", () => + Effect.scoped( + // No identity anywhere, and Git may not guess one from the host name. + withGitEnv( + { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.useConfigOnly", + GIT_CONFIG_VALUE_0: "true", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }); + + const result = yield* createNewProjectFolder({ root, name: "No Identity" }); + + assert.include(result.commitError ?? "", "no name or email"); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, "README.md"))); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, ".git"))); + }), + ), + ), + ); +}); diff --git a/apps/server/src/project/NewProject.ts b/apps/server/src/project/NewProject.ts new file mode 100644 index 000000000000..d59be024e5e3 --- /dev/null +++ b/apps/server/src/project/NewProject.ts @@ -0,0 +1,179 @@ +/** + * NewProject - makes the folder for a project started from just a name: + * `/` with a README, an icon, and a first commit. + * + * The icon lives at `assets/icon.svg`, a path ProjectFaviconResolver already + * checks after the usual web app icons. It travels with the repository, so + * every machine that clones the project shows the same icon. + * + * @module NewProject + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { newProjectFolderName } from "@t3tools/shared/path"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +// Tailwind 600 shades: dark enough for white initials on every hue. +const ICON_BACKGROUNDS = [ + "#dc2626", + "#ea580c", + "#d97706", + "#16a34a", + "#059669", + "#0d9488", + "#0891b2", + "#0284c7", + "#2563eb", + "#4f46e5", + "#7c3aed", + "#9333ea", + "#c026d3", + "#db2777", + "#e11d48", +] as const; + +const MAX_FOLDER_ATTEMPTS = 100; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** A rounded square with the name's initials, colored by a hash of the name. */ +function newProjectIconSvg(name: string): string { + const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); + const initials = + words + .slice(0, 2) + .map((word) => Array.from(word)[0] ?? "") + .join("") + .toUpperCase() || + Array.from(name.trim())[0] || + "?"; + let hash = 0; + for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0; + const background = ICON_BACKGROUNDS[hash % ICON_BACKGROUNDS.length]; + return [ + ``, + ` `, + ` ${escapeXml(initials)}`, + ``, + "", + ].join("\n"); +} + +function newProjectReadme(name: string): string { + return [ + ``, + "", + `# ${name}`, + "", + "Created in [T3 Code](https://t3.codes).", + "", + ].join("\n"); +} + +// Git's own identity message runs several lines; say what to do instead. +// Otherwise its last line ("error: gpg failed to sign the data") says enough. +function describeCommitFailure(stderr: string): string { + if (/identity unknown|tell me who you are|no (name|email) was given/i.test(stderr)) { + return "Git has no name or email on this machine. Set user.name and user.email, then commit."; + } + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return lines.at(-1) ?? "Git could not make the first commit."; +} + +/** + * Claims a fresh folder for `name` under `root` (adding `-2`, `-3`, ... when + * the name is taken), writes the starter files, and makes the first commit. + * A failed commit (no Git identity, a signing prompt) keeps the folder and + * returns why, so the project still opens. Any other failure removes the + * folder it claimed. + */ +export const createNewProjectFolder = Effect.fn("NewProject.createNewProjectFolder")( + function* (input: { readonly root: string; readonly name: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + + yield* fileSystem.makeDirectory(input.root, { recursive: true }); + // Created without `recursive`, so the create itself claims the folder and + // two requests for the same name never share one. + const folderName = newProjectFolderName(input.name); + let workspaceRoot: string | null = null; + for (let attempt = 1; workspaceRoot === null && attempt <= MAX_FOLDER_ATTEMPTS; attempt++) { + const candidate = path.join( + input.root, + attempt === 1 ? folderName : `${folderName}-${attempt}`, + ); + const claimed = yield* fileSystem.makeDirectory(candidate).pipe( + Effect.as(true), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + ); + if (claimed) workspaceRoot = candidate; + } + if (workspaceRoot === null) { + return yield* PlatformError.systemError({ + _tag: "AlreadyExists", + module: "FileSystem", + method: "makeDirectory", + description: `Every folder name for "${folderName}" is taken.`, + pathOrDescriptor: input.root, + }); + } + const cwd = workspaceRoot; + + return yield* Effect.gen(function* () { + const branch = + (yield* git + .readConfigValue(cwd, "init.defaultBranch") + .pipe(Effect.orElseSucceed(() => null))) ?? "main"; + yield* git.execute({ + operation: "NewProject.init", + cwd, + args: ["init", `--initial-branch=${branch}`], + timeoutMs: 10_000, + }); + yield* fileSystem.writeFileString(path.join(cwd, "README.md"), newProjectReadme(input.name)); + yield* fileSystem.makeDirectory(path.join(cwd, "assets")); + yield* fileSystem.writeFileString( + path.join(cwd, "assets", "icon.svg"), + newProjectIconSvg(input.name), + ); + // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. + yield* git.execute({ + operation: "NewProject.add", + cwd, + args: ["add", "--force", "--", "README.md", "assets/icon.svg"], + timeoutMs: 10_000, + }); + const commitError = yield* git + .execute({ + operation: "NewProject.commit", + cwd, + args: ["commit", "--message", "Initial commit"], + allowNonZeroExit: true, + timeoutMs: 30_000, + }) + .pipe( + Effect.map((result) => + result.exitCode === 0 ? undefined : describeCommitFailure(result.stderr), + ), + Effect.catch((error) => Effect.succeed(error.message)), + ); + return { workspaceRoot: cwd, commitError }; + }).pipe(Effect.onError(() => fileSystem.remove(cwd, { recursive: true }).pipe(Effect.ignore))); + }, +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 930e7e7bb369..fd505400c2e5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5556,6 +5556,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a project from just a name in the projects folder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created: Array<{ readonly title: string; readonly workspaceRoot: string }> = []; + const gitCalls: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "project.create") { + created.push({ title: command.title, workspaceRoot: command.workspaceRoot }); + } + return { sequence: created.length }; + }), + }, + gitVcsDriver: { + readConfigValue: () => Effect.succeed(null), + execute: (input) => + Effect.sync(() => { + gitCalls.push(input.args.join(" ")); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }, + }, + }); + + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + const root = (yield* client[WS_METHODS.serverGetConfig]({})).newProjectsRoot ?? ""; + const result = yield* client[WS_METHODS.projectsCreateNew]({ name: "Pinball Stats" }); + + assert.equal(result.workspaceRoot, path.join(root, "pinball-stats")); + assert.isUndefined(result.commitError); + assert.deepEqual(created, [ + { title: "Pinball Stats", workspaceRoot: result.workspaceRoot }, + ]); + assert.deepEqual(gitCalls, [ + "init --initial-branch=main", + "add --force -- README.md assets/icon.svg", + "commit --message Initial commit", + ]); + assert.isTrue( + yield* fileSystem.exists(path.join(result.workspaceRoot, "assets", "icon.svg")), + ); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("advertises the usable file manager and its reveal label", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1bdf80208e55..f256dfaa4aa0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,6 +49,7 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectId, + type ProjectCreateNewInput, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -143,6 +144,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as NewProject from "./project/NewProject.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; @@ -2018,6 +2020,49 @@ const makeWsRpcLayer = ( ); }); + // Projects started from just a name live beside Scratch and worktrees, + // away from folders the user organizes by hand. A nested repository is + // fine here (unlike Scratch) because each project gets its own `git init`. + const newProjectsRoot = path.resolve(config.baseDir, "projects"); + const createNewProject = (input: ProjectCreateNewInput) => + Effect.gen(function* () { + const folder = yield* NewProject.createNewProjectFolder({ + root: newProjectsRoot, + name: input.name, + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the project folder.", + cause, + }), + ), + ); + const projectId = ProjectId.make(yield* randomUUID); + yield* Effect.gen(function* () { + const command = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: yield* serverCommandId("project-create-new"), + projectId, + title: input.name, + workspaceRoot: folder.workspaceRoot, + createdAt: yield* nowIso, + }); + yield* dispatchNormalizedCommand(command); + }).pipe( + // Only a rejected command means no project uses the folder. An + // interrupt can land after the command is queued, so keep it then. + Effect.tapError(() => + fileSystem.remove(folder.workspaceRoot, { recursive: true }).pipe(Effect.ignore), + ), + ); + return { + projectId, + workspaceRoot: folder.workspaceRoot, + ...(folder.commitError === undefined ? {} : { commitError: folder.commitError }), + }; + }); + // Only clients that answer /usage-limits themselves see it in the catalogs; // an older client would send the injected command to the provider. const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => @@ -2082,6 +2127,7 @@ const makeWsRpcLayer = ( threadSnapshotPagination: true, reasoningMessages: true, ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), + newProjectsRoot, }; }); @@ -3252,6 +3298,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { "rpc.aggregate": "orchestration", }), + [WS_METHODS.projectsCreateNew]: (input) => + observeRpcEffect(WS_METHODS.projectsCreateNew, createNewProject(input), { + "rpc.aggregate": "orchestration", + }), [WS_METHODS.projectCloneCancel]: (input) => observeRpcEffect( WS_METHODS.projectCloneCancel, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6ea939d666bb..95f2df5928ae 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -10,6 +10,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -43,8 +46,11 @@ import * as Option from "effect/Option"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, + CheckIcon, + ChevronRightIcon, CornerLeftUpIcon, FileSearchIcon, + FolderGit2Icon, FolderIcon, FolderPlusIcon, MessageSquareDashedIcon, @@ -100,6 +106,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; +import { useNewProject } from "../hooks/useNewProject"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; @@ -170,6 +177,7 @@ import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; @@ -862,6 +870,17 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + // The name step of New project: while set, the palette input is the name. + const [newProjectFlow, setNewProjectFlow] = useState<{ + readonly environmentId: EnvironmentId; + /** Machine of the Add project sources view under this step; null from the palette root. */ + readonly sourcesEnvironmentId: EnvironmentId | null; + } | null>(null); + const [newProjectPublishesToGitHub, setNewProjectPublishesToGitHub] = useState(false); + const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); + // State lags a render behind, so a repeated Enter could start a second create. + const newProjectSubmittingRef = useRef(false); + const createNewProject = useNewProject(); const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); @@ -1073,9 +1092,15 @@ function OpenCommandPaletteDialog(props: { getFilesystemBrowsePath( query, browseEnvironmentPlatform, - browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null, ), - [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + [ + browseEnvironmentId, + browseEnvironmentPlatform, + isRemoteProjectRepositoryStep, + newProjectFlow, + query, + ], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1469,8 +1494,12 @@ function OpenCommandPaletteDialog(props: { function popView(): void { browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); + } else if (newProjectFlow?.sourcesEnvironmentId) { + // The machine switcher may have moved off the sources view's machine. + setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId); } setViewStack((previousViews) => previousViews.slice(0, -1)); setHighlightedItemValue(null); @@ -1531,6 +1560,28 @@ function OpenCommandPaletteDialog(props: { [pushPaletteView], ); + /** Folder that holds an environment's name-only projects, or null when it has none. */ + const newProjectsRootFor = useCallback( + (environmentId: EnvironmentId | null): string | null => + environments.find((environment) => environment.environmentId === environmentId)?.serverConfig + ?.newProjectsRoot ?? null, + [environments], + ); + + const startNewProject = useCallback( + (environmentId: EnvironmentId, sourcesEnvironmentId: EnvironmentId | null): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + setNewProjectFlow({ environmentId, sourcesEnvironmentId }); + setNewProjectPublishesToGitHub(false); + pushPaletteView({ + addonIcon: , + groups: [], + }); + }, + [pushPaletteView], + ); + const openSourceControlSettings = useCallback(() => { setOpen(false); void navigate({ to: "/settings/source-control" }); @@ -1556,6 +1607,21 @@ function OpenCommandPaletteDialog(props: { }, ]; + if (newProjectsRootFor(environmentId) !== null) { + sourceItems.unshift({ + kind: "action", + value: `action:add-project:${environmentId}:new`, + searchTerms: ["new project", "create", "empty", "repository", "git init"], + title: "New project", + description: "Start a new Git repository from a name", + icon: , + keepOpen: true, + run: async () => { + startNewProject(environmentId, environmentId); + }, + }); + } + const orderedSources: ReadonlyArray = [ "url", ...sortAddProjectProviderSources(readinessBySource), @@ -1626,7 +1692,13 @@ function OpenCommandPaletteDialog(props: { return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; }, - [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], + [ + newProjectsRootFor, + openSourceControlSettings, + startAddProjectBrowse, + startAddProjectClone, + startNewProject, + ], ); const startAddProjectSourceSelection = useCallback( @@ -1665,24 +1737,36 @@ function OpenCommandPaletteDialog(props: { ], ); - const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( - (option) => ({ - kind: "action", - value: `action:add-project:environment:${option.environmentId}`, - searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], - title: option.label, - description: option.isConnected - ? option.isPrimary - ? "This device" - : option.environmentId - : option.status, - disabled: !option.isConnected, - icon: , - keepOpen: true, - run: async () => { - startAddProjectSourceSelection(option.environmentId); - }, - }), + const buildEnvironmentItem = ( + option: AddProjectEnvironmentOption, + value: string, + run: (environmentId: EnvironmentId) => void, + ): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], + title: option.label, + description: option.isConnected + ? option.isPrimary + ? "This device" + : option.environmentId + : option.status, + disabled: !option.isConnected, + icon: , + keepOpen: true, + run: async () => { + run(option.environmentId); + }, + }); + const addProjectEnvironmentItems = addProjectEnvironmentOptions.map((option) => + buildEnvironmentItem( + option, + `action:add-project:environment:${option.environmentId}`, + startAddProjectSourceSelection, + ), + ); + const newProjectEnvironmentOptions = addProjectEnvironmentOptions.filter( + (option) => option.isConnected && newProjectsRootFor(option.environmentId) !== null, ); const addProjectEnvironmentGroups = useMemo( @@ -1724,12 +1808,20 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + // New project starts on this device (options list it first); the name step + // lists the other machines when there is a choice. + const openNewProjectFlow = () => { + const firstOption = newProjectEnvironmentOptions[0]; + if (firstOption) startNewProject(firstOption.environmentId, null); + }; + useLayoutEffect(() => { if (openIntent?.kind !== "search") return; browseNavigation.invalidate(); cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setLinkedThreadSearch(openIntent); setQuery(openIntent.query); @@ -1751,6 +1843,7 @@ function OpenCommandPaletteDialog(props: { clearOpenIntent(); browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setQuery(""); const currentPrefix = @@ -1949,6 +2042,20 @@ function OpenCommandPaletteDialog(props: { }, }); + if (newProjectEnvironmentOptions.length > 0) { + actionItems.push({ + kind: "action", + value: "action:new-project", + searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"], + title: "New project", + icon: , + keepOpen: true, + run: async () => { + openNewProjectFlow(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:add-project", @@ -2081,6 +2188,7 @@ function OpenCommandPaletteDialog(props: { cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); pushPaletteView({ addonIcon: , @@ -2398,6 +2506,34 @@ function OpenCommandPaletteDialog(props: { ], ); + const newProjectGitHubTarget = + newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null); + const newProjectName = query.trim(); + const canSubmitNewProject = + newProjectFlow !== null && + newProjectName.length > 0 && + !isCreatingNewProject && + canCreateProjectInEnvironment(browseEnvironment?.connection.phase); + + async function submitNewProject(): Promise { + if (newProjectFlow === null || !canSubmitNewProject || newProjectSubmittingRef.current) { + return; + } + newProjectSubmittingRef.current = true; + setIsCreatingNewProject(true); + try { + const created = await createNewProject({ + environmentId: newProjectFlow.environmentId, + name: newProjectName, + github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null, + }); + if (created) setOpen(false); + } finally { + newProjectSubmittingRef.current = false; + setIsCreatingNewProject(false); + } + } + function getDefaultCloneParentPath(environmentId: EnvironmentId): string { return getAddProjectInitialQueryForEnvironment(environmentId); } @@ -2681,8 +2817,135 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; + const newProjectPathPreview = + newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); + const newProjectGitHubToggleValue = "new-project:github"; + // The name step's way out to folders and clones, for the selected machine. + // It replaces the name step (and a sources view for another machine under + // it), so Back returns to wherever New project was opened from. + const showExistingProjectSources = (environmentId: EnvironmentId) => { + const sourcesEnvironmentId = newProjectFlow?.sourcesEnvironmentId ?? null; + if (sourcesEnvironmentId === environmentId) { + popView(); + return; + } + if (sourcesEnvironmentId !== null) { + setViewStack((previousViews) => previousViews.slice(0, -1)); + } + popView(); + startAddProjectSourceSelection(environmentId); + }; + // Switching machines keeps the typed name; the GitHub option follows the + // machine because source control discovery reads addProjectEnvironmentId. + const switchNewProjectEnvironment = (environmentId: EnvironmentId) => { + setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId })); + setAddProjectEnvironmentId(environmentId); + }; + const selectedNewProjectEnvironment = + newProjectFlow === null + ? undefined + : newProjectEnvironmentOptions.find( + (option) => option.environmentId === newProjectFlow.environmentId, + ); + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showNewProjectMachines = + newProjectFlow !== null && + (newProjectEnvironmentOptions.length > 1 || + (selectedNewProjectEnvironment === undefined && newProjectEnvironmentOptions.length > 0)); + const newProjectEnvironmentLabel = showNewProjectMachines + ? (selectedNewProjectEnvironment?.label ?? + environmentLabelById.get(newProjectFlow.environmentId) ?? + null) + : null; + const newProjectMachineGroup: CommandPaletteView["groups"][number] | null = + !showNewProjectMachines || newProjectFlow === null + ? null + : { + value: "new-project-machines", + label: "Environments", + items: newProjectEnvironmentOptions.map((option) => ({ + ...buildEnvironmentItem( + option, + `new-project:environment:${option.environmentId}`, + switchNewProjectEnvironment, + ), + // The create in flight keeps the machine it started on. + ...(isCreatingNewProject ? { disabled: true } : {}), + ...(option.environmentId === newProjectFlow.environmentId + ? { + titleTrailingContent: ( + + ), + } + : {}), + })), + }; + const newProjectExistingGroup: CommandPaletteView["groups"][number] | null = + newProjectFlow === null + ? null + : { + value: "new-project-existing", + label: "", + items: [ + { + kind: "action", + value: "new-project:existing", + searchTerms: [], + title: "Add existing project", + description: "Open a folder or clone a repository", + icon: , + titleTrailingContent: ( + + ), + keepOpen: true, + run: async () => { + showExistingProjectSources(newProjectFlow.environmentId); + }, + }, + ], + }; + const newProjectOptionGroups: CommandPaletteView["groups"] = + newProjectGitHubTarget === null || newProjectPathPreview === null + ? [] + : [ + { + value: "new-project-options", + label: "Options", + items: [ + { + kind: "action", + value: newProjectGitHubToggleValue, + searchTerms: [], + title: "Create private repository on GitHub", + description: + newProjectName.length > 0 + ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview) + : (newProjectGitHubTarget.account ?? "Your GitHub account"), + icon: , + titleTrailingContent: ( + + + + ), + keepOpen: true, + run: async () => { + setNewProjectPublishesToGitHub((publishes) => !publishes); + }, + }, + ], + }, + ]; + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; - if (addProjectCloneFlow?.step === "repository") { + if (newProjectFlow !== null) { + displayedGroups = [ + ...(newProjectMachineGroup ? [newProjectMachineGroup] : []), + ...newProjectOptionGroups, + ...(newProjectExistingGroup ? [newProjectExistingGroup] : []), + ]; + } else if (addProjectCloneFlow?.step === "repository") { displayedGroups = []; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; @@ -2691,8 +2954,10 @@ function OpenCommandPaletteDialog(props: { } const inputPlaceholder = - remoteProjectInputPlaceholder(addProjectCloneFlow) ?? - getCommandPaletteInputPlaceholder(paletteMode); + newProjectFlow !== null + ? "Project name" + : (remoteProjectInputPlaceholder(addProjectCloneFlow) ?? + getCommandPaletteInputPlaceholder(paletteMode)); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = @@ -2799,6 +3064,21 @@ function OpenCommandPaletteDialog(props: { return; } + // Enter creates the project unless an item below the name is + // highlighted, in which case it runs that item. + if ( + newProjectFlow !== null && + event.key === "Enter" && + highlightedItemValue === null && + // Enter that confirms an IME composition is part of typing the name. + !event.nativeEvent.isComposing && + event.keyCode !== 229 + ) { + event.preventDefault(); + void submitNewProject(); + return; + } + const shouldSubmitBrowsePath = canSubmitBrowsePath && event.key === "Enter" && @@ -2957,7 +3237,34 @@ function OpenCommandPaletteDialog(props: { ]); const inputAccessory = - addProjectCloneFlow?.step === "repository" ? ( + newProjectFlow !== null ? ( + + { + event.preventDefault(); + }} + onClick={() => { + void submitNewProject(); + }} + /> + } + > + {isCreatingNewProject ? "Creating" : "Create"} + + Enter + + + Create (Enter) + + ) : addProjectCloneFlow?.step === "repository" ? ( + {newProjectPathPreview !== null ? ( +
+
+ + + + {newProjectName.length > 0 ? newProjectName : "New project"} + + + {newProjectName.length > 0 + ? `Creates ${newProjectPathPreview}` + : `Goes in ${newProjectsRoot}`} + {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`} + + +
+
+ ) : null} {remoteProjectContext ? (
Repository
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b94707f5eca6..4be4aee17ff7 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label ? {group.label} : null} {(item) => item.disabled ? ( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3d5b7bfd311d..ac995a47a670 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -304,7 +304,7 @@ export function DraftHeroHeadline({ {projectPickerEntries.length > 0 ? : null} - New project + Add project diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e552e64c19a3..e2bd4bfb74e5 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -127,7 +127,7 @@ export function SidebarThreadHeader({ {hasProjects ? ( <> {projectScope} - + diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts new file mode 100644 index 000000000000..175609cbfc42 --- /dev/null +++ b/apps/web/src/hooks/useNewProject.ts @@ -0,0 +1,147 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { waitForProject } from "~/state/entities"; +import { projectEnvironment } from "~/state/projects"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An error occurred."; +} + +/** + * Starts a project from just a name. The server makes a folder under its + * `newProjectsRoot` with a README, an icon, and a first commit; this then + * opens a new thread draft in it. With `github`, it also publishes the + * repository as private, without holding up the draft. + * + * Resolves to whether the project was created. + */ +export function useNewProject() { + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const handleNewThread = useNewThreadHandler(); + + const publishToGitHub = useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly account: string | null; + }) => { + const result = await publishRepository({ + environmentId: input.environmentId, + input: { + cwd: input.workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(input, input.workspaceRoot), + visibility: "private", + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the GitHub repository", + description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`, + }), + ); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Published to GitHub", + description: result.value.repository.nameWithOwner, + }), + ); + }, + [publishRepository], + ); + + return useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly name: string; + readonly github: { readonly account: string | null } | null; + }): Promise => { + const result = await createNew({ + environmentId: input.environmentId, + input: { name: input.name }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the project", + description: errorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return false; + } + + const { projectId, workspaceRoot, commitError } = result.value; + // The folder sits in T3 Code's data directory, so always say where. + toastManager.add( + stackedThreadToast( + commitError === undefined + ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot } + : { + type: "warning", + title: `Created ${input.name} without a first commit`, + description: `${commitError} The project is in ${workspaceRoot}.`, + }, + ), + ); + if (input.github) { + void publishToGitHub({ + environmentId: input.environmentId, + workspaceRoot, + account: input.github.account, + }); + } + + const projectRef = scopeProjectRef(input.environmentId, projectId); + // Drafts key off the project's stored path, so wait for the create event + // to reach the store before opening one. + const project = await waitForProject(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`, + }), + ); + return null; + }); + if (project === null) return true; + await handleNewThread(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: errorMessage(error), + }), + ); + }); + return true; + }, + [createNew, handleNewThread, publishToGitHub], + ); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 306632d9ca64..3d068a232be7 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -79,7 +79,15 @@ az extension add --name azure-devops az login ``` -## Clone or publish a project +## Start, clone, or publish a project + +To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or +**New project** under **Add Project** on any client, and type a name. T3 Code makes a Git +repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, +an icon, and a first commit, then opens a new thread in it. The folder is named after the project, +like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also +publish it. If Git has no name or email on that machine, the project is created without the +first commit. Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting provider or paste a Git URL, then choose where to save it. The project opens right away while the diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 597b0b5d7b4f..ec03ea86dce1 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -8,6 +8,7 @@ import type { SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; +import { newProjectFolderName } from "@t3tools/shared/path"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -250,6 +251,36 @@ export function getCloneDestinationPath( return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; } +/** + * Where `projects.createNew` will put a project named `name`. The server adds + * `-2`, `-3`, ... when that folder is taken, so this is a preview. + */ +export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string { + return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); +} + +/** + * The GitHub account a new project would be published under, or null when + * GitHub is not ready on that environment. A ready GitHub with an unknown + * account still publishes; `gh` picks the signed-in user. + */ +export function getNewProjectGitHubTarget( + discovery: SourceControlDiscoveryResult | null, +): { readonly account: string | null } | null { + if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null; + const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github"); + return { account: github ? Option.getOrNull(github.auth.account) : null }; +} + +/** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */ +export function getNewProjectGitHubRepository( + target: { readonly account: string | null }, + workspaceRoot: string, +): string { + const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; + return target.account ? `${target.account}/${folderName}` : folderName; +} + /** * Destination query after choosing a directory while the clone folder is * pinned in the path input. Selecting an existing directory with the pinned diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index d969a8c0d379..5c39113501fb 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -99,6 +99,13 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + // Makes a new folder and repository from just a name, then the project. + createNew: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-new", + tag: WS_METHODS.projectsCreateNew, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index f97924b084fb..a36003eb11b1 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -288,6 +288,20 @@ export const ProjectEnsureScratchResult = Schema.Struct({ }); export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; +/** A project started from just a name, in a new folder the server makes. */ +export const ProjectCreateNewInput = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), +}); +export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type; + +export const ProjectCreateNewResult = Schema.Struct({ + projectId: ProjectId, + workspaceRoot: TrimmedNonEmptyString, + /** Why the first commit failed. The project and its files exist either way. */ + commitError: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41331f0c46cf..c1b25459048e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -159,6 +159,8 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectCreateNewInput, + ProjectCreateNewResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -295,6 +297,7 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", projectsEnsureScratch: "projects.ensureScratch", + projectsCreateNew: "projects.createNew", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -1003,6 +1006,13 @@ const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +// Makes a folder under ServerConfig.newProjectsRoot with a first commit, then the project. +const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { + payload: ProjectCreateNewInput, + success: ProjectCreateNewResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1521,6 +1531,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, + WsProjectsCreateNewRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 49faaaf44712..c8983988ead1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -617,6 +617,11 @@ export const ServerConfig = Schema.Struct({ * and whose data dir is outside a Git checkout. */ scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), + /** + * Folder that holds projects started from just a name. Present only on + * servers that answer projects.createNew. + */ + newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 799f225fb6e2..b018a4e81d8c 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,6 +4,7 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, + newProjectFolderName, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, } from "./path.ts"; @@ -43,4 +44,17 @@ describe("path helpers", () => { // Non-root drive paths keep their trailing separator trimmed as before. expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); }); + + it("names a new project's folder from any typed name", () => { + expect(newProjectFolderName("Pinball Stats")).toBe("pinball-stats"); + expect(newProjectFolderName(" Café & Crème! ")).toBe("cafe-creme"); + expect(newProjectFolderName("../../etc")).toBe("etc"); + // Nothing usable left, so the server falls back to a fixed name. + expect(newProjectFolderName("🎱🎱")).toBe("project"); + expect(newProjectFolderName(`${"a".repeat(63)} b`)).toBe("a".repeat(63)); + // Windows cannot make folders with device names. + expect(newProjectFolderName("Con")).toBe("con-project"); + expect(newProjectFolderName("LPT1")).toBe("lpt1-project"); + expect(newProjectFolderName("console")).toBe("console"); + }); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index b6758d814aa8..c8626eb5e14c 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -53,3 +53,24 @@ export function normalizeProjectPathForComparison(value: string): string { } return normalized; } + +// Windows refuses these as file names, with or without an extension. +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/; + +/** + * Folder name for a project started from just a name ("Pinball Stats" becomes + * "pinball-stats"). The server uses it for `projects.createNew`, and clients + * use it to show the path before the server makes it. + */ +export function newProjectFolderName(name: string): string { + const slug = name + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .slice(0, 64) + .replace(/-+$/, ""); + if (slug.length === 0) return "project"; + return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug; +} From c37210dfef51f77d2560d2167aedf9ceaee7da96 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:49:16 +0000 Subject: [PATCH 4/9] fix(server): keep a ready OpenCode provider through a slow version probe A timeout on opencode --version keeps the last ready, versioned snapshot. The 4 second default is unchanged. T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT can raise it, clamped to 1-60 seconds. A first probe with no prior version and a missing binary still error. Fixes #11080. --- .../provider/Layers/OpenCodeProvider.test.ts | 137 +++++++++++++++++- .../src/provider/Layers/OpenCodeProvider.ts | 83 ++++++++++- .../provider/Layers/ProviderRegistry.test.ts | 132 +++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 43 ++++++ docs/user/providers-opencode.md | 9 ++ 5 files changed, 399 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 55e159197e6b..1212d051a2a9 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -23,7 +24,9 @@ import { import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus, + OPENCODE_VERSION_PROBE_TIMEOUT_ENV, openCodeCommandsToServerProviderSlashCommands, + resolveOpenCodeVersionProbeTimeout, } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; @@ -163,6 +166,7 @@ const runtimeMock = { state: { runVersionError: null as Error | null, runVersionPending: false, + runVersionDelayMs: null as number | null, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, connectionError: null as Error | null, @@ -182,6 +186,7 @@ const runtimeMock = { reset() { this.state.runVersionError = null; this.state.runVersionPending = false; + this.state.runVersionDelayMs = null; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.connectionError = null; @@ -254,7 +259,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: runtimeMock.state.runVersionError, }), ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + : runtimeMock.state.runVersionDelayMs === null + ? Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }) + : Effect.sleep(runtimeMock.state.runVersionDelayMs).pipe( + Effect.as({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + ), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); return {} as unknown as ReturnType; @@ -340,6 +349,52 @@ const checkProvider = Effect.fn("checkProvider")(function* ( ); }); +it("resolves the OpenCode version probe timeout without changing the 4 second default", () => { + const millis = (duration: Duration.Duration) => Duration.toMillis(duration); + + NodeAssert.equal(millis(resolveOpenCodeVersionProbeTimeout({})), millis(Duration.seconds(4))); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8 seconds", + }), + ), + millis(Duration.seconds(8)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "nope", + }), + ), + millis(Duration.seconds(4)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "0 seconds", + }), + ), + millis(Duration.seconds(4)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "1 ms", + }), + ), + millis(Duration.seconds(1)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "Infinity", + }), + ), + millis(Duration.seconds(60)), + ); +}); + it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("shows a codex-style missing binary message", () => Effect.gen(function* () { @@ -369,7 +424,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("times out a hanging local CLI version probe", () => Effect.gen(function* () { runtimeMock.state.runVersionPending = true; - const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe( + Effect.forkChild, + ); yield* Effect.yieldNow; yield* TestClock.adjust("4 seconds"); @@ -384,6 +441,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("keeps the 4 second default when a cold start needs 5 seconds", () => + Effect.gen(function* () { + runtimeMock.state.runVersionDelayMs = 5_000; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe( + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("waits for a slow version probe when the environment timeout allows it", () => + Effect.gen(function* () { + runtimeMock.state.runVersionDelayMs = 5_000; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8s", + }).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("5 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "1.14.19"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("clamps an oversized version probe timeout to 60 seconds", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "2 hours", + }).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("59 seconds"); + yield* Effect.yieldNow; + NodeAssert.equal(probeFiber.pollUnsafe(), undefined); + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("still reports a missing binary when the version probe timeout is raised", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const snapshot = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "60 seconds", + }); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal(snapshot.version, null); + NodeAssert.equal( + snapshot.message, + "OpenCode CLI (`opencode`) is not installed or not on PATH.", + ); + }), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 0495f8b30737..10fcb06fbb13 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -8,7 +8,9 @@ import { import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -33,7 +35,80 @@ const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; -const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; + +/** + * Product default from #8750. It stays 4 seconds so a hung `--version` is + * still killed quickly (#8681). Slow hosts opt in with + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` instead of changing it. + */ +const DEFAULT_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(4); +const MIN_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(1); +const MAX_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(60); +export const OPENCODE_VERSION_PROBE_TIMEOUT_ENV = "T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT"; +const OPENCODE_VERSION_PROBE_TIMEOUT_SHORTHAND = /^(?\d+(?:\.\d+)?)\s*(?ms|s|m|h)$/i; + +const OPENCODE_VERSION_PROBE_TIMEOUT_UNITS = { + ms: "millis", + s: "seconds", + m: "minutes", + h: "hours", +} as const; + +const formatOpenCodeVersionProbeTimeout = (duration: Duration.Duration): string => { + const millis = Duration.toMillis(duration); + if (Number.isInteger(millis) && millis % 1000 === 0) { + const seconds = millis / 1000; + return seconds === 1 ? "1 second" : `${seconds} seconds`; + } + return `${millis} millis`; +}; + +const parseOpenCodeVersionProbeTimeout = ( + raw: string | undefined, +): Duration.Duration | undefined => { + const trimmed = raw?.trim() ?? ""; + if (trimmed.length === 0) { + return undefined; + } + + const shorthand = OPENCODE_VERSION_PROBE_TIMEOUT_SHORTHAND.exec(trimmed); + const shorthandAmount = shorthand?.groups?.amount; + const shorthandUnit = shorthand?.groups?.unit?.toLowerCase(); + const normalized = + shorthandAmount !== undefined && + shorthandUnit !== undefined && + shorthandUnit in OPENCODE_VERSION_PROBE_TIMEOUT_UNITS + ? `${shorthandAmount} ${OPENCODE_VERSION_PROBE_TIMEOUT_UNITS[shorthandUnit as keyof typeof OPENCODE_VERSION_PROBE_TIMEOUT_UNITS]}` + : trimmed; + const decoded = Duration.fromInput(normalized as Duration.Input); + if (Option.isNone(decoded)) { + return undefined; + } + + const millis = Duration.toMillis(decoded.value); + // Non-positive values fall through to the 4 second default. Infinity is + // finite-capped with the rest of the range. + if (!Number.isFinite(millis) || millis <= 0) { + return millis > 0 ? MAX_OPENCODE_VERSION_PROBE_TIMEOUT : undefined; + } + if (millis < Duration.toMillis(MIN_OPENCODE_VERSION_PROBE_TIMEOUT)) { + return MIN_OPENCODE_VERSION_PROBE_TIMEOUT; + } + if (millis > Duration.toMillis(MAX_OPENCODE_VERSION_PROBE_TIMEOUT)) { + return MAX_OPENCODE_VERSION_PROBE_TIMEOUT; + } + return decoded.value; +}; + +/** + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` overrides the 4 second default. + * Invalid text is ignored. Accepted values are clamped to 1-60 seconds. + */ +export const resolveOpenCodeVersionProbeTimeout = ( + environment?: NodeJS.ProcessEnv, +): Duration.Duration => + parseOpenCodeVersionProbeTimeout(environment?.[OPENCODE_VERSION_PROBE_TIMEOUT_ENV]) ?? + DEFAULT_OPENCODE_VERSION_PROBE_TIMEOUT; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause?: unknown; @@ -393,6 +468,8 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const openCodeRuntime = yield* OpenCodeRuntime; const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; const resolvedEnvironment = environment ?? process.env; + const versionProbeTimeout = resolveOpenCodeVersionProbeTimeout(resolvedEnvironment); + const versionProbeTimeoutLabel = formatOpenCodeVersionProbeTimeout(versionProbeTimeout); const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; @@ -455,11 +532,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), Effect.timeoutOrElse({ - duration: OPENCODE_VERSION_PROBE_TIMEOUT, + duration: versionProbeTimeout, orElse: () => Effect.fail( new OpenCodeProbeError({ - detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + detail: `OpenCode CLI version probe timed out after ${versionProbeTimeoutLabel}.`, }), ), }), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0970ea88748b..503c33c99c58 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -922,6 +922,138 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); }); + it("keeps a ready OpenCode snapshot through a version-probe timeout", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], + } as const satisfies ServerProvider; + const timedOutProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + } satisfies ServerProvider; + + const merged = mergeProviderSnapshot(previousProvider, timedOutProvider); + assert.equal(merged.status, "ready"); + assert.equal(merged.version, previousProvider.version); + assert.deepStrictEqual(merged.auth, previousProvider.auth); + assert.deepStrictEqual(merged.models, [...previousProvider.models]); + assert.deepStrictEqual(merged.slashCommands, previousProvider.slashCommands); + assert.deepStrictEqual(merged.skills, previousProvider.skills); + assert.equal(merged.message, timedOutProvider.message); + assert.equal(merged.checkedAt, timedOutProvider.checkedAt); + }); + + it("does not keep last-known-good OpenCode state without a ready versioned snapshot", () => { + const pendingProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "OpenCode provider status has not been checked in this session yet.", + } as const satisfies ServerProvider; + const timedOutProvider = { + ...pendingProvider, + status: "error", + installed: true, + checkedAt: "2026-07-17T00:01:00.000Z", + message: + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + } satisfies ServerProvider; + + const merged = mergeProviderSnapshot(pendingProvider, timedOutProvider); + assert.equal(merged.status, "error"); + assert.equal(merged.version, null); + assert.equal(merged.message, timedOutProvider.message); + }); + + it("does not keep a ready OpenCode snapshot for a missing binary or other probe error", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const otherError = { + ...missingProvider, + installed: true, + message: "Failed to execute OpenCode CLI health check.", + } satisfies ServerProvider; + + const missing = mergeProviderSnapshot(previousProvider, missingProvider); + assert.equal(missing.status, "error"); + assert.equal(missing.installed, false); + assert.equal(missing.version, null); + assert.deepStrictEqual(missing.models, []); + + const failed = mergeProviderSnapshot(previousProvider, otherError); + assert.equal(failed.status, "error"); + assert.equal(failed.version, null); + assert.equal(failed.message, otherError.message); + }); + describe("Codex model inventories", () => { const cachedProvider = { instanceId: ProviderInstanceId.make("codex-personal"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 063d3712c589..6899d9620583 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -173,6 +173,44 @@ const mergeProviderModels = ( : mergedModels; }; +// Matches the detail produced by `checkOpenCodeProviderStatus`. +const OPENCODE_VERSION_PROBE_TIMEOUT_MARKER = "version probe timed out"; + +const isOpenCodeVersionProbeTimeout = (provider: ServerProvider): boolean => + provider.driver === ProviderDriverKind.make("opencode") && + provider.installed && + provider.status === "error" && + provider.version === null && + (provider.message?.includes(OPENCODE_VERSION_PROBE_TIMEOUT_MARKER) ?? false); + +/** + * A slow OpenCode `--version` must not mark a provider Unavailable after it + * has already reported a version. The probe still dies at its cap. Only a + * timeout against a previous ready, versioned snapshot keeps status, version, + * and auth. A first probe, a missing binary, and any other failure replace + * the snapshot. + */ +const carryLastKnownOpenCodeOnVersionProbeTimeout = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): Pick | undefined => { + if ( + !isOpenCodeVersionProbeTimeout(nextProvider) || + previousProvider.driver !== ProviderDriverKind.make("opencode") || + previousProvider.status !== "ready" || + previousProvider.version === null || + previousProvider.version.length === 0 + ) { + return undefined; + } + + return { + auth: previousProvider.auth, + status: previousProvider.status, + version: previousProvider.version, + }; +}; + /** * Antigravity's health check only initializes the agent, so after a server * restart it reports the account as unchecked. The saved Google login still @@ -213,12 +251,17 @@ export const mergeProviderSnapshot = ( return nextProvider; } const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); + const lastKnownOpenCode = carryLastKnownOpenCodeOnVersionProbeTimeout( + previousProvider, + nextProvider, + ); // "Google account access is not checked yet" describes the probe, not the // account; it must not outlive the state it explained. const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; return { ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), ...savedAccount, + ...lastKnownOpenCode, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), ...(nextProvider.workspaceSnapshots !== undefined ? { workspaceSnapshots: nextProvider.workspaceSnapshots } diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 23de99798956..b4cc001b075d 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -20,6 +20,15 @@ fail, check the URL, credentials, and OpenCode version, then refresh provider st After a lost connection, send another prompt to reconnect to the same OpenCode session. +## Slow version checks + +If OpenCode is installed but a refresh leaves it unavailable, the version check +may be timing out. Set `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` on the machine +running T3 Code, for example `10 seconds`. The default wait is 4 seconds, and +any value is limited to 1-60 seconds. Once OpenCode has already been ready, a +slow check keeps that status. A missing `opencode` binary still reports as not +installed. + ## Approvals OpenCode follows the shared [permission modes](./permission-modes.md). **Auto** has From f6e8a0e5f95256010bd52fb547cb98e256e47a72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 10:34:07 +0000 Subject: [PATCH 5/9] fix(server): match OpenCode version-probe timeouts exactly A non-timeout launch failure was classified as a timeout when binaryPath contained "version probe timed out", so the last ready snapshot was kept. The merge now requires the provider message to equal the exact timeout text, including its duration label. Fixes #11080. Verification: vp test run apps/server/src/provider/Layers/ProviderRegistry.test.ts apps/server/src/provider/Layers/OpenCodeProvider.test.ts Test Files 2 passed (2) Tests 79 passed (79) Duration 3.18s Model: Grok 4.7 via Cursor cloud agent Co-authored-by: maco --- .../src/provider/Layers/OpenCodeProvider.ts | 20 ++++++- .../provider/Layers/ProviderRegistry.test.ts | 59 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 49 ++++++++++++--- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 10fcb06fbb13..afa1705c6658 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -63,6 +63,22 @@ const formatOpenCodeVersionProbeTimeout = (duration: Duration.Duration): string return `${millis} millis`; }; +const OPENCODE_CLI_HEALTH_CHECK_FAILURE = "Failed to execute OpenCode CLI health check"; + +/** Text before the duration label in the exact `--version` timeout message. */ +export const OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX = `${OPENCODE_CLI_HEALTH_CHECK_FAILURE}: OpenCode CLI version probe timed out after `; + +/** Detail text for a `--version` probe that reached its cap. */ +const openCodeVersionProbeTimeoutDetail = (timeoutLabel: string): string => + `OpenCode CLI version probe timed out after ${timeoutLabel}.`; + +/** + * Exact provider message for that timeout. Snapshot merging must compare this + * with `===`; a launch error that only contains the phrase is a different failure. + */ +export const openCodeVersionProbeTimeoutMessage = (timeoutLabel: string): string => + `${OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX}${timeoutLabel}.`; + const parseOpenCodeVersionProbeTimeout = ( raw: string | undefined, ): Duration.Duration | undefined => { @@ -210,7 +226,7 @@ function formatOpenCodeProbeError(input: { const failureLabel = input.phase === "inventory" ? "Failed to load OpenCode provider inventory" - : "Failed to execute OpenCode CLI health check"; + : OPENCODE_CLI_HEALTH_CHECK_FAILURE; return { installed: true, message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, @@ -536,7 +552,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu orElse: () => Effect.fail( new OpenCodeProbeError({ - detail: `OpenCode CLI version probe timed out after ${versionProbeTimeoutLabel}.`, + detail: openCodeVersionProbeTimeoutDetail(versionProbeTimeoutLabel), }), ), }), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 503c33c99c58..e9ba672fa6b7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -973,6 +973,65 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test assert.deepStrictEqual(merged.skills, previousProvider.skills); assert.equal(merged.message, timedOutProvider.message); assert.equal(merged.checkedAt, timedOutProvider.checkedAt); + + for (const message of [ + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1 second.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1500 millis.", + ]) { + const variant = mergeProviderSnapshot(previousProvider, { ...timedOutProvider, message }); + assert.equal(variant.status, "ready", message); + assert.equal(variant.version, previousProvider.version, message); + assert.deepStrictEqual(variant.auth, previousProvider.auth, message); + } + }); + + it("does not treat a launch failure that only contains the timeout phrase as a version-probe timeout", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const binaryPath = "/opt/version probe timed out/opencode"; + const messages = [ + `Failed to execute OpenCode CLI health check: Failed to execute '${binaryPath} --version': spawn ${binaryPath} ENOENT`, + "version probe timed out", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds. See the binary path.", + "Note: Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds", + ]; + + for (const message of messages) { + const failedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message, + } satisfies ServerProvider; + const merged = mergeProviderSnapshot(previousProvider, failedProvider); + assert.equal(merged.status, "error", message); + assert.equal(merged.version, null, message); + assert.deepStrictEqual(merged.auth, { status: "unknown" }, message); + assert.equal(merged.message, message); + } }); it("does not keep last-known-good OpenCode state without a ready versioned snapshot", () => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 6899d9620583..9b456e91a9b9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -56,6 +56,10 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { + OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX, + openCodeVersionProbeTimeoutMessage, +} from "./OpenCodeProvider.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -173,15 +177,44 @@ const mergeProviderModels = ( : mergedModels; }; -// Matches the detail produced by `checkOpenCodeProviderStatus`. -const OPENCODE_VERSION_PROBE_TIMEOUT_MARKER = "version probe timed out"; +// Labels `formatOpenCodeVersionProbeTimeout` can emit: "1 second", "N seconds", +// or "N millis" when the cap is not a whole number of seconds. +const OPENCODE_VERSION_PROBE_TIMEOUT_LABEL = /^(?:1 second|[1-9]\d* seconds|[1-9]\d* millis)$/; -const isOpenCodeVersionProbeTimeout = (provider: ServerProvider): boolean => - provider.driver === ProviderDriverKind.make("opencode") && - provider.installed && - provider.status === "error" && - provider.version === null && - (provider.message?.includes(OPENCODE_VERSION_PROBE_TIMEOUT_MARKER) ?? false); +/** + * Duration label inside the exact `--version` timeout message. A launch + * failure whose text merely contains "version probe timed out" — including a + * configured `binaryPath` with that phrase — does not match. + */ +const readOpenCodeVersionProbeTimeoutLabel = (message: string | undefined): string | undefined => { + if ( + message === undefined || + !message.startsWith(OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX) || + !message.endsWith(".") + ) { + return undefined; + } + const label = message.slice(OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX.length, -1); + if (!OPENCODE_VERSION_PROBE_TIMEOUT_LABEL.test(label)) { + return undefined; + } + return message === openCodeVersionProbeTimeoutMessage(label) ? label : undefined; +}; + +const isOpenCodeVersionProbeTimeout = (provider: ServerProvider): boolean => { + const label = readOpenCodeVersionProbeTimeoutLabel(provider.message); + if (label === undefined) { + return false; + } + const OPENCODE_VERSION_PROBE_TIMEOUT_MARKER = openCodeVersionProbeTimeoutMessage(label); + return ( + provider.driver === ProviderDriverKind.make("opencode") && + provider.installed && + provider.status === "error" && + provider.version === null && + provider.message === OPENCODE_VERSION_PROBE_TIMEOUT_MARKER + ); +}; /** * A slow OpenCode `--version` must not mark a provider Unavailable after it From 3170be9391be18a5b2f6b48bd45ee7af256bb340 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 11:34:10 +0000 Subject: [PATCH 6/9] docs(server): document OpenCode version-probe helpers CodeRabbit docstring coverage on PR #14575 was 0.00% (threshold 80%, one function across four files). Added JSDoc on the production functions this change touches, including the exact timeout match and the probe error formatter. No runtime behavior change. Verification: ./node_modules/.bin/vp test run apps/server/src/provider/Layers/ProviderRegistry.test.ts apps/server/src/provider/Layers/OpenCodeProvider.test.ts Test Files 2 passed (2) Tests 79 passed (79) Duration 3.31s ./node_modules/.bin/vp lint --report-unused-disable-directives apps/server/src/provider/Layers/OpenCodeProvider.ts apps/server/src/provider/Layers/ProviderRegistry.ts exit 0 ./node_modules/.bin/vp run --filter t3 typecheck exit 0 (pre-existing suggestions only; none in these files) Model: Grok 4.7 via Cursor cloud agent Co-authored-by: maco --- .../src/provider/Layers/OpenCodeProvider.ts | 22 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 13 +++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index afa1705c6658..9e1897ff6a5e 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -54,6 +54,11 @@ const OPENCODE_VERSION_PROBE_TIMEOUT_UNITS = { h: "hours", } as const; +/** + * Duration label placed in the `--version` timeout message. Whole seconds + * render as "1 second" or "N seconds"; every other duration renders as + * "N millis". The registry only treats those shapes as a probe timeout. + */ const formatOpenCodeVersionProbeTimeout = (duration: Duration.Duration): string => { const millis = Duration.toMillis(duration); if (Number.isInteger(millis) && millis % 1000 === 0) { @@ -79,6 +84,12 @@ const openCodeVersionProbeTimeoutDetail = (timeoutLabel: string): string => export const openCodeVersionProbeTimeoutMessage = (timeoutLabel: string): string => `${OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX}${timeoutLabel}.`; +/** + * Parses `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT`. Empty or unparseable text + * returns undefined so the 4 second default stands. `ms`, `s`, `m`, and `h` + * shorthand is accepted. Finite values clamp to 1-60 seconds; non-positive + * values are ignored and an infinite value uses the 60 second cap. + */ const parseOpenCodeVersionProbeTimeout = ( raw: string | undefined, ): Duration.Duration | undefined => { @@ -157,6 +168,11 @@ function normalizedErrorMessage(cause: unknown): string | undefined { return normalizeProbeMessage(cause.message); } +/** + * Maps a version or inventory probe failure onto the snapshot fields the UI + * shows. Local `--version` failures use `OPENCODE_CLI_HEALTH_CHECK_FAILURE`, + * so a timeout stays identical to `openCodeVersionProbeTimeoutMessage`. + */ function formatOpenCodeProbeError(input: { readonly cause: unknown; readonly isExternalServer: boolean; @@ -472,6 +488,12 @@ export const makePendingOpenCodeProvider = ( }); }); +/** + * Checks OpenCode once and returns its provider snapshot. A local install + * runs `--version` under `resolveOpenCodeVersionProbeTimeout` before loading + * inventory; a timeout is reported with `openCodeVersionProbeTimeoutDetail`. + * A configured server URL skips that CLI probe and connects directly. + */ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, cwd: string, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 9b456e91a9b9..186eeb5f00ee 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -201,6 +201,12 @@ const readOpenCodeVersionProbeTimeoutLabel = (message: string | undefined): stri return message === openCodeVersionProbeTimeoutMessage(label) ? label : undefined; }; +/** + * True when `provider` is an installed OpenCode instance whose snapshot failed + * with the exact `--version` timeout message and no version. A launch error + * that only contains that phrase, including a `binaryPath` with the words, is + * not a timeout. + */ const isOpenCodeVersionProbeTimeout = (provider: ServerProvider): boolean => { const label = readOpenCodeVersionProbeTimeoutLabel(provider.message); if (label === undefined) { @@ -276,6 +282,13 @@ const carrySavedAntigravityAccount = ( return { auth: previousProvider.auth, status }; }; +/** + * Folds a fresh probe into the previous snapshot. Carries Antigravity account + * state forward, and keeps a ready OpenCode version, status, and auth across + * an exact `--version` timeout. Models, workspace snapshots, and empty + * OpenCode skills or slash commands stay when the new probe did not replace + * them. + */ export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, From ab0b36c945767e230e7e7198b4234497f01e532e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:37:48 +0000 Subject: [PATCH 7/9] docs(server): document the OpenCode status-check generator CodeRabbit docstring coverage on PR #14575 was 50.00% (threshold 80%, two functions across four files). The status-check JSDoc sat above the export, so it did not cover the function* passed to Effect.fn. That comment now precedes the generator. No runtime behavior change. Verification (Node v24.13.1): ./node_modules/.bin/vp test run apps/server/src/provider/Layers/ProviderRegistry.test.ts apps/server/src/provider/Layers/OpenCodeProvider.test.ts Test Files 2 passed (2) Tests 79 passed (79) Duration 3.79s ./node_modules/.bin/vp lint --report-unused-disable-directives apps/server/src/provider/Layers/OpenCodeProvider.ts apps/server/src/provider/Layers/ProviderRegistry.ts exit 0 ./node_modules/.bin/vp fmt --check apps/server/src/provider/Layers/OpenCodeProvider.ts exit 0 (all matched files use the correct format) ./node_modules/.bin/vp run --filter t3 typecheck exit 0 (pre-existing suggestions only in CodexInstallation.ts, CodexAdapter.test.ts, CodexProvider.ts, and ws.ts; none in these files) Model: Grok 4.7 via Cursor cloud agent Co-authored-by: maco --- .../src/provider/Layers/OpenCodeProvider.ts | 359 +++++++++--------- 1 file changed, 182 insertions(+), 177 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 9e1897ff6a5e..f685a538b80e 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -488,197 +488,202 @@ export const makePendingOpenCodeProvider = ( }); }); -/** - * Checks OpenCode once and returns its provider snapshot. A local install - * runs `--version` under `resolveOpenCodeVersionProbeTimeout` before loading - * inventory; a timeout is reported with `openCodeVersionProbeTimeoutDetail`. - * A configured server URL skips that CLI probe and connects directly. - */ -export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( - openCodeSettings: OpenCodeSettings, - cwd: string, - environment?: NodeJS.ProcessEnv, -): Effect.fn.Return< - ServerProviderDraft, - never, - OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner -> { - const openCodeRuntime = yield* OpenCodeRuntime; - const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; - const resolvedEnvironment = environment ?? process.env; - const versionProbeTimeout = resolveOpenCodeVersionProbeTimeout(resolvedEnvironment); - const versionProbeTimeoutLabel = formatOpenCodeVersionProbeTimeout(versionProbeTimeout); - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const customModels = openCodeSettings.customModels; - const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - - const fallback = ( - cause: unknown, - version: string | null = null, - phase: "version" | "inventory" = "version", - ) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - phase, - serverUrl: openCodeSettings.serverUrl, - }); - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, - }); - }; - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: isExternalServer - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - - let version: string | null = null; - if (!isExternalServer) { - const versionExit = yield* Effect.exit( - openCodeRuntime - .runOpenCodeCommand({ - binaryPath: openCodeSettings.binaryPath, - args: ["--version"], - environment: resolvedEnvironment, - }) - .pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - Effect.timeoutOrElse({ - duration: versionProbeTimeout, - orElse: () => - Effect.fail( - new OpenCodeProbeError({ - detail: openCodeVersionProbeTimeoutDetail(versionProbeTimeoutLabel), - }), - ), - }), - ), - ); - if (versionExit._tag === "Failure") { - return fallback(Cause.squash(versionExit.cause)); - } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; - - if (!version) { - return fallback( - new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), - null, - ); - } - if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { +export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")( + /** + * Checks OpenCode once and returns its provider snapshot. A local install + * runs `--version` under `resolveOpenCodeVersionProbeTimeout` before loading + * inventory; a timeout is reported with `openCodeVersionProbeTimeoutDetail`. + * A configured server URL skips that CLI probe and connects directly. + */ + function* ( + openCodeSettings: OpenCodeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, + ): Effect.fn.Return< + ServerProviderDraft, + never, + OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner + > { + const openCodeRuntime = yield* OpenCodeRuntime; + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; + const resolvedEnvironment = environment ?? process.env; + const versionProbeTimeout = resolveOpenCodeVersionProbeTimeout(resolvedEnvironment); + const versionProbeTimeoutLabel = formatOpenCodeVersionProbeTimeout(versionProbeTimeout); + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const customModels = openCodeSettings.customModels; + const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; + + const fallback = ( + cause: unknown, + version: string | null = null, + phase: "version" | "inventory" = "version", + ) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + phase, + serverUrl: openCodeSettings.serverUrl, + }); return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { - installed: true, + installed: failure.installed, version, status: "error", auth: { status: "unknown" }, - message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + message: failure.message, + }, + }); + }; + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", }, }); } - } - const loadInventory = (server: { - readonly url: string; - readonly serverPassword?: string; - readonly version: string; - }) => - openCodeRuntime - .loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }), - ) - .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); - const inventoryEffect = isExternalServer - ? openCodeRuntime - .connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - directory: cwd, - serverUrl: openCodeSettings.serverUrl, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }) - .pipe(Effect.flatMap(loadInventory), Effect.scoped) - : serverOwner.withServer(loadInventory); - const inventoryExit = yield* Effect.exit( - inventoryEffect.pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + openCodeRuntime + .runOpenCodeCommand({ + binaryPath: openCodeSettings.binaryPath, + args: ["--version"], + environment: resolvedEnvironment, + }) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + Effect.timeoutOrElse({ + duration: versionProbeTimeout, + orElse: () => + Effect.fail( + new OpenCodeProbeError({ + detail: openCodeVersionProbeTimeoutDetail(versionProbeTimeoutLabel), + }), + ), + }), + ), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + + if (!version) { + return fallback( + new Error( + `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + ), + null, + ); + } + if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); + } + } + + const loadInventory = (server: { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + }) => + openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined + ? { serverPassword: server.serverPassword } + : {}), + }), + ) + .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + const inventoryEffect = isExternalServer + ? openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + directory: cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe(Effect.flatMap(loadInventory), Effect.scoped) + : serverOwner.withServer(loadInventory); + const inventoryExit = yield* Effect.exit( + inventoryEffect.pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), ), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); - } + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); + } - version = inventoryExit.value.version; + version = inventoryExit.value.version; - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value.inventory), - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); - const connectedCount = inventoryExit.value.inventory.providerList.connected.length; - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: true, - checkedAt, - models, - skills, - slashCommands: openCodeCommandsToServerProviderSlashCommands( - inventoryExit.value.inventory.commands, - ), - probe: { - installed: true, - version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value.inventory), + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); + const connectedCount = inventoryExit.value.inventory.providerList.connected.length; + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + skills, + slashCommands: openCodeCommandsToServerProviderSlashCommands( + inventoryExit.value.inventory.commands, + ), + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", - }, - }); -}); + }); + }, +); From 04bd5afd99c363c7afbe155e767a29ef300d3df4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 13:41:00 +0000 Subject: [PATCH 8/9] docs(server): document both OpenCode status-check functions CodeRabbit docstring coverage on PR #14575 stayed at 50% after the status-check comment moved onto the Effect.fn generator. That comment documents the generator, and the exported binding needs its own JSDoc immediately above the declaration. No runtime behavior change. Verification (Node v24.13.1, linux): ./node_modules/.bin/vp test run apps/server/src/provider/Layers/ProviderRegistry.test.ts apps/server/src/provider/Layers/OpenCodeProvider.test.ts Test Files 2 passed (2) Tests 79 passed (79) Duration 3.25s ./node_modules/.bin/vp lint --report-unused-disable-directives apps/server/src/provider/Layers/OpenCodeProvider.ts apps/server/src/provider/Layers/ProviderRegistry.ts exit 0 ./node_modules/.bin/vp fmt --check apps/server/src/provider/Layers/OpenCodeProvider.ts exit 0 (all matched files use the correct format) ./node_modules/.bin/vp run --filter t3 typecheck exit 0 (pre-existing suggestions only in CodexInstallation.ts, CodexAdapter.test.ts, CodexProvider.ts, and ws.ts; none in these files) Model: Grok 4.7 via Cursor cloud agent Co-authored-by: maco --- apps/server/src/provider/Layers/OpenCodeProvider.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index f685a538b80e..43fca8e00370 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -488,9 +488,15 @@ export const makePendingOpenCodeProvider = ( }); }); +/** + * Checks OpenCode once and returns its provider snapshot. A local install + * runs `--version` under `resolveOpenCodeVersionProbeTimeout` before loading + * inventory; a timeout is reported with `openCodeVersionProbeTimeoutDetail`. + * A configured server URL skips that CLI probe and connects directly. + */ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")( /** - * Checks OpenCode once and returns its provider snapshot. A local install + * Generator passed to `Effect.fn` for that status check. A local install * runs `--version` under `resolveOpenCodeVersionProbeTimeout` before loading * inventory; a timeout is reported with `openCodeVersionProbeTimeoutDetail`. * A configured server URL skips that CLI probe and connects directly. From c9ac543fdbbaf2a0318a486b44ada30f886f042c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:41:25 +0000 Subject: [PATCH 9/9] docs(server): document the OpenCode probe error helpers CodeRabbit's 50% score is the two function declarations beside the probe error formatter, not the Effect.fn status check. normalizedErrorMessage had no docstring. normalizeProbeMessage is documented too, because the new comment hunk would otherwise count that preceding declaration as undocumented. Co-authored-by: maco --- apps/server/src/provider/Layers/OpenCodeProvider.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 43fca8e00370..46c2760fba96 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -142,6 +142,10 @@ class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly detail: string; }> {} +/** + * Drops blank text and Effect's generic `try` / `tryPromise` wrappers so a + * probe failure keeps the detail the snapshot can show. + */ function normalizeProbeMessage(message: string): string | undefined { const trimmed = message.trim(); if (trimmed.length === 0) { @@ -156,6 +160,11 @@ function normalizeProbeMessage(message: string): string | undefined { return trimmed; } +/** + * Detail string for a probe failure. `OpenCodeProbeError` contributes its + * `detail`; any other `Error` contributes `message`. Both go through + * `normalizeProbeMessage`. + */ function normalizedErrorMessage(cause: unknown): string | undefined { if (cause instanceof OpenCodeProbeError) { return normalizeProbeMessage(cause.detail);