Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ export function NewTaskDraftScreen(props: {
flow.finishEditingPendingTask();
} else {
// Drop draft-local model/workspace selections with the content. The
// next task re-resolves project defaults before sticky app defaults.
// next task re-resolves sticky app defaults before project defaults.
clearComposerDraftContent(draftKey, {
clearModelSelection: true,
clearWorkspaceSelection: true,
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,18 +438,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
() =>
buildModelOptions(
selectedEnvironmentServerConfig,
draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
draftModelSelection ?? stickyModelSelection ?? projectDefaultModelSelection,
),
[
selectedEnvironmentServerConfig,
draftModelSelection,
projectDefaultModelSelection,
stickyModelSelection,
projectDefaultModelSelection,
],
);

// An unsent draft keeps its explicit pick. Fresh drafts resolve the project
// default before the last manual app-wide selection and provider default.
// last manual app-wide selection before the project and provider defaults.
const selectedModel = resolveNewTaskModelSelection({
draftSelection: draftModelSelection,
projectDefaultSelection: projectDefaultModelSelection,
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ describe("mobile model options", () => {
expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);
});

it("resolves new tasks from draft, project, sticky, then provider defaults", () => {
it("resolves new tasks from draft, sticky, project, then provider defaults", () => {
const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" };
const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" };
const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" };
Expand All @@ -244,7 +244,8 @@ describe("mobile model options", () => {
});

expect(resolve(draft, project, sticky)).toBe(draft);
expect(resolve(null, project, sticky)).toBe(project);
expect(resolve(null, project, sticky)).toBe(sticky);
expect(resolve(null, project, null)).toBe(project);
expect(resolve(null, null, sticky)).toBe(sticky);
expect(resolve(null, null, null)).toBe(providerDefault.selection);
});
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ export function resolveNewTaskModelSelection(input: {
}): ModelSelection | null {
return (
input.draftSelection ??
input.projectDefaultSelection ??
input.stickySelection ??
input.projectDefaultSelection ??
input.modelOptions.find((option) => option.isDefault)?.selection ??
input.modelOptions[0]?.selection ??
null
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export function decodePersistedComposerState(value: unknown): {
// Stale new-task drafts left on disk by builds before the
// model-precedence fix carry a bare modelSelection with no
// other selector settings. Strip it so the next compose pass
// re-resolves project → sticky → provider defaults. Drafts
// re-resolves sticky → project → provider defaults. Drafts
// with runtime/interaction/workspace settings or actual text /
// attachments were deliberately configured and are left alone.
key.startsWith("new-task:") &&
Expand Down
26 changes: 16 additions & 10 deletions apps/web/src/hooks/useHandleNewThread.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ export function useNewThreadHandler() {
} = useComposerDraftStore.getState();
const currentRouteTarget = getCurrentRouteTarget();
// A new thread carries the user's working mode from the thread being
// viewed. The target project's configured model still wins; runtime and
// interaction modes carry independently. Branch, worktree, and env mode
// come from configured defaults unless the caller passes them explicitly.
// viewed. Runtime and interaction modes carry independently. Branch,
// worktree, and env mode come from configured defaults unless the caller
// passes them explicitly.
const carrySourceShell =
currentRouteTarget?.kind === "server"
? readThreadShell(currentRouteTarget.threadRef)
Expand Down Expand Up @@ -134,14 +134,20 @@ export function useNewThreadHandler() {
candidate.id === projectRef.projectId &&
candidate.environmentId === projectRef.environmentId,
);
const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
resolveNewThreadModelSelectionOverride({
const resolveModelSelectionOverride = (destinationDraftId: DraftId) => {
const { stickyActiveProvider, stickyModelSelectionByProvider } =
useComposerDraftStore.getState();
return resolveNewThreadModelSelectionOverride({
projectDefaultSelection: project?.defaultModelSelection ?? null,
stickySelection: stickyActiveProvider
? (stickyModelSelectionByProvider[stickyActiveProvider] ?? null)
: null,
carrySelection: carryModelSelection,
carrySourceDraftId:
currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
destinationDraftId,
});
};
// The shared resolver owns the priority order. The t3.json read is
// skipped entirely when a higher-priority source decides, and its
// query atom caches per project after the first call.
Expand Down Expand Up @@ -261,9 +267,9 @@ export function useNewThreadHandler() {
});
}
// Model intent: an explicit human pick always stands. Seeds and
// legacy entries alike re-resolve here — sticky first, mirroring
// the mint-fresh path, then the project default or carried
// selection on top. This runs even when the draft is already open:
// legacy entries alike re-resolve here from the current thread,
// then the app-wide sticky choice, then the project default. This
// runs even when the draft is already open:
// without it, a changed pin could never reach the draft the user
// is looking at, because explicit picks are the only thing the
// flag protects.
Expand Down Expand Up @@ -409,8 +415,8 @@ export function useNewThreadHandler() {
applyStickyState(draftId);
const modelSelectionOverride = resolveModelSelectionOverride(draftId);
if (modelSelectionOverride) {
// Project defaults and carried selections both outrank global sticky
// state. The project default wins when both are present.
// The current thread wins over global sticky state. Project defaults
// only fill in when neither user-wide source has a selection.
setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true });
}
await router.navigate({
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/lib/chatThreadActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("chatThreadActions", () => {
expect(
resolveNewThreadModelSelectionOverride({
projectDefaultSelection: null,
stickySelection: null,
carrySelection: CARRIED_SELECTION,
carrySourceDraftId: "draft-a",
destinationDraftId: "draft-a",
Expand All @@ -69,22 +70,36 @@ describe("chatThreadActions", () => {
expect(
resolveNewThreadModelSelectionOverride({
projectDefaultSelection: null,
stickySelection: null,
carrySelection: CARRIED_SELECTION,
carrySourceDraftId: "draft-a",
destinationDraftId: "draft-b",
}),
).toEqual(CARRIED_SELECTION);
});

it("keeps the project default above any carried selection", () => {
it("keeps a carried selection above the sticky and project defaults", () => {
expect(
resolveNewThreadModelSelectionOverride({
projectDefaultSelection: PROJECT_DEFAULT_SELECTION,
stickySelection: PROJECT_DEFAULT_SELECTION,
carrySelection: CARRIED_SELECTION,
carrySourceDraftId: "draft-a",
destinationDraftId: "draft-b",
}),
).toEqual(PROJECT_DEFAULT_SELECTION);
).toEqual(CARRIED_SELECTION);
});

it("keeps the sticky selection above the project default", () => {
expect(
resolveNewThreadModelSelectionOverride({
projectDefaultSelection: PROJECT_DEFAULT_SELECTION,
stickySelection: CARRIED_SELECTION,
carrySelection: null,
carrySourceDraftId: null,
destinationDraftId: "draft-b",
}),
).toEqual(CARRIED_SELECTION);
});

it("only applies the start-from-origin default to new worktree drafts", () => {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/chatThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ export function resolveNewDraftStartFromOrigin(input: {

export function resolveNewThreadModelSelectionOverride(input: {
readonly projectDefaultSelection: ModelSelection | null;
readonly stickySelection: ModelSelection | null;
readonly carrySelection: ModelSelection | null;
readonly carrySourceDraftId: string | null;
readonly destinationDraftId: string;
}): ModelSelection | null {
return (
input.projectDefaultSelection ??
(input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection)
(input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) ??
input.stickySelection ??
input.projectDefaultSelection
);
}

Expand Down
Loading