Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2be7bda
feat: show provider usage limits with /usage-limits
chrisdeeming Sep 4, 2026
7f5b75a
fix: clear stale usage limits and surface failed sources
chrisdeeming Sep 5, 2026
8183ada
fix: republish limit commands on source refresh and drop stale panels
chrisdeeming Sep 5, 2026
50666af
fix: keep the limits panel live and out of New Task
chrisdeeming Sep 5, 2026
3836513
feat: open usage limits when the command is picked
chrisdeeming Sep 5, 2026
819ff67
fix: never drop a provider update and advertise failed sources
chrisdeeming Sep 5, 2026
e7a71ac
fix: tell sibling instances apart and scope panel clears
chrisdeeming Sep 5, 2026
c8d8115
fix(mobile): let a notice-only limits card be dismissed
chrisdeeming Sep 5, 2026
a1227e7
fix: scope the on-send panel clear to the sending thread
chrisdeeming Sep 5, 2026
b0faf85
fix(mobile): keep the limits panel when a reply fails
chrisdeeming Sep 5, 2026
af50a48
fix(mobile): name pooled accounts in the limits card
chrisdeeming Sep 5, 2026
78b805b
fix: identify the panel's thread exactly
chrisdeeming Sep 5, 2026
d1b7828
fix(web): do not repeat a driver-named instance in the panel heading
chrisdeeming Sep 5, 2026
a23a1f4
fix(web): clear the limits panel only once the turn starts
chrisdeeming Sep 5, 2026
ee64aaa
fix: claim /usage-limits only where T3 has limits data
chrisdeeming Sep 5, 2026
4ada290
fix: advertise /usage-limits only to clients that answer it
chrisdeeming Sep 5, 2026
7115102
fix(web): keep the typed command while the panel cannot open
chrisdeeming Sep 5, 2026
f370809
fix: only offer the menu action for a text-only draft
chrisdeeming Sep 5, 2026
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
4 changes: 3 additions & 1 deletion apps/mobile/src/connection/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ type ConnectionLayerSource =
| typeof mobileBackgroundActivityReporterLayer;

const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })),
Layer.provideMerge(
Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }),
),
Layer.provideMerge(
Layer.mergeAll(
runtimeContextLayer,
Expand Down
103 changes: 103 additions & 0 deletions apps/mobile/src/features/threads/ComposerUsageLimits.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts";
import { Pressable, ScrollView, useWindowDimensions, View } from "react-native";

import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection";

const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };

/**
* The /usage-limits result, docked above the composer. It is the Usage → Limits
* card one size down, so the two read as the same thing. The surface is opaque
* because nothing blurs the feed behind it.
*/
export function ComposerUsageLimits({
report,
environmentId,
onClose,
}: {
readonly report: UsageLimitsReport;
readonly environmentId: EnvironmentId;
readonly onClose: () => void;
}) {
const now = Date.parse(report.createdAt);
const { height } = useWindowDimensions();
const close = (
<Pressable
accessibilityLabel="Dismiss usage limits"
accessibilityRole="button"
hitSlop={12}
onPress={onClose}
className="-me-1 p-1 active:opacity-60"
>
<SymbolView name="xmark" size={14} tintColorClassName="accent-icon-muted" type="monochrome" />
</Pressable>
);
return (
<View className="overflow-hidden rounded-[20px] border-continuous bg-card">
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
style={{ maxHeight: Math.round(height * 0.4) }}
>
{report.accounts.map((account, index) => {
const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver);
return (
<AccountLimits
key={account.id}
dense
first={index === 0}
driver={account.driver}
label={driverLabel}
// Siblings need telling apart: a custom instance without a name shows its
// id, and a pooled account shows its hub and account id.
instanceLabel={
account.instanceId
? account.displayName?.trim() ||
(String(account.instanceId) !== String(account.driver)
? account.instanceId
: driverLabel)
: account.label
}
detail={account.plan}
limits={account.limits}
now={now}
trailing={index === 0 ? close : undefined}
footer={
account.instanceId && account.limits.resetCredits ? (
<ResetCredits
dense
environmentId={environmentId}
instanceId={account.instanceId}
credits={account.limits.resetCredits}
now={now}
/>
) : undefined
}
/>
);
})}
{report.accounts.length === 0 ? (
// Nothing but notices, so the close control needs a row of its own.
<View className="flex-row items-center gap-3 px-4 pt-3">
<Text className="min-w-0 flex-1 text-base text-foreground">Usage limits</Text>
{close}
</View>
) : null}
{report.notices.map((notice) => (
<Text
key={notice}
className={
report.accounts.length === 0
? "px-4 py-3 text-xs text-foreground-muted"
: "border-t border-border-subtle px-4 py-3 text-xs text-foreground-muted"
}
>
{notice}
</Text>
))}
Comment thread
cursor[bot] marked this conversation as resolved.
</ScrollView>
</View>
);
}
24 changes: 24 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid
import { ProviderIcon } from "../../components/ProviderIcon";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
import { ShimmeringWorkContent } from "./thread-work-log";
import { ComposerCommandPopover } from "./ComposerCommandPopover";
Expand Down Expand Up @@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: {
const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting;
// Also guard while a submit is in flight: an Android back press or iOS
// Cancel would otherwise abandon the screen while the task still starts.
// T3 owns /usage-limits only where Limits has data for the selected provider.
const offersUsageLimits =
flow.selectedProviderStatus !== null &&
hasProviderUsageLimits(
flow.selectedProviderStatus.driver,
selectedEnvironmentServerConfig?.providers ?? [],
selectedEnvironmentServerConfig?.usageLimitSources ?? [],
);
const composerMenu = useComposerCommandMenu({
draftMessage: flow.prompt,
ownerKey: flow.draftKey,
Expand All @@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: {
selectedProviderStatus: flow.selectedProviderStatus,
hasThread: false,
hasCompactableConversation: false,
offersUsageLimits: offersUsageLimits,
enabled: isComposerFocused && !isComposerInteractionLocked,
onChangeDraftMessage: flow.setPrompt,
onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
Expand Down Expand Up @@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: {
);
return;
}
// T3's own limits command is answered by the thread composer; a new task would
// send it to the agent. A provider's same-named command, or a prompt carrying
// attachments, goes through as usual.
if (
offersUsageLimits &&
isUsageLimitsCommand(initialMessageText) &&
draft.attachments.length === 0
) {
Alert.alert(
"Usage limits",
"Send /usage-limits inside a thread, or open Settings → Usage → Limits.",
);
return;
}
// A failed-send restore can leave the draft over the cap on purpose (it
// never drops the user's files); starting anyway would upload everything
// and have the server reject the turn.
Expand Down
55 changes: 52 additions & 3 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import type {
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
UsageLimitsReport,
} from "@t3tools/contracts";
import {
collectProviderUsageLimits,
hasProviderUsageLimits,
isUsageLimitsCommand,
} from "@t3tools/shared/usageLimits";
import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
import type { ReactNode } from "react";
import {
Expand All @@ -20,7 +26,7 @@ import {
useState,
type RefObject,
} from "react";
import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import {
composerAttachmentUploadBlockReason,
Expand Down Expand Up @@ -124,6 +130,8 @@ export interface ThreadComposerProps {
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
/** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */
readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void;
readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
Expand Down Expand Up @@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
);
}, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props;
// T3 owns /usage-limits only where Limits has data for the selected provider;
// elsewhere the name stays the provider's own and is sent through untouched.
const usageLimitsOffered =
selectedProviderStatus !== null &&
hasProviderUsageLimits(
selectedProviderStatus.driver,
props.serverConfig?.providers ?? [],
props.serverConfig?.usageLimitSources ?? [],
);
// Answered locally from the last Limits snapshot; the agent never sees it.
const openUsageLimits = useCallback(() => {
const report = collectProviderUsageLimits(
currentModelSelection.instanceId,
props.serverConfig?.providers ?? [],
props.serverConfig?.usageLimitSources ?? [],
Date.now(),
);
onShowUsageLimits(report);
if (!report) {
Alert.alert("Usage limits unavailable", "This provider does not currently report limits.");
}
return report !== null;
}, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]);

const composerMenu = useComposerCommandMenu({
draftMessage: props.draftMessage,
Expand All @@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
selectedProviderStatus?.showInteractionModeToggle === false
? undefined
: props.onUpdateInteractionMode,
offersUsageLimits: usageLimitsOffered,
// With attachments aboard the pick just inserts the text, so it sends as a prompt.
onUsageLimits:
usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined,
});
const voiceInput = useVoiceInputController({
ownerKey: composerOwnerKey,
Expand Down Expand Up @@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
onEditorFocusChange?.(false);
}, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);
const { onSendMessage } = props;

const handleSend = useCallback(async () => {
// Typed out in full rather than picked from the menu. Attachments mean the
// user is sending a prompt, so those go through as usual.
if (
usageLimitsOffered &&
isUsageLimitsCommand(props.draftMessage) &&
props.draftAttachments.length === 0
) {
if (openUsageLimits()) onChangeDraftMessage("");
return;
}
if (voiceInput.blocksSubmission) return;
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
if (inFlightThreadIdsRef.current.has(threadKey)) return;
Expand All @@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
inFlightThreadIdsRef.current.delete(threadKey);
}
}, [
props.draftMessage,
props.draftAttachments.length,
onChangeDraftMessage,
openUsageLimits,
usageLimitsOffered,
onSendMessage,
props.environmentId,
props.environmentLabel,
Expand Down
Loading
Loading