Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
selectedEnvironmentId,
})
: null;
const canStartScratch = scratchOption?.ready === true;
const canStartScratch = scratchOption?.status === "ready";
const scratchSubtitle =
scratchOption?.status === "unavailable"
? "Not available on this environment"
: scratchOption?.status === "pending"
? "Waiting for your environment"
: "Start a task without a project";
const scratchStartInFlightRef = useRef(false);

async function selectProject(project: EnvironmentProject): Promise<void> {
Expand Down Expand Up @@ -357,7 +363,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
<NavigationRow
className="bg-grouped-card"
title="No project"
subtitle="Start a task without a project"
subtitle={scratchSubtitle}
disabled={!canStartScratch}
onPress={() => void startScratch()}
leading={
Expand Down Expand Up @@ -393,7 +399,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
<View className="min-w-0 flex-1">
<Text className="text-base font-t3-bold leading-snug">No project</Text>
<Text className="text-xs leading-snug text-foreground-muted" numberOfLines={1}>
Start a task without a project
{scratchSubtitle}
</Text>
</View>
<SymbolView
Expand Down
66 changes: 52 additions & 14 deletions apps/mobile/src/features/threads/new-task-project-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,34 +276,72 @@ describe("resolveNewTaskScratchOption", () => {
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, connectionState: "connected" }],
environments: [{ environmentId, isEnabled: true, connectionState: "connected" }],
serverConfigs: new Map([[environmentId, {}]]),
selectedEnvironmentId: null,
}),
).toBeNull();
).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,
serverConfigs: new Map([[environmentId, {}]]),
selectedEnvironmentId: null,
}),
).toEqual({ environmentId, status: "unavailable" });
expect(
resolveNewTaskScratchOption({
environments: [{ environmentId, connectionState: "connected" }],
environments: environments.slice(1),
serverConfigs: new Map(),
selectedEnvironmentId: null,
}),
Expand All @@ -314,31 +352,31 @@ 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" }],
[otherId, { scratchWorkspaceRoot: "/other-scratch" }],
]),
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" }],
[otherId, { scratchWorkspaceRoot: "/other-scratch" }],
]),
selectedEnvironmentId: environmentId,
}),
).toEqual({ environmentId, ready: true });
).toEqual({ environmentId, status: "ready" });
});
});
41 changes: 27 additions & 14 deletions apps/mobile/src/features/threads/new-task-project-selection.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<EnvironmentId, Pick<ServerConfig, "scratchWorkspaceRoot">>;
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(
Expand Down
Loading