Skip to content

Commit fd9fd2e

Browse files
Merge pull request #11 from ScriptedAlchemy/feat/composer-model-mentions
feat: @-mention a provider or model to switch to it
2 parents ffde3a2 + 636dc9e commit fd9fd2e

21 files changed

Lines changed: 775 additions & 29 deletions

‎apps/mobile/src/components/ComposerEditor.tsx‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useAtomValue } from "@effect/atom-react";
44
import { AsyncResult } from "effect/reactivity";
55
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
66
import { useEffect, useState } from "react";
7-
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
7+
import type { EnvironmentId } from "@t3tools/contracts";
88
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
99
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
1010
import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEditor";
@@ -28,7 +28,6 @@ import { appAtomRegistry } from "../state/atom-registry";
2828
import { mobilePreferencesAtom } from "../state/preferences";
2929
import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread";
3030
import { ComposerContextSheet } from "./ComposerContextSheet";
31-
import { ProjectKnowledgeEntry } from "./ProjectKnowledgeEntry";
3231
import { AppText as Text } from "./ui/AppText";
3332
import {
3433
composerDocumentAttachment,
@@ -40,7 +39,6 @@ export type ComposerEditorProps = NativeComposerEditorProps & {
4039
readonly draftKey?: string | null;
4140
readonly hidden?: boolean;
4241
readonly environmentId?: EnvironmentId;
43-
readonly projectId?: ProjectId;
4442
readonly onOpenMention?: (path: string) => void;
4543
readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void;
4644
readonly chipsInert?: boolean;
@@ -51,7 +49,6 @@ export function ComposerEditor({
5149
draftKey,
5250
hidden,
5351
environmentId,
54-
projectId,
5552
onOpenMention,
5653
onOpenAttachment,
5754
chipsInert,
@@ -213,14 +210,6 @@ export function ComposerEditor({
213210
{importing ? (
214211
<Text className="py-2 text-xs text-foreground-muted">Copying context…</Text>
215212
) : null}
216-
{environmentId && projectId && draftKey && !hidden ? (
217-
<ProjectKnowledgeEntry
218-
environmentId={environmentId}
219-
projectId={projectId}
220-
draftKey={draftKey}
221-
disabled={props.readOnly || props.editable === false}
222-
/>
223-
) : null}
224213
{selected && (selectedReference || selectedSkill) ? (
225214
<ComposerContextSheet
226215
label={

‎apps/mobile/src/features/threads/ComposerCommandPopover.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { AppText as Text } from "../../components/ui/AppText";
1919
import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachineSymbol";
2020
import { GlassSurface } from "../../components/ui/GlassSurface";
2121
import { PierreEntryIcon } from "../../components/PierreEntryIcon";
22+
import { ProviderIcon } from "../../components/ProviderIcon";
23+
import type { ModelOption } from "../../lib/modelOptions";
2224
import { pullRequestStatus } from "../pull-requests/pullRequestStatus";
2325
export type ComposerCommandItem =
2426
| {
@@ -71,6 +73,13 @@ export type ComposerCommandItem =
7173
readonly skill: ServerProviderSkill;
7274
readonly label: string;
7375
readonly description: string;
76+
}
77+
| {
78+
readonly id: string;
79+
readonly type: "model";
80+
readonly option: ModelOption;
81+
readonly label: string;
82+
readonly description: string;
7483
};
7584

7685
interface ComposerCommandPopoverProps {
@@ -118,6 +127,7 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
118127
case "skill":
119128
return SKILL_SOURCE_SYMBOL_BY_KIND[resolveProviderSkillSourceKind(item.skill)];
120129
case "path":
130+
case "model":
121131
return null;
122132
case "thread":
123133
return "text.bubble";
@@ -176,6 +186,12 @@ const CommandRow = memo(function CommandRow(props: {
176186
>
177187
{props.item.type === "path" ? (
178188
<PierreEntryIcon path={props.item.path} kind={props.item.kind} size={16} />
189+
) : props.item.type === "model" ? (
190+
<ProviderIcon
191+
iconUrl={props.item.option.providerIconUrl}
192+
provider={props.item.option.providerDriver}
193+
size={16}
194+
/>
179195
) : iconName ? (
180196
<SymbolView
181197
name={iconName}

‎apps/mobile/src/features/threads/NewTaskDraftScreen.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,9 @@ export function NewTaskDraftScreen(props: {
12461246
onSendMessage={handleStart}
12471247
onReconnectEnvironment={() => onReconnectEnvironment(selectedProject.environmentId)}
12481248
canSwitchThreadProvider
1249+
onUpdateThreadModelSelection={(selection) =>
1250+
flow.setSelectedModelKey(`${selection.instanceId}:${selection.model}`, selection.options)
1251+
}
12491252
onUpdateThreadInteractionMode={flow.planModeEnabled ? flow.setInteractionMode : undefined}
12501253
onRespondToApproval={async () => {}}
12511254
onSelectUserInputOption={() => {}}

‎apps/mobile/src/features/threads/ThreadComposer.tsx‎

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ProjectKnowledgeEntry } from "../../components/ProjectKnowledgeEntry";
12
import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar";
23
import { Button } from "../../components/ui/Button";
34
import * as Dialog from "../../components/ui/Dialog";
@@ -52,6 +53,8 @@ import Animated, {
5253
import { useUniwindTheme } from "../../lib/useUniwindTheme";
5354
import { themeColorWithAlpha } from "../../lib/mobileTheme";
5455
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
56+
import { updateThreadDraftModelSelection } from "../../state/use-thread-composer-state";
57+
import { withRememberedModelOptions } from "../../state/use-model-option-memory";
5558
import { scopedThreadKey } from "../../lib/scopedEntities";
5659
import {
5760
getComposerDraftSnapshot,
@@ -235,6 +238,7 @@ export interface ThreadComposerProps {
235238
) => Promise<MessageId | null>;
236239
readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void;
237240
readonly canSwitchProvider: boolean;
241+
readonly onUpdateModelSelection?: (modelSelection: ModelSelection) => void;
238242
readonly onUpdateInteractionMode?: (interactionMode: ProviderInteractionMode) => void;
239243
readonly onHeightChange?: (height: number) => void;
240244
readonly onEditorFocusChange?: (focused: boolean) => void;
@@ -517,6 +521,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
517521
props.selectedThread.modelSelection.instanceId,
518522
props.serverConfig?.providers ?? [],
519523
props.serverConfig?.usageLimitSources ?? [],
524+
// oxlint-disable-next-line react/purity -- The submit and menu handlers read usage limits only after a user action.
520525
Date.now(),
521526
);
522527
onShowUsageLimits(report);
@@ -526,6 +531,16 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
526531
return report !== null;
527532
};
528533

534+
const lockedProviderInstanceId = props.canSwitchProvider
535+
? undefined
536+
: currentModelSelection.instanceId;
537+
const modelOptions = buildModelOptions(
538+
props.serverConfig,
539+
currentModelSelection,
540+
lockedProviderInstanceId,
541+
props.selectedThread.projectId,
542+
"composer",
543+
);
529544
const composerMenu = useComposerCommandMenu({
530545
draftMessage: draftMessage,
531546
ownerKey: composerDraftKey,
@@ -551,6 +566,21 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
551566
offersUsageLimits: usageLimitsOffered,
552567
onUsageLimits:
553568
usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined,
569+
modelOptions,
570+
modelProviders: props.serverConfig?.providers,
571+
onSelectModel:
572+
props.interactionLocked === true
573+
? undefined
574+
: (option) => {
575+
const selection = withRememberedModelOptions(option.selection);
576+
if (props.onUpdateModelSelection) props.onUpdateModelSelection(selection);
577+
else
578+
updateThreadDraftModelSelection(
579+
composerOwnerKey,
580+
selection,
581+
props.serverConfig?.providers ?? [],
582+
);
583+
},
554584
});
555585
const voiceInput = useVoiceInputController({
556586
ownerKey: composerDraftKey,
@@ -689,16 +719,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
689719
});
690720
};
691721

692-
const lockedProviderInstanceId = props.canSwitchProvider
693-
? undefined
694-
: currentModelSelection.instanceId;
695-
const modelOptions = buildModelOptions(
696-
props.serverConfig,
697-
currentModelSelection,
698-
lockedProviderInstanceId,
699-
props.selectedThread.projectId,
700-
"composer",
701-
);
702722
const currentModelOption =
703723
modelOptions.find(
704724
(option) =>
@@ -858,6 +878,15 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
858878
/>
859879
) : null}
860880

881+
{!props.hidden && composerDraftKey ? (
882+
<ProjectKnowledgeEntry
883+
environmentId={props.environmentId}
884+
projectId={props.selectedThread.projectId}
885+
draftKey={composerDraftKey}
886+
disabled={isSubmissionLocked}
887+
/>
888+
) : null}
889+
861890
<ComposerSurface
862891
animateLayout={false}
863892
style={{
@@ -978,7 +1007,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
9781007
hidden={props.hidden}
9791008
draftKey={composerDraftKey}
9801009
environmentId={props.environmentId}
981-
projectId={props.selectedThread.projectId}
9821010
onOpenMention={(path) => {
9831011
Keyboard.dismiss();
9841012
if (props.isDraft) {

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/r
4040
import type {
4141
EnvironmentId,
4242
MessageId,
43+
ModelSelection,
4344
ProviderInteractionMode,
4445
RuntimeRequestId,
4546
ScopedThreadRef,
@@ -283,6 +284,7 @@ export interface ThreadDetailScreenProps {
283284
) => Promise<MessageId | null>;
284285
readonly onReconnectEnvironment: () => void;
285286
readonly canSwitchThreadProvider: boolean;
287+
readonly onUpdateThreadModelSelection?: (modelSelection: ModelSelection) => void;
286288
readonly onUpdateThreadInteractionMode?: (interactionMode: ProviderInteractionMode) => void;
287289
readonly onRespondToApproval: ApprovalResponder;
288290
readonly onSelectUserInputOption: (
@@ -1627,6 +1629,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
16271629
onSendMessage={handleSendMessage}
16281630
onShowUsageLimits={showUsageLimits}
16291631
canSwitchProvider={props.canSwitchThreadProvider}
1632+
onUpdateModelSelection={props.onUpdateThreadModelSelection}
16301633
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
16311634
onHeightChange={handleComposerHeightChange}
16321635
onEditorFocusChange={handleOwnedInputFocusChange}

‎apps/mobile/src/features/threads/use-composer-command-menu.test.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from "vite-plus/test";
2-
import { ProviderDriverKind } from "@t3tools/contracts";
2+
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
33

44
vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
55

@@ -20,7 +20,9 @@ vi.mock("../../state/server", () => ({
2020
},
2121
}));
2222

23+
import type { ModelOption } from "../../lib/modelOptions";
2324
import {
25+
buildComposerModelItems,
2426
buildComposerSlashCommandItems,
2527
resolveComposerCommandSelection,
2628
} from "./use-composer-command-menu";
@@ -90,3 +92,71 @@ describe("mobile slash commands", () => {
9092
).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
9193
});
9294
});
95+
96+
describe("mobile model mentions", () => {
97+
const provider = (
98+
instanceId: string,
99+
overrides: Partial<ServerProvider> = {},
100+
): ServerProvider => ({
101+
instanceId: ProviderInstanceId.make(instanceId),
102+
driver: ProviderDriverKind.make("codex"),
103+
enabled: true,
104+
installed: true,
105+
version: null,
106+
status: "ready",
107+
auth: { status: "authenticated" },
108+
checkedAt: "2026-01-01T00:00:00.000Z",
109+
models: [],
110+
slashCommands: [],
111+
skills: [],
112+
...overrides,
113+
});
114+
const option = (key: string, overrides: Partial<ModelOption> = {}): ModelOption => {
115+
const [instanceId = "", model = ""] = key.split(":");
116+
return {
117+
key,
118+
label: "GPT-6.1",
119+
subtitle: "",
120+
providerKey: instanceId,
121+
providerLabel: "Traex",
122+
providerDriver: "codex",
123+
isDefault: false,
124+
isLegacy: false,
125+
capabilities: null,
126+
selection: { instanceId: ProviderInstanceId.make(instanceId), model },
127+
...overrides,
128+
};
129+
};
130+
131+
it("offers current, visible models labelled with their provider", () => {
132+
const items = buildComposerModelItems(
133+
[
134+
option("traex:gpt-6.1"),
135+
option("opencode:traex/gpt-6.1", {
136+
subtitle: "Traex",
137+
providerLabel: "OpenCode",
138+
providerDriver: "opencode",
139+
}),
140+
option("hidden:gpt-6.1", { isHidden: true }),
141+
option("gone:gpt-6.1", { isUnavailable: true }),
142+
option("old:gpt-5", { label: "GPT-5", isLegacy: true }),
143+
],
144+
"gpt",
145+
["traex", "opencode", "hidden", "gone", "old"].map((id) => provider(id)),
146+
);
147+
expect(items.map((item) => [item.id, item.description]).toSorted()).toEqual([
148+
["model:opencode:traex/gpt-6.1", "OpenCode · Traex"],
149+
["model:traex:gpt-6.1", "Traex"],
150+
]);
151+
});
152+
153+
it.each([
154+
["missing", []],
155+
["not installed", [provider("traex", { installed: false })]],
156+
["signed out", [provider("traex", { auth: { status: "unauthenticated" } })]],
157+
["not ready", [provider("traex", { status: "error" })]],
158+
["unavailable", [provider("traex", { availability: "unavailable" })]],
159+
] as const)("does not suggest a preserved selection whose provider is %s", (_, providers) => {
160+
expect(buildComposerModelItems([option("traex:gpt-6.1")], "gpt", providers)).toEqual([]);
161+
});
162+
});

0 commit comments

Comments
 (0)