Skip to content
Merged
5 changes: 1 addition & 4 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
type DraftThreadState,
} from "../composerDraftStore";
import * as Schema from "effect/Schema";
import { threadHasStarted } from "../lib/chatThreadActions";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadDetails } from "../state/threads";
import {
Expand Down Expand Up @@ -825,10 +826,6 @@ export function recallCheckoutIsRepo(
: sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd));
}

function threadHasStarted(thread: Thread | null | undefined): boolean {
return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime));
}

// Imported history lacks a session until its first prompt; resolve its instance before locking the driver.
export function deriveLockedProvider(input: {
thread: Thread | null | undefined;
Expand Down
17 changes: 12 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ import { useNewThreadHandler } from "../hooks/useHandleNewThread";
import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
import { isPreviewFocused } from "../lib/previewFocus";
import { getTerminalFocusOwner } from "../lib/terminalFocus";
import { draftModelSelectionApplies, threadHasStarted } from "../lib/chatThreadActions";
import {
deriveLogicalProjectKeyFromSettings,
selectProjectGroupingSettings,
Expand Down Expand Up @@ -608,15 +609,15 @@ export default function ChatView(props: ChatViewProps) {
const composerActiveProvider = useComposerDraftStore(
(store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
);
const composerModelSelectionExplicit = useComposerDraftStore(
(store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true,
);
const composerSelectedModelSelection = useComposerDraftStore((store) => {
const draft = store.getComposerDraft(composerDraftTarget);
return draft?.activeProvider
? (draft.modelSelectionByProvider[draft.activeProvider] ?? null)
: null;
});
const composerModelSelectionExplicit = useComposerDraftStore(
(store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true,
);
const composerHasAttachments = useComposerDraftStore((store) => {
const draft = store.getComposerDraft(composerDraftTarget);
return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
Expand Down Expand Up @@ -1264,7 +1265,11 @@ export default function ChatView(props: ChatViewProps) {
: providersByEnvironment.get(providerEnvironmentId)) ?? EMPTY_PROVIDERS,
gitCwd,
);
const selectedProviderByThreadId = composerActiveProvider ?? null;
const composerModelSelectionApplies = draftModelSelectionApplies(
{ modelSelectionExplicit: composerModelSelectionExplicit },
threadHasStarted(activeThread),
);
const selectedProviderByThreadId = composerModelSelectionApplies ? composerActiveProvider : null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
activeProjectDefaultModelSelection?.instanceId ??
Expand Down Expand Up @@ -2480,7 +2485,9 @@ export default function ChatView(props: ChatViewProps) {
item.type === "user_message" &&
(item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
);
const pendingHandoffSelection = composerSelectedModelSelection ?? activeThread?.modelSelection;
const pendingHandoffSelection =
(composerModelSelectionApplies ? composerSelectedModelSelection : null) ??
activeThread?.modelSelection;
const pendingHandoffSelectionUsable = providerInstanceEntries.some(
(entry) =>
entry.instanceId === pendingHandoffSelection?.instanceId &&
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,11 @@ import {
waitForProject,
} from "../state/entities";
import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads";
import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
import {
resolveThreadActionProjectRef,
startNewThreadFromContext,
threadHasStarted,
} from "../lib/chatThreadActions";
import {
appendBrowsePathSegment,
ensureBrowseDirectoryPath,
Expand Down Expand Up @@ -1397,6 +1401,7 @@ function OpenCommandPaletteDialog(props: {
? providersByEnvironment.get(promptLinkProjectRef.environmentId)
: undefined) ?? [],
threadModelSelection: activeThread?.modelSelection ?? null,
threadStarted: threadHasStarted(activeThread),
};
const plainPromptLink = promptLinkInput
? composerPromptLink({ ...promptLinkInput, withContext: false })
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
} from "../../composer-logic";
import { DEFAULT_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
import { deriveComposerSendState, getAntigravitySendBlockReason } from "../ChatView.logic";
import { threadHasStarted } from "../../lib/chatThreadActions";
import {
dataTransferHasComposerMention,
makeComposerMentionDragHandlers,
Expand Down Expand Up @@ -888,8 +889,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
threadModelSelection: activeThreadModelSelection,
projectModelSelection: activeProjectDefaultModelSelection,
promptInjectionState: getComposerPromptInjectionState(prompt),
threadStarted: threadHasStarted(activeThread),
candidateInstanceIds: [
composerDraft.activeProvider ?? null,
activeThread?.runtime?.providerInstanceId,
activeThreadModelSelection?.instanceId,
activeProjectDefaultModelSelection?.instanceId,
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/chat/useComposerProviderModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
deriveEffectiveComposerModelState,
useComposerDraftModelState,
} from "../../composerDraftStore";
import { draftModelSelectionApplies } from "../../lib/chatThreadActions";
import {
type ModelSelection,
type ProjectId,
Expand Down Expand Up @@ -35,6 +36,7 @@ export function useComposerProviderModel(
readonly threadModelSelection: ModelSelection | null | undefined;
readonly projectModelSelection: ModelSelection | null | undefined;
readonly promptInjectionState: ReturnType<typeof getComposerPromptInjectionState>;
readonly threadStarted: boolean;
},
) {
const {
Expand All @@ -48,6 +50,7 @@ export function useComposerProviderModel(
candidateInstanceIds,
lockedProvider,
lockedInstanceId,
threadStarted,
} = input;
const draftModelState = useComposerDraftModelState(threadRef);
const providerInstanceEntries: ReadonlyArray<ProviderInstanceEntry> = providerInstanceEntriesFor(
Expand All @@ -57,7 +60,12 @@ export function useComposerProviderModel(
);
const selection = resolveComposerProviderSelection({
entries: providerInstanceEntries,
candidateInstanceIds,
candidateInstanceIds: [
draftModelSelectionApplies(draftModelState, threadStarted)
? draftModelState.activeProvider
: null,
...candidateInstanceIds,
],
lockedProvider,
lockedInstanceId,
});
Expand All @@ -67,6 +75,7 @@ export function useComposerProviderModel(
selection.selectedProviderEntry?.driverKind ?? selection.requestedDriverKind;
const modelState = deriveEffectiveComposerModelState({
draft: draftModelState,
threadStarted,
providers: providerStatuses,
selectedProvider,
selectedInstanceId,
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/chat/useComposerSelection.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ it("switches back to the thread's stored model while the session runs on another
expect(
deriveEffectiveComposerModelState({
draft,
threadStarted: true,
providers,
selectedProvider: ProviderDriverKind.make("cursor"),
selectedInstanceId: cursor,
Expand Down Expand Up @@ -175,3 +176,33 @@ it("saves a re-picked model again when its first save failed", async () => {
});
await vi.waitFor(() => expect(setModelSelection.mock.calls).toEqual([[autoSave], [autoSave]]));
});

it("saves a pick of a seeded model the started thread does not show", async () => {
const { setModelSelection } = setup();
setModelSelection.mockResolvedValue(AsyncResult.success(undefined));
const thread = cursorThread("thread-cursor-seeded");
appAtomRegistry.set(shellAtom(thread.id), thread);
useComposerDraftStore
.getState()
.setModelSelection(
scopeThreadRef(environmentId, thread.id),
createModelSelection(cursor, "auto-smart"),
);
await using _screen = await render(
<AppAtomRegistryProvider>
<Picker thread={thread} runtime={null} model="auto-smart" />
</AppAtomRegistryProvider>,
);
await page.getByRole("button", { name: "Pick the model" }).click();

await vi.waitFor(() =>
expect(setModelSelection).toHaveBeenCalledWith(
expect.objectContaining({
input: {
threadId: thread.id,
modelSelection: expect.objectContaining({ instanceId: cursor, model: "auto-smart" }),
},
}),
),
);
});
11 changes: 9 additions & 2 deletions apps/web/src/components/chat/useComposerSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { type SetStateAction } from "react";
import { useNavigate } from "@tanstack/react-router";
import { toastManager } from "../ui/toast";
import { resolveAppModelSelectionForInstance } from "../../modelSelection";
import { resolveNewDraftStartFromOrigin } from "../../lib/chatThreadActions";
import {
draftModelSelectionApplies,
resolveNewDraftStartFromOrigin,
threadHasStarted,
} from "../../lib/chatThreadActions";
import { type DraftThreadEnvMode, useComposerDraftStore } from "../../composerDraftStore";
import {
getStartedThreadModelChangeBlockReason,
Expand Down Expand Up @@ -161,7 +165,10 @@ export function useComposerSelection(input: ComposerSelectionInput) {
{ preserveUnavailableSelection: true },
);
const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
const draft = useComposerDraftStore.getState().getComposerDraft(threadRef);
const storedDraft = useComposerDraftStore.getState().getComposerDraft(threadRef);
const draft = draftModelSelectionApplies(storedDraft, threadHasStarted(activeThread))
? storedDraft
: null;
const shownInstanceId =
draft?.activeProvider ??
activeRuntime?.providerInstanceId ??
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/promptLinks/copyPromptLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { type ComposerThreadTarget, useComposerDraftStore } from "~/composerDraftStore";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { configuredHostedAppUrl } from "~/hostedPairing";
import { draftModelSelectionApplies } from "~/lib/chatThreadActions";

import { stackedThreadToast, toastManager } from "../ui/toast";

Expand Down Expand Up @@ -48,14 +49,16 @@ export function composerPromptLink(input: {
readonly project: EnvironmentProject | undefined;
readonly providers: ReadonlyArray<ServerProvider>;
readonly threadModelSelection: ModelSelection | null;
readonly threadStarted: boolean;
readonly withContext: boolean;
}): string | null {
const draft = useComposerDraftStore.getState().getComposerDraft(input.target);
if (!draft) return null;
if (!input.withContext) return promptLinkFor(draft.prompt);
const selection =
(draft.activeProvider ? draft.modelSelectionByProvider[draft.activeProvider] : undefined) ??
input.threadModelSelection;
(draft.activeProvider && draftModelSelectionApplies(draft, input.threadStarted)
? draft.modelSelectionByProvider[draft.activeProvider]
: undefined) ?? input.threadModelSelection;
const provider = selection
? (input.providers.find((candidate) => candidate.instanceId === selection.instanceId)?.driver ??
null)
Expand Down
Loading
Loading