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
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ function ThreadRouteContent(
const mirrorPicker = sandboxMirrorPicker(
sandboxMirror,
routeEnvironmentRuntime?.serverConfig ?? null,
selectedThread,
);
const selectedThreadWithDraftSettings = selectedThread
? {
Expand Down
55 changes: 36 additions & 19 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
ProviderOptionDescriptor,
ProviderOptionSelection,
RuntimeMode,
ServerConfig,
ServerSettings,
ThreadId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -88,6 +89,7 @@ import {
} from "../../state/use-composer-drafts";
import { useQuietThreadStructure } from "../../state/use-thread-detail";
import { activeComposerDraftKey, useQueuedRunEdit } from "../../state/queued-run-edit";
import { SANDBOX_MIRROR_MODELS_PENDING_REASON } from "@t3tools/client-runtime/state/sandbox-mirror";
import { sandboxMirrorPicker, useSandboxMirror } from "../../state/sandbox-mirror";
import { selectThreadModel } from "../../state/thread-model-selection";
import { useThreadSelection } from "../../state/use-thread-selection";
Expand Down Expand Up @@ -262,6 +264,10 @@ type ThreadSettingsSessionProps = {
readonly providerInstanceId?: ProviderInstanceId;
readonly lockedProviderInstanceId?: ProviderInstanceId;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
/** The catalog "Manage hidden models" lists, when not the environment's own. */
readonly catalogConfig?: ServerConfig | null;
/** Lists no models, saying why. */
readonly modelsUnavailableReason?: string;
readonly selectedModel: ModelSelection | null;
readonly prompt: string;
readonly onPromptChange: (prompt: string) => void;
Expand All @@ -285,6 +291,7 @@ type ThreadSettingsSessionValue = {
readonly environmentId: EnvironmentId | null;
readonly providerInstanceId?: ProviderInstanceId;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
readonly modelsUnavailableReason: string | undefined;
readonly favoriteKeys: ReadonlySet<string>;
readonly favoritesLoaded: boolean;
readonly toggleFavorite: (option: ModelOption) => void;
Expand Down Expand Up @@ -324,8 +331,9 @@ function ThreadSettingsSessionProvider(
const registry = use(RegistryContext);
const preferences = useAtomValue(mobilePreferencesAtom);
const configs = useAtomValue(environmentServerConfigsAtom);
const config = props.environmentId ? configs.get(props.environmentId) : undefined;
const settings = config?.settings;
const environmentConfig = props.environmentId ? configs.get(props.environmentId) : undefined;
const config = props.catalogConfig ?? environmentConfig;
const settings = environmentConfig?.settings;
const connection = useEnvironmentQuery(
props.environmentId ? environmentCatalog.stateAtom(props.environmentId) : null,
).data;
Expand All @@ -337,17 +345,19 @@ function ThreadSettingsSessionProvider(
);
const [pendingHiddenKeys, setPendingHiddenKeys] = useState<ReadonlySet<string>>(() => new Set());
const providerGroups =
manageHidden && config
? groupByProvider(
buildModelOptions(
config,
props.selectedModel,
props.lockedProviderInstanceId,
props.projectId,
true,
),
)
: props.providerGroups;
props.modelsUnavailableReason !== undefined
? []
: manageHidden && config
? groupByProvider(
buildModelOptions(
config,
props.selectedModel,
props.lockedProviderInstanceId,
props.projectId,
true,
),
)
: props.providerGroups;
const legacyFavorites = AsyncResult.isSuccess(preferences)
? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES)
: EMPTY_MODEL_FAVORITES;
Expand Down Expand Up @@ -597,6 +607,7 @@ function ThreadSettingsSessionProvider(
environmentId: props.environmentId,
providerInstanceId: props.providerInstanceId,
providerGroups,
modelsUnavailableReason: props.modelsUnavailableReason,
manageHidden,
setManageHidden,
toggleHidden,
Expand Down Expand Up @@ -969,12 +980,13 @@ function ThreadSettingsMainContent(props: {
content = (
<View className="items-center px-8 py-14">
<Text className="text-center text-sm text-foreground-muted">
{session.providerFilter === FAVORITES_PROVIDER_FILTER &&
session.searchQuery.trim().length === 0
? "No favorite models"
: hasActiveCatalogFilter
? "No matching models"
: "No available models"}
{session.modelsUnavailableReason ??
(session.providerFilter === FAVORITES_PROVIDER_FILTER &&
session.searchQuery.trim().length === 0
? "No favorite models"
: hasActiveCatalogFilter
? "No matching models"
: "No available models")}
</Text>
</View>
);
Expand Down Expand Up @@ -1336,6 +1348,7 @@ export function ExistingThreadSettingsRouteScreen({
const mirrorPicker = sandboxMirrorPicker(
sandboxMirror,
selectedEnvironmentRuntime?.serverConfig ?? null,
selectedThread,
);
const serverConfig = mirrorPicker.config;
const modelSelection =
Expand Down Expand Up @@ -1368,6 +1381,10 @@ export function ExistingThreadSettingsRouteScreen({
providerInstanceId={modelSelection.instanceId}
{...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {})}
providerGroups={groupByProvider(modelOptions)}
catalogConfig={serverConfig}
{...(mirrorPicker.readOnly
? { modelsUnavailableReason: SANDBOX_MIRROR_MODELS_PENDING_REASON }
: {})}
selectedModel={modelSelection}
prompt={prompt}
onPromptChange={(text) => setComposerDraftText(draftKey, text)}
Expand Down
32 changes: 24 additions & 8 deletions apps/mobile/src/state/sandbox-mirror.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAtomValue } from "@effect/atom-react";
import {
isSandboxMirrorThread,
sandboxMirrorModelSourceOf,
sandboxMirrorPickerSource,
} from "@t3tools/client-runtime/state/sandbox-mirror";
import type {
EnvironmentId,
Expand Down Expand Up @@ -31,15 +31,31 @@ export function useSandboxMirror(
}

/**
* What a mirror's model picker reads: the sandbox's provider in place of the bridge's shared list,
* and the sandbox's selection (null for any other thread, which keeps its own config and pick).
* What a mirror's model picker reads: the sandbox's provider and selection, or the mirror's own
* pick alone, read-only, until the host has read them; see `sandboxMirrorPickerSource`. Any other
* thread keeps its own config and pick (a null selection).
*/
export function sandboxMirrorPicker(
mirror: SandboxThreadMirror | null,
config: ServerConfig | null,
): { readonly config: ServerConfig | null; readonly modelSelection: ModelSelection | null } {
const source = sandboxMirrorModelSourceOf(mirror);
return source === null || config === null
? { config, modelSelection: null }
: { config: { ...config, providers: source.providers }, modelSelection: source.modelSelection };
thread: { readonly modelSelection: ModelSelection } | null | undefined,
): {
readonly config: ServerConfig | null;
readonly modelSelection: ModelSelection | null;
readonly readOnly: boolean;
} {
if (config === null || thread == null || !isSandboxMirrorThread(thread)) {
return { config, modelSelection: null, readOnly: false };
}
const source = sandboxMirrorPickerSource({
paired: null,
hostMirror: mirror,
mirrorSelection: thread.modelSelection,
hostProviders: config.providers,
});
return {
config: { ...config, providers: source.providers },
modelSelection: source.modelSelection,
readOnly: source.readOnly,
};
}
10 changes: 8 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { useProjectScriptEditing } from "./chat/useProjectScriptEditing";
import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent";
import { usePendingThreadRequests } from "./chat/usePendingThreadRequests";
import { isScratchProject } from "@t3tools/client-runtime/state/projects";
import { SANDBOX_MIRROR_MODELS_PENDING_REASON } from "@t3tools/client-runtime/state/sandbox-mirror";
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
Expand Down Expand Up @@ -1357,8 +1358,8 @@ export default function ChatView(props: ChatViewProps) {
: (primaryEnvironment?.serverConfig ?? null);
const providerEnvironmentId = (activeThread ? activeEnvironment : primaryEnvironment)
?.environmentId;
// On a sandbox chat's host mirror, the sandbox's own provider and selection, not the bridge's.
const sandboxMirrorModelSource = useSandboxMirrorModelSource(activeThreadRef);
// On a sandbox chat's host mirror, the sandbox's own provider and selection, never the host's.
const sandboxMirrorModelSource = useSandboxMirrorModelSource(activeThreadRef, serverThread);
const providerStatuses =
sandboxMirrorModelSource?.providers ??
(providerEnvironmentId === undefined
Expand Down Expand Up @@ -3993,6 +3994,11 @@ export default function ChatView(props: ChatViewProps) {
lockedProvider={modelPickerLockedProvider}
providerStatuses={providerStatuses as ServerProvider[]}
providerCatalogKnown={serverConfig !== null}
modelPickerReadOnlyReason={
sandboxMirrorModelSource?.readOnly
? SANDBOX_MIRROR_MODELS_PENDING_REASON
: null
}
activeProjectDefaultModelSelection={
activeProjectDefaultModelSelection
}
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ export interface ChatComposerProps {
lockedProvider: ProviderDriverKind | null;
providerStatuses: ServerProvider[];
providerCatalogKnown: boolean;
/** Shows the selection without letting it change, and why. */
modelPickerReadOnlyReason?: string | null;
activeProjectDefaultModelSelection: ModelSelection | null | undefined;
activeThreadModelSelection: ModelSelection | null | undefined;
reportedModelSelection?: ModelSelection | null;
Expand Down Expand Up @@ -536,6 +538,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
lockedProvider,
providerStatuses,
providerCatalogKnown,
modelPickerReadOnlyReason = null,
activeProjectDefaultModelSelection,
activeThreadModelSelection,
reportedModelSelection,
Expand Down Expand Up @@ -1898,7 +1901,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
if (applied) {
setComposerHighlightedItemId(null);
setIsComposerModelPickerOpen(true);
if (modelPickerReadOnlyReason === null) setIsComposerModelPickerOpen(true);
}
return;
}
Expand Down Expand Up @@ -2437,6 +2440,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
providerSetupInstanceId={providerSetupInstanceId}
onOpenProviderSetup={onOpenProviderSetup}
providerCatalogPending={providerCatalogPending}
modelPickerReadOnlyReason={modelPickerReadOnlyReason}
isSendBusy={isSendBusy}
multipleModelsEnabled={routeKind === "draft" && supportsMultipleModels}
multipleModelSelections={multipleModelSelections}
Expand Down Expand Up @@ -2899,6 +2903,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}, []);

const openModelPicker = () => {
// The keybinding opens it here, past the disabled trigger.
if (modelPickerReadOnlyReason !== null) return;
if (composerControlsHidden) {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
Expand Down Expand Up @@ -2952,7 +2958,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
),
openModelPicker: openModelPickerStable,
stepReasoningEffort: (direction) => {
if (noProviderAvailable || pendingUserInputs.length > 0 || isEditingQueuedMessage) {
if (
noProviderAvailable ||
modelPickerReadOnlyReason !== null ||
pendingUserInputs.length > 0 ||
isEditingQueuedMessage
) {
return;
}
const change = getComposerReasoningStep({
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/components/chat/ComposerControlsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PromptUltrathink } from "./TraitsPicker";
import type { ProviderInstanceEntry } from "../../providerInstances";
import { type AppModelOption } from "../../modelSelection";
import { CircleAlertIcon } from "lucide-react";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
import { ComposerControl } from "./ComposerControl";
import {
Expand Down Expand Up @@ -67,6 +68,7 @@ export function ComposerControlsBar(props: {
providerSetupInstanceId: ProviderInstanceId | undefined;
onOpenProviderSetup: ChatComposerProps["onOpenProviderSetup"];
providerCatalogPending: boolean;
modelPickerReadOnlyReason: string | null;
isSendBusy: boolean;
multipleModelsEnabled: boolean;
multipleModelSelections: ChatComposerProps["multipleModelSelections"];
Expand Down Expand Up @@ -111,6 +113,7 @@ export function ComposerControlsBar(props: {
providerSetupInstanceId,
onOpenProviderSetup,
providerCatalogPending,
modelPickerReadOnlyReason,
isSendBusy,
multipleModelsEnabled,
multipleModelSelections,
Expand Down Expand Up @@ -187,7 +190,7 @@ export function ComposerControlsBar(props: {
isComposerOwned
// The trigger grows away from this edge when its model or effort label changes.
popupAlign={composerControlsCollapsed ? "start" : "end"}
disabled={providerCatalogPending || isSendBusy}
disabled={providerCatalogPending || isSendBusy || modelPickerReadOnlyReason !== null}
multipleModelsEnabled={multipleModelsEnabled}
multipleModelSelections={multipleModelSelections}
onMultipleModelSelectionsChange={onMultipleModelSelectionsChange}
Expand Down Expand Up @@ -243,6 +246,18 @@ export function ComposerControlsBar(props: {
onOpenProviderSetup={onOpenProviderSetup}
/>
);
// A disabled trigger shows no tooltip of its own.
const modelPickerControl =
modelPickerReadOnlyReason === null ? (
composerModelPicker
) : (
<Tooltip>
<TooltipTrigger render={<span className="inline-flex min-w-0 shrink" />}>
{composerModelPicker}
</TooltipTrigger>
<TooltipPopup side="top">{modelPickerReadOnlyReason}</TooltipPopup>
</Tooltip>
);
return showProviderUnavailable ? (
<ComposerControl
type="button"
Expand All @@ -260,7 +275,7 @@ export function ComposerControlsBar(props: {
</ComposerControl>
) : (
<>
{composerModelPicker}
{modelPickerControl}

<>
{restingBlockDefs.map((def, index) => {
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/components/chat/useApplyThreadModelPick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { isSandboxMirrorThread } from "@t3tools/client-runtime/state/sandbox-mirror";
import { useContext } from "react";

import { type ComposerThreadTarget, useComposerDraftStore } from "../../composerDraftStore";
import { useQuietThreadShells } from "../../state/entities";
import { serverEnvironment } from "../../state/server";
import { useSandboxProvisionerEnvironmentId, useSandboxRecords } from "../../state/sandboxes";
import { sandboxChatPickPartner, sandboxHostMirrors } from "../../state/sandboxThreads";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
Expand All @@ -23,6 +25,10 @@ export function useApplyThreadModelPick() {
const setModelSelection = useOrchestrationCommand(threadEnvironment.setModelSelection, {
label: "change model",
});
// A mirror whose sandbox thread this app does not list sets it through the host, as the phone does.
const setSandboxMirrorModel = useOrchestrationCommand(serverEnvironment.sandboxesSetThreadModel, {
label: "change model",
});
// A stopped sandbox's thread takes the pick from the bridge with the next message instead.
const setPartnerModelSelection = useOrchestrationCommand(threadEnvironment.setModelSelection, {
reportFailure: false,
Expand All @@ -47,11 +53,11 @@ export function useApplyThreadModelPick() {
target,
pick,
);
const input = { threadId: target.threadId, modelSelection: pick };
const [result, partnerResult] = await Promise.all([
setModelSelection({
environmentId: target.environmentId,
input: { threadId: target.threadId, modelSelection: pick },
}),
partner === null && isSandboxMirrorThread(thread)
? setSandboxMirrorModel({ environmentId: target.environmentId, input })
: setModelSelection({ environmentId: target.environmentId, input }),
partner === null
? null
: setPartnerModelSelection({
Expand Down
Loading