From 95d4c709a4dbc209609466e3c92673a835920fbb Mon Sep 17 00:00:00 2001 From: imqdee Date: Tue, 10 Nov 2026 14:24:00 +0100 Subject: [PATCH 1/2] fix(mobile): the No project row keeps its place while environments sync --- .../features/threads/NewTaskRouteScreen.tsx | 12 ++- .../new-task-project-selection.test.ts | 74 +++++++++++++++---- .../threads/new-task-project-selection.ts | 41 ++++++---- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index d904c3c67c543..1ccf4bb46d7e6 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -206,7 +206,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { @@ -334,7 +340,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void startScratch()} leading={ @@ -370,7 +376,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps No project - Start a task without a project + {scratchSubtitle} { it("offers the option from the cached config while the environment is still connecting", () => { expect( resolveNewTaskScratchOption({ - environments: [{ environmentId, connectionState: "connecting" }], + environments: [{ environmentId, isEnabled: true, connectionState: "connecting" }], serverConfigs: configs, selectedEnvironmentId: null, }), - ).toEqual({ environmentId, ready: false }); + ).toEqual({ environmentId, status: "pending" }); }); it("becomes ready without changing environment once connected", () => { expect( resolveNewTaskScratchOption({ - environments: [{ environmentId, connectionState: "connected" }], + environments: [{ environmentId, isEnabled: true, connectionState: "connected" }], serverConfigs: configs, selectedEnvironmentId: null, }), - ).toEqual({ environmentId, ready: true }); + ).toEqual({ environmentId, status: "ready" }); }); - it("hides the option when no environment reports a scratch root", () => { + it("reserves the option before any config arrives, so the list never shifts", () => { + for (const connectionState of ["connecting", "connected", "offline"] as const) { + expect( + resolveNewTaskScratchOption({ + environments: [{ environmentId, isEnabled: true, connectionState }], + serverConfigs: new Map(), + selectedEnvironmentId: null, + }), + ).toEqual({ environmentId, status: "pending" }); + } + }); + + it("keeps the option, unavailable, once every connected environment reports no scratch root", () => { + expect( + resolveNewTaskScratchOption({ + environments: [{ environmentId, isEnabled: true, connectionState: "connected" }], + serverConfigs: new Map([[environmentId, {}]]), + selectedEnvironmentId: null, + }), + ).toEqual({ environmentId, status: "unavailable" }); + expect( + resolveNewTaskScratchOption({ + environments: [ + { environmentId, isEnabled: true, connectionState: "connected" }, + { environmentId: otherId, isEnabled: true, connectionState: "connecting" }, + ], + serverConfigs: new Map([[environmentId, {}]]), + selectedEnvironmentId: null, + }), + ).toEqual({ environmentId, status: "pending" }); + }); + + it("ignores disabled and unsupported environments, which never report a config", () => { + const disabledId = EnvironmentId.make("disabled"); + const environments = [ + { environmentId, isEnabled: true, connectionState: "connected" as const }, + { environmentId: otherId, isEnabled: true, connectionState: "unsupported" as const }, + { environmentId: disabledId, isEnabled: false, connectionState: "available" as const }, + ]; expect( resolveNewTaskScratchOption({ - environments: [{ environmentId, connectionState: "connected" }], + environments, serverConfigs: new Map([[environmentId, {}]]), selectedEnvironmentId: null, }), + ).toEqual({ environmentId, status: "unavailable" }); + expect( + resolveNewTaskScratchOption({ + environments: environments.slice(1), + serverConfigs: new Map(), + selectedEnvironmentId: null, + }), ).toBeNull(); + }); + + it("offers nothing without an environment", () => { expect( resolveNewTaskScratchOption({ - environments: [{ environmentId, connectionState: "connected" }], + environments: [], serverConfigs: new Map(), selectedEnvironmentId: null, }), @@ -314,8 +362,8 @@ describe("resolveNewTaskScratchOption", () => { expect( resolveNewTaskScratchOption({ environments: [ - { environmentId, connectionState: "reconnecting" }, - { environmentId: otherId, connectionState: "connected" }, + { environmentId, isEnabled: true, connectionState: "reconnecting" }, + { environmentId: otherId, isEnabled: true, connectionState: "connected" }, ], serverConfigs: new Map([ [environmentId, { scratchWorkspaceRoot: "/scratch" }], @@ -323,15 +371,15 @@ describe("resolveNewTaskScratchOption", () => { ]), selectedEnvironmentId: environmentId, }), - ).toEqual({ environmentId: otherId, ready: true }); + ).toEqual({ environmentId: otherId, status: "ready" }); }); it("keeps the selected environment when every offering environment is connected", () => { expect( resolveNewTaskScratchOption({ environments: [ - { environmentId: otherId, connectionState: "connected" }, - { environmentId, connectionState: "connected" }, + { environmentId: otherId, isEnabled: true, connectionState: "connected" }, + { environmentId, isEnabled: true, connectionState: "connected" }, ], serverConfigs: new Map([ [environmentId, { scratchWorkspaceRoot: "/scratch" }], @@ -339,6 +387,6 @@ describe("resolveNewTaskScratchOption", () => { ]), selectedEnvironmentId: environmentId, }), - ).toEqual({ environmentId, ready: true }); + ).toEqual({ environmentId, status: "ready" }); }); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 93c8f1d5f51d6..1bcd7406a858f 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,5 +1,5 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { repositoryGroupingKeyOf, type EnvironmentId, type ServerConfig } from "@t3tools/contracts"; @@ -48,31 +48,44 @@ export function filterProjectScopes( ); } -// Cached configuration reserves the row; connection readiness only gates activation. +// Any environment that can answer reserves the row so the list never shifts; config and connection only set its status. export function resolveNewTaskScratchOption(input: { readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; + readonly isEnabled: boolean; readonly connectionState: EnvironmentConnectionPhase; }>; readonly serverConfigs: ReadonlyMap>; readonly selectedEnvironmentId: EnvironmentId | null; -}) { - const offering = input.environments.filter( - (environment) => - (input.serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot ?? null) !== null, +}): { + readonly environmentId: EnvironmentId; + readonly status: "ready" | "pending" | "unavailable"; +} | null { + // Disabled and unsupported environments never receive a config, so they would hold the row pending forever. + const answering = input.environments.filter( + (environment) => environment.isEnabled && environment.connectionState !== "unsupported", ); - const ready = offering.filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - input.serverConfigs.get(environment.environmentId), - ) !== null, + const rootOf = (environmentId: EnvironmentId) => + input.serverConfigs.get(environmentId)?.scratchWorkspaceRoot ?? null; + const offering = answering.filter((environment) => rootOf(environment.environmentId) !== null); + const ready = offering.filter((environment) => + canCreateProjectInEnvironment(environment.connectionState), ); - const candidates = ready.length > 0 ? ready : offering; + const candidates = ready.length > 0 ? ready : offering.length > 0 ? offering : answering; const environment = candidates.find((candidate) => candidate.environmentId === input.selectedEnvironmentId) ?? candidates[0]; - return environment ? { environmentId: environment.environmentId, ready: ready.length > 0 } : null; + if (!environment) return null; + const knownUnsupported = answering.every( + (candidate) => + canCreateProjectInEnvironment(candidate.connectionState) && + input.serverConfigs.has(candidate.environmentId) && + rootOf(candidate.environmentId) === null, + ); + return { + environmentId: environment.environmentId, + status: ready.length > 0 ? "ready" : knownUnsupported ? "unavailable" : "pending", + }; } export function resolveEnvironmentProjectMatch( From 94a5f909af35d307d87f6799b5b9469c89d364dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:08:23 +0000 Subject: [PATCH 2/2] test(mobile): drop the scratch option case another case already covers AI-assisted; verified with the tests and tools listed above. --- .../threads/new-task-project-selection.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index f80a4fc0b44e2..3406d6778b72d 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -348,16 +348,6 @@ describe("resolveNewTaskScratchOption", () => { ).toBeNull(); }); - it("offers nothing without an environment", () => { - expect( - resolveNewTaskScratchOption({ - environments: [], - serverConfigs: new Map(), - selectedEnvironmentId: null, - }), - ).toBeNull(); - }); - it("prefers a connected environment over the selected one that is still reconnecting", () => { expect( resolveNewTaskScratchOption({