Skip to content
Merged
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
22 changes: 18 additions & 4 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import {
restoreComposerDraftSnapshot,
type ComposerDraft,
} from "../../state/use-composer-drafts";
import { useProjects } from "../../state/entities";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import { resolveSelectableModelSelection } from "../../lib/modelOptions";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
Expand Down Expand Up @@ -90,6 +91,9 @@ export function NewTaskDraftScreen(props: {
const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
const { logicalProjects, selectedProject, setProject } = flow;
const { connectedEnvironments } = useRemoteConnectionStatus();
const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
selectedProject?.environmentId ?? null,
);
const environmentConnected =
selectedProject !== null &&
connectedEnvironments.find(
Expand Down Expand Up @@ -795,7 +799,14 @@ export function NewTaskDraftScreen(props: {
return;
}
const draft = getComposerDraftSnapshot(draftKey);
const modelSelection = draft.modelSelection ?? flow.selectedModel;
// Snapshot read keeps just-typed selector state; the availability gate
// still applies so a stored selection on a disabled provider falls back
// to the flow's resolved model.
const modelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? flow.selectedModel;
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const selectedWorktreePath =
Expand Down Expand Up @@ -847,7 +858,10 @@ export function NewTaskDraftScreen(props: {
if (editingPendingTask) {
flow.finishEditingPendingTask();
} else {
clearComposerDraftContent(draftKey);
// Drop the workspace selection with the content: the next task should
// re-resolve mode/branch/origin from the server's configured defaults
// instead of resurrecting this task's picks.
clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
}
navigation.getParent()?.goBack();
return;
Expand Down Expand Up @@ -905,7 +919,7 @@ export function NewTaskDraftScreen(props: {
}
flow.finishEditingPendingTask();
} else {
clearComposerDraftContent(draftKey);
clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
}
navigation.dispatch(
StackActions.replace("Thread", {
Expand Down
49 changes: 37 additions & 12 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../
import type { TurnCommandMetadata } from "../../lib/commandMetadata";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import {
buildModelOptions,
groupByProvider,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { groupProjectsByRepository } from "../../lib/repositoryGroups";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { appAtomRegistry } from "../../state/atom-registry";
Expand Down Expand Up @@ -347,7 +351,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
const prompt = selectedProjectDraft.text;
const attachments = selectedProjectDraft.attachments;
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";
// The server's configured default decides the mode until the user picks one
// explicitly — same resolution web uses for new draft threads.
const defaultWorkspaceMode: WorkspaceMode =
selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local";
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
// Keep the user's explicit choice separate from the resolved display value:
Expand All @@ -361,22 +369,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;

// Stored selections (draft and project default) only count while their
// provider is usable on the server; otherwise the server's default model
// wins instead of silently targeting a disabled provider.
const draftModelSelection = resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
selectedProjectDraft.modelSelection ?? null,
);
const projectDefaultModelSelection = resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
selectedProject?.defaultModelSelection ?? null,
);
const modelOptions = useMemo(
() =>
buildModelOptions(
selectedEnvironmentServerConfig,
selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null,
draftModelSelection ?? projectDefaultModelSelection,
),
[
selectedEnvironmentServerConfig,
selectedProject?.defaultModelSelection,
selectedProjectDraft.modelSelection,
],
[selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],
);

const selectedModel =
selectedProjectDraft.modelSelection ??
selectedProject?.defaultModelSelection ??
draftModelSelection ??
projectDefaultModelSelection ??
modelOptions.find((option) => option.isDefault)?.selection ??
modelOptions[0]?.selection ??
null;
Expand Down Expand Up @@ -675,12 +690,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
}
const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
const text = draft.text.trim();
const draftModelSelection = draft.modelSelection ?? selectedModel;
// Same availability gate the composer display applies: a stored
// selection targeting a disabled provider must not ride into the queue.
const draftModelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? selectedModel;
if (text.length === 0 || !draftModelSelection) {
return null;
}
const workspaceSelection = draft.workspaceSelection;
const mode = workspaceSelection?.mode ?? "local";
// Fall back to the resolved mode (server default) so queued tasks drain
// with the same mode the composer displayed.
const mode = workspaceSelection?.mode ?? workspaceMode;
// When the selection is the stand-in built from the queued snapshot,
// persist the original (possibly absent) snapshot values — the
// stand-in's placeholder title/workspaceRoot must never be written back
Expand Down Expand Up @@ -722,10 +745,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
[
editingPendingProject,
editingPendingTask,
selectedEnvironmentServerConfig,
selectedModel,
selectedProject,
selectedProjectDraftKey,
startFromOrigin,
workspaceMode,
],
);

Expand Down
44 changes: 43 additions & 1 deletion apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";

import { buildModelOptions } from "./modelOptions";
import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions";

describe("mobile model options", () => {
it("normalizes a legacy fallback selection against current capabilities", () => {
Expand Down Expand Up @@ -49,4 +49,46 @@ describe("mobile model options", () => {
expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]);
});

it("rejects stored selections whose provider is not usable", () => {
const config = {
providers: [
{
instanceId: "codex",
driver: "codex",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [],
},
{
instanceId: "claudeAgent",
driver: "claudeAgent",
enabled: false,
installed: true,
auth: { status: "authenticated" },
models: [],
},
],
} as unknown as ServerConfig;

const usable = {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.6-sol",
};
const disabled = {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-sonnet-5",
};
const removed = {
instanceId: ProviderInstanceId.make("codex_personal"),
model: "gpt-5.6-sol",
};

expect(resolveSelectableModelSelection(config, usable)).toBe(usable);
expect(resolveSelectableModelSelection(config, disabled)).toBeNull();
expect(resolveSelectableModelSelection(config, removed)).toBeNull();
// No config (environment offline) — nothing to validate against.
expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);
});
});
25 changes: 25 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ function normalizeSelectionOptions(
};
}

/**
* A stored model selection is only usable when its provider instance is
* currently enabled, installed, and authenticated on the server. Returns the
* selection unchanged when usable, otherwise `null` so callers fall through to
* the server's default model. A missing config (environment offline) cannot be
* validated, so stored selections pass through untouched.
*/
export function resolveSelectableModelSelection(
config: T3ServerConfig | null | undefined,
selection: ModelSelection | null,
): ModelSelection | null {
if (!selection || !config) {
return selection;
}
const provider = config.providers.find(
(candidate) => candidate.instanceId === selection.instanceId,
);
return provider &&
provider.enabled &&
provider.installed &&
provider.auth.status !== "unauthenticated"
? selection
: null;
}

export function buildModelOptions(
config: T3ServerConfig | null | undefined,
fallbackModelSelection: ModelSelection | null,
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,36 @@ describe("mobile composer drafts", () => {
});
});

it("drops the workspace selection when clearing a sent new-task draft", () => {
const draftKey = "new-task:environment-1:project-1";
const draft: ComposerDraft = {
text: "send this",
attachments: [],
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.4",
},
workspaceSelection: {
mode: "worktree",
branch: "main",
worktreePath: null,
startFromOrigin: false,
},
};

expect(
clearComposerDraftContentState({ [draftKey]: draft }, draftKey, {
clearWorkspaceSelection: true,
}),
).toEqual({
[draftKey]: {
modelSelection: draft.modelSelection,
text: "",
attachments: [],
},
});
});

it("reads the latest selector state synchronously for send", () => {
const draftKey = "environment-1:thread-1";
const selectedDraft: ComposerDraft = {
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,14 +372,18 @@ export function updateComposerDraftSettings(
export function clearComposerDraftContentState(
current: Record<string, ComposerDraft>,
draftKey: string,
options?: { readonly clearWorkspaceSelection?: boolean },
): Record<string, ComposerDraft> {
const existing = current[draftKey];
if (!existing) {
return current;
}
const { importedShareIds: _importedShareIds, ...retained } = existing;
const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing;
const draft = {
...retained,
...(options?.clearWorkspaceSelection || workspaceSelection === undefined
? {}
: { workspaceSelection }),
text: "",
attachments: [],
};
Expand Down Expand Up @@ -526,8 +530,11 @@ export async function restoreComposerDraftSnapshot(
await persistenceQueue.run(() => writePersistedComposerDrafts(next));
}

export function clearComposerDraftContent(draftKey: string): void {
updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey));
export function clearComposerDraftContent(
draftKey: string,
options?: { readonly clearWorkspaceSelection?: boolean },
): void {
updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
}

export function clearComposerDraft(draftKey: string): void {
Expand Down
Loading