Skip to content
Open
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
51 changes: 44 additions & 7 deletions apps/mobile/src/components/ComposerModelControl.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { ModelSelection, ProviderDriverKind } from "@t3tools/contracts";
import { useState } from "react";
import { View } from "react-native";
import {
buildTraitsTriggerDisplay,
getPromptControlledTraits,
Expand All @@ -19,26 +21,39 @@ export function ComposerModelControl(props: {
readonly disabled?: boolean;
readonly size?: "regular" | "large";
readonly iconOnly?: boolean;
/** Effort abbreviation instead of the full traits, for a row shared with the editor. */
readonly compact?: boolean;
/** Width the control may fill; falls back to the effort abbreviation plus speed when the full traits overflow it. */
readonly fitWidth?: number;
readonly onPress: () => void;
}) {
const descriptors = resolveProviderOptionDescriptors({
capabilities: props.modelOption?.capabilities,
selections: props.selection?.options,
});
const promptTraits = getPromptControlledTraits(descriptors, props.prompt);
const summary = props.provider
const traits = props.provider
? buildTraitsTriggerDisplay({
provider: props.provider,
descriptors,
primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null,
ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled,
modelSelection: props.selection,
reportedModelSelection: props.reportedSelection,
}).label
: "";
})
: null;
const summary = traits?.label ?? "";
const [fullWidth, setFullWidth] = useState(0);
const overflows = props.fitWidth !== undefined && fullWidth > 0 && fullWidth > props.fitWidth + 1;
const compact = props.compact === true || overflows;
const badge = props.modelOption?.providerBadge;
const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model";
return (
const visibleSummary = props.compact
? (traits?.compactEffortLabel ?? "")
: overflows
? [traits?.compactEffortLabel, traits?.speedLabel].filter(Boolean).join(" ")
: summary;
const renderControl = (full: boolean) => (
<ComposerInlineControl
accessibilityLabel={[
"Model and reasoning settings",
Expand Down Expand Up @@ -72,8 +87,10 @@ export function ComposerModelControl(props: {
}
label={props.modelOption?.shortName ?? label}
iconOnly={props.iconOnly}
// Only short abbreviations keep their width; an unrecognized effort name truncates.
suffixFixed={!full && compact && (traits?.compactEffortLabel?.length ?? 0) <= 4}
suffix={
summary ? (
(full ? summary : visibleSummary) ? (
<Text
className={
props.size === "large"
Expand All @@ -82,12 +99,32 @@ export function ComposerModelControl(props: {
}
numberOfLines={1}
>
{summary}
{full ? summary : visibleSummary}
</Text>
) : undefined
}
maxWidth="100%"
maxWidth={full ? 10_000 : "100%"}
onPress={props.onPress}
/>
);
if (props.fitWidth === undefined) return renderControl(false);
return (
<>
{/* Unconstrained copy measures whether the full labels fit. */}
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{ position: "absolute", left: 0, top: 0, width: 10_000, opacity: 0 }}
>
<View
className="self-start"
onLayout={(event) => setFullWidth(event.nativeEvent.layout.width)}
>
{renderControl(true)}
</View>
</View>
{renderControl(false)}
</>
);
}
4 changes: 3 additions & 1 deletion apps/mobile/src/components/ComposerToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export function ComposerInlineControl(props: {
readonly renderIcon?: (size: number) => ReactNode;
readonly label: string;
readonly suffix?: ReactNode;
/** A short suffix keeps its width while the label truncates. */
readonly suffixFixed?: boolean;
/** Show only the icon and chevron; the label still names the control for accessibility. */
readonly iconOnly?: boolean;
readonly maxWidth?: ViewStyle["maxWidth"];
Expand Down Expand Up @@ -99,7 +101,7 @@ export function ComposerInlineControl(props: {
</Animated.View>
)}
{!props.iconOnly && props.suffix ? (
<View className="min-w-0 shrink">{props.suffix}</View>
<View className={props.suffixFixed ? "shrink-0" : "min-w-0 shrink"}>{props.suffix}</View>
) : null}
{props.showChevron === false ? null : (
<SymbolView
Expand Down
48 changes: 37 additions & 11 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ function collapsedEditorInset(text: ScaledTextRole): number {
return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift));
}

export function useCompactComposerEditor(text: ScaledTextRole, value: string) {
export function useCompactComposerEditor(
text: ScaledTextRole,
value: string,
forceExpanded = false,
) {
const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
const [compactWidth, setCompactWidth] = useState(0);
const contentHeight = contentSize.height;
Expand All @@ -145,9 +149,10 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) {
// A line that fits the expanded editor may still overflow its compact slot.
// Compare its measured width too so widening cannot trigger a collapse loop.
const isExpanded =
value.length > 0 &&
(contentHeight > text.lineHeight * 1.5 ||
(compactWidth > 0 && contentSize.width > compactWidth));
forceExpanded ||
(value.length > 0 &&
(contentHeight > text.lineHeight * 1.5 ||
(compactWidth > 0 && contentSize.width > compactWidth)));
const compactRowHeight = Math.max(44, minimum);
const editorStyle = useAnimatedStyle(() => ({
position: "absolute",
Expand Down Expand Up @@ -266,6 +271,8 @@ export const COMPOSER_LAYOUT_TRANSITION =
Platform.OS === "android" ? undefined : composerHeightTransition;
// Share of the one-row composer the draft keeps; a long model name truncates first.
const INLINE_EDITOR_MIN_WIDTH = "40%";
// Android types in the expanded editor, so the unfocused row only previews the draft.
const ANDROID_PREVIEW_EDITOR_MIN_WIDTH = "25%";
const INLINE_EDITOR_HEIGHT = 36;

const COMPOSER_ATTACHMENT_ENTERING =
Expand Down Expand Up @@ -565,8 +572,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
const usesLargeControls =
useSidebarWorkspaceTabs() === "picker" || (Platform.OS === "ios" && Platform.isPad);
const [compactRowWidth, setCompactRowWidth] = useState(0);
const [modelToolbarWidth, setModelToolbarWidth] = useState(0);
const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1;
const compactEditor = useCompactComposerEditor(bodyText, draftMessage);
// Android gives focused typing the full-width editor and a toolbar row for the model labels.
const compactEditor = useCompactComposerEditor(
bodyText,
draftMessage,
Platform.OS === "android" && isExpanded,
);
const showsCompactDictation = isVoiceInputPresented;
const isToolbarVisible = isVoiceInputPresented;
const hasEditingAttachments =
Expand Down Expand Up @@ -766,8 +779,16 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
</>
) : null;

const fitsModelToolbar = Platform.OS === "android" && compactEditor.isExpanded;
const modelControl = (
<View className="min-w-0 shrink">
<View
className={fitsModelToolbar ? "min-w-0 flex-1 flex-row justify-end" : "min-w-0 shrink"}
onLayout={
fitsModelToolbar
? (event) => setModelToolbarWidth(event.nativeEvent.layout.width)
: undefined
}
>
<ComposerModelControl
size={usesLargeControls ? "large" : "regular"}
modelOption={currentModelOption}
Expand All @@ -776,7 +797,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
reportedSelection={props.reportedModelSelection}
prompt={draftMessage}
surfaceColor={composerPanel}
iconOnly={!compactEditor.isExpanded}
iconOnly={Platform.OS !== "android" && !compactEditor.isExpanded}
compact={Platform.OS === "android" && !compactEditor.isExpanded}
fitWidth={fitsModelToolbar && modelToolbarWidth > 0 ? modelToolbarWidth : undefined}
onPress={openSettings}
/>
</View>
Expand Down Expand Up @@ -885,10 +908,13 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
onLayout={compactEditor.onEditorLayout}
style={[
{
minWidth:
compactEditor.isExpanded || stripAttachments.length > 0
? 0
: INLINE_EDITOR_MIN_WIDTH,
minWidth: compactEditor.isExpanded
? 0
: Platform.OS === "android"
? ANDROID_PREVIEW_EDITOR_MIN_WIDTH
: stripAttachments.length > 0
? 0
: INLINE_EDITOR_MIN_WIDTH,
},
compactEditor.containerStyle,
compactEditor.isExpanded ? { flex: 0, width: 0 } : undefined,
Expand Down
57 changes: 57 additions & 0 deletions packages/client-runtime/src/modelTraits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { buildTraitsTriggerDisplay } from "./modelTraits.ts";

function effort(currentValue: string): ProviderOptionDescriptor {
return {
id: "reasoningEffort",
label: "Reasoning",
type: "select",
currentValue,
options: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "Extra High" },
{ id: "custom", label: "Deep Think" },
],
};
}

function fastMode(currentValue: boolean): ProviderOptionDescriptor {
return { id: "fastMode", label: "Fast mode", type: "boolean", currentValue };
}

function display(descriptors: ReadonlyArray<ProviderOptionDescriptor>) {
return buildTraitsTriggerDisplay({
provider: ProviderDriverKind.make("claudeAgent"),
descriptors,
primarySelectDescriptorId: "reasoningEffort",
ultrathinkPromptControlled: false,
});
}

describe("buildTraitsTriggerDisplay", () => {
it("abbreviates the selected effort", () => {
expect(display([effort("medium")]).compactEffortLabel).toBe("M");
expect(display([effort("high")]).compactEffortLabel).toBe("H");
expect(display([effort("xhigh")]).compactEffortLabel).toBe("XH");
});

it("keeps an unknown effort label whole rather than inventing an abbreviation", () => {
expect(display([effort("custom")]).compactEffortLabel).toBe("Deep Think");
});

it("reports Fast only while it is enabled and keeps the effort when it is off", () => {
expect(display([effort("high"), fastMode(true)])).toEqual({
label: "High Fast",
compactEffortLabel: "H",
speedLabel: "Fast",
});
expect(display([effort("high"), fastMode(false)])).toEqual({
label: "High",
compactEffortLabel: "H",
speedLabel: null,
});
});
});
30 changes: 27 additions & 3 deletions packages/client-runtime/src/modelTraits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,33 @@ export function getTraitSelectChange(
};
}

const COMPACT_EFFORT_LABELS: Record<string, string> = {
none: "None",
minimal: "Min",
low: "L",
medium: "M",
high: "H",
"extra high": "XH",
xhigh: "XH",
max: "Max",
};

function compactEffortLabel(label: string): string {
const key = label
.toLowerCase()
.replace(/\s+effort$/, "")
.trim();
return COMPACT_EFFORT_LABELS[key] ?? label;
}

export function buildTraitsTriggerDisplay(input: {
provider: ProviderDriverKind;
descriptors: ReadonlyArray<ProviderOptionDescriptor>;
primarySelectDescriptorId: string | null;
ultrathinkPromptControlled: boolean;
modelSelection?: ModelSelection | null;
reportedModelSelection?: ModelSelection | null | undefined;
}): { label: string } {
}): { label: string; compactEffortLabel: string | null; speedLabel: string | null } {
let fastModeFallbackLabel: string | null = null;
let speedLabel: string | null = null;
let reasoningLabelIndex = -1;
Expand Down Expand Up @@ -167,8 +186,13 @@ export function buildTraitsTriggerDisplay(input: {
}
}

const compact = {
compactEffortLabel:
reasoningLabelIndex >= 0 ? compactEffortLabel(labels[reasoningLabelIndex]!) : null,
speedLabel,
};
if (labels.length === 0 && fastModeFallbackLabel !== null) {
return { label: fastModeFallbackLabel };
return { label: fastModeFallbackLabel, ...compact };
}
if (speedLabel) {
if (reasoningLabelIndex >= 0) {
Expand All @@ -177,5 +201,5 @@ export function buildTraitsTriggerDisplay(input: {
labels.push(speedLabel);
}
}
return { label: labels.join(" · ") };
return { label: labels.join(" · "), ...compact };
}
Loading