| author | |
| committer | |
| log | 183c3433091c30a19117ee733832b1c44a6af074 |
| tree | 707106828dfc6bf934ba90e028bb8193ff11005f |
| parent | 748fe0f8b68b7951df6797d4c22d2ca01314a3dc |
| signature | Signed by PGP key B5690EEEBB952194 |
23 files changed, 1315 insertions(+), 128 deletions(-)
apps/mobile/src/connection/runtime.ts+3-1| ... | @@ -31,7 +31,9 @@ type ConnectionLayerSource = | ... | @@ -31,7 +31,9 @@ type ConnectionLayerSource = |
| 31 | | typeof mobileBackgroundActivityReporterLayer; | 31 | | typeof mobileBackgroundActivityReporterLayer; |
| 32 | 32 | ||
| 33 | const providedClientConnectionLayer = snapshotLoaderLayer.pipe( | 33 | const providedClientConnectionLayer = snapshotLoaderLayer.pipe( |
| 34 | Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })), | 34 | Layer.provideMerge( |
| 35 | Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), | ||
| 36 | ), | ||
| 35 | Layer.provideMerge( | 37 | Layer.provideMerge( |
| 36 | Layer.mergeAll( | 38 | Layer.mergeAll( |
| 37 | runtimeContextLayer, | 39 | runtimeContextLayer, |
apps/mobile/src/features/threads/ComposerUsageLimits.tsx created+103| ... | @@ -0,0 +1,103 @@ | ||
| 1 | import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; | ||
| 2 | import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | ||
| 3 | |||
| 4 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 5 | import { AppText as Text } from "../../components/AppText"; | ||
| 6 | import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; | ||
| 7 | |||
| 8 | const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | ||
| 9 | |||
| 10 | /** | ||
| 11 | * The /usage-limits result, docked above the composer. It is the Usage → Limits | ||
| 12 | * card one size down, so the two read as the same thing. The surface is opaque | ||
| 13 | * because nothing blurs the feed behind it. | ||
| 14 | */ | ||
| 15 | export function ComposerUsageLimits({ | ||
| 16 | report, | ||
| 17 | environmentId, | ||
| 18 | onClose, | ||
| 19 | }: { | ||
| 20 | readonly report: UsageLimitsReport; | ||
| 21 | readonly environmentId: EnvironmentId; | ||
| 22 | readonly onClose: () => void; | ||
| 23 | }) { | ||
| 24 | const now = Date.parse(report.createdAt); | ||
| 25 | const { height } = useWindowDimensions(); | ||
| 26 | const close = ( | ||
| 27 | <Pressable | ||
| 28 | accessibilityLabel="Dismiss usage limits" | ||
| 29 | accessibilityRole="button" | ||
| 30 | hitSlop={12} | ||
| 31 | onPress={onClose} | ||
| 32 | className="-me-1 p-1 active:opacity-60" | ||
| 33 | > | ||
| 34 | <SymbolView name="xmark" size={14} tintColorClassName="accent-icon-muted" type="monochrome" /> | ||
| 35 | </Pressable> | ||
| 36 | ); | ||
| 37 | return ( | ||
| 38 | <View className="overflow-hidden rounded-[20px] border-continuous bg-card"> | ||
| 39 | <ScrollView | ||
| 40 | bounces={false} | ||
| 41 | showsVerticalScrollIndicator={false} | ||
| 42 | style={{ maxHeight: Math.round(height * 0.4) }} | ||
| 43 | > | ||
| 44 | {report.accounts.map((account, index) => { | ||
| 45 | const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); | ||
| 46 | return ( | ||
| 47 | <AccountLimits | ||
| 48 | key={account.id} | ||
| 49 | dense | ||
| 50 | first={index === 0} | ||
| 51 | driver={account.driver} | ||
| 52 | label={driverLabel} | ||
| 53 | // Siblings need telling apart: a custom instance without a name shows its | ||
| 54 | // id, and a pooled account shows its hub and account id. | ||
| 55 | instanceLabel={ | ||
| 56 | account.instanceId | ||
| 57 | ? account.displayName?.trim() || | ||
| 58 | (String(account.instanceId) !== String(account.driver) | ||
| 59 | ? account.instanceId | ||
| 60 | : driverLabel) | ||
| 61 | : account.label | ||
| 62 | } | ||
| 63 | detail={account.plan} | ||
| 64 | limits={account.limits} | ||
| 65 | now={now} | ||
| 66 | trailing={index === 0 ? close : undefined} | ||
| 67 | footer={ | ||
| 68 | account.instanceId && account.limits.resetCredits ? ( | ||
| 69 | <ResetCredits | ||
| 70 | dense | ||
| 71 | environmentId={environmentId} | ||
| 72 | instanceId={account.instanceId} | ||
| 73 | credits={account.limits.resetCredits} | ||
| 74 | now={now} | ||
| 75 | /> | ||
| 76 | ) : undefined | ||
| 77 | } | ||
| 78 | /> | ||
| 79 | ); | ||
| 80 | })} | ||
| 81 | {report.accounts.length === 0 ? ( | ||
| 82 | // Nothing but notices, so the close control needs a row of its own. | ||
| 83 | <View className="flex-row items-center gap-3 px-4 pt-3"> | ||
| 84 | <Text className="min-w-0 flex-1 text-base text-foreground">Usage limits</Text> | ||
| 85 | {close} | ||
| 86 | </View> | ||
| 87 | ) : null} | ||
| 88 | {report.notices.map((notice) => ( | ||
| 89 | <Text | ||
| 90 | key={notice} | ||
| 91 | className={ | ||
| 92 | report.accounts.length === 0 | ||
| 93 | ? "px-4 py-3 text-xs text-foreground-muted" | ||
| 94 | : "border-t border-border-subtle px-4 py-3 text-xs text-foreground-muted" | ||
| 95 | } | ||
| 96 | > | ||
| 97 | {notice} | ||
| 98 | </Text> | ||
| 99 | ))} | ||
| 100 | </ScrollView> | ||
| 101 | </View> | ||
| 102 | ); | ||
| 103 | } | ||
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24| ... | @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid | ... | @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid |
| 49 | import { ProviderIcon } from "../../components/ProviderIcon"; | 49 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 50 | import { SymbolView } from "../../components/AppSymbol"; | 50 | import { SymbolView } from "../../components/AppSymbol"; |
| 51 | import { AppText as Text } from "../../components/AppText"; | 51 | import { AppText as Text } from "../../components/AppText"; |
| 52 | import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; | ||
| 52 | import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; | 53 | import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; |
| 53 | import { ShimmeringWorkContent } from "./thread-work-log"; | 54 | import { ShimmeringWorkContent } from "./thread-work-log"; |
| 54 | import { ComposerCommandPopover } from "./ComposerCommandPopover"; | 55 | import { ComposerCommandPopover } from "./ComposerCommandPopover"; |
| ... | @@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: { | ... | @@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: { |
| 309 | const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; | 310 | const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; |
| 310 | // Also guard while a submit is in flight: an Android back press or iOS | 311 | // Also guard while a submit is in flight: an Android back press or iOS |
| 311 | // Cancel would otherwise abandon the screen while the task still starts. | 312 | // Cancel would otherwise abandon the screen while the task still starts. |
| 313 | // T3 owns /usage-limits only where Limits has data for the selected provider. | ||
| 314 | const offersUsageLimits = | ||
| 315 | flow.selectedProviderStatus !== null && | ||
| 316 | hasProviderUsageLimits( | ||
| 317 | flow.selectedProviderStatus.driver, | ||
| 318 | selectedEnvironmentServerConfig?.providers ?? [], | ||
| 319 | selectedEnvironmentServerConfig?.usageLimitSources ?? [], | ||
| 320 | ); | ||
| 312 | const composerMenu = useComposerCommandMenu({ | 321 | const composerMenu = useComposerCommandMenu({ |
| 313 | draftMessage: flow.prompt, | 322 | draftMessage: flow.prompt, |
| 314 | ownerKey: flow.draftKey, | 323 | ownerKey: flow.draftKey, |
| ... | @@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: { |
| 320 | selectedProviderStatus: flow.selectedProviderStatus, | 329 | selectedProviderStatus: flow.selectedProviderStatus, |
| 321 | hasThread: false, | 330 | hasThread: false, |
| 322 | hasCompactableConversation: false, | 331 | hasCompactableConversation: false, |
| 332 | offersUsageLimits: offersUsageLimits, | ||
| 323 | enabled: isComposerFocused && !isComposerInteractionLocked, | 333 | enabled: isComposerFocused && !isComposerInteractionLocked, |
| 324 | onChangeDraftMessage: flow.setPrompt, | 334 | onChangeDraftMessage: flow.setPrompt, |
| 325 | onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, | 335 | onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, |
| ... | @@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: { | ... | @@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: { |
| 908 | ); | 918 | ); |
| 909 | return; | 919 | return; |
| 910 | } | 920 | } |
| 921 | // T3's own limits command is answered by the thread composer; a new task would | ||
| 922 | // send it to the agent. A provider's same-named command, or a prompt carrying | ||
| 923 | // attachments, goes through as usual. | ||
| 924 | if ( | ||
| 925 | offersUsageLimits && | ||
| 926 | isUsageLimitsCommand(initialMessageText) && | ||
| 927 | draft.attachments.length === 0 | ||
| 928 | ) { | ||
| 929 | Alert.alert( | ||
| 930 | "Usage limits", | ||
| 931 | "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", | ||
| 932 | ); | ||
| 933 | return; | ||
| 934 | } | ||
| 911 | // A failed-send restore can leave the draft over the cap on purpose (it | 935 | // A failed-send restore can leave the draft over the cap on purpose (it |
| 912 | // never drops the user's files); starting anyway would upload everything | 936 | // never drops the user's files); starting anyway would upload everything |
| 913 | // and have the server reject the turn. | 937 | // and have the server reject the turn. |
apps/mobile/src/features/threads/ThreadComposer.tsx+52-3| ... | @@ -7,7 +7,13 @@ import type { | ... | @@ -7,7 +7,13 @@ import type { |
| 7 | ProviderInteractionMode, | 7 | ProviderInteractionMode, |
| 8 | RuntimeMode, | 8 | RuntimeMode, |
| 9 | ServerConfig as T3ServerConfig, | 9 | ServerConfig as T3ServerConfig, |
| 10 | UsageLimitsReport, | ||
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { | ||
| 13 | collectProviderUsageLimits, | ||
| 14 | hasProviderUsageLimits, | ||
| 15 | isUsageLimitsCommand, | ||
| 16 | } from "@t3tools/shared/usageLimits"; | ||
| 11 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; | 17 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 12 | import type { ReactNode } from "react"; | 18 | import type { ReactNode } from "react"; |
| 13 | import { | 19 | import { |
| ... | @@ -20,7 +26,7 @@ import { | ... | @@ -20,7 +26,7 @@ import { |
| 20 | useState, | 26 | useState, |
| 21 | type RefObject, | 27 | type RefObject, |
| 22 | } from "react"; | 28 | } from "react"; |
| 23 | import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; | 29 | import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; |
| 24 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 30 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 25 | import { | 31 | import { |
| 26 | composerAttachmentUploadBlockReason, | 32 | composerAttachmentUploadBlockReason, |
| ... | @@ -124,6 +130,8 @@ export interface ThreadComposerProps { | ... | @@ -124,6 +130,8 @@ export interface ThreadComposerProps { |
| 124 | readonly onRemoveDraftImage: (imageId: string) => void; | 130 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 125 | readonly onStopThread: () => void; | 131 | readonly onStopThread: () => void; |
| 126 | readonly onSendMessage: () => Promise<MessageId | null>; | 132 | readonly onSendMessage: () => Promise<MessageId | null>; |
| 133 | /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ | ||
| 134 | readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; | ||
| 127 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; | 135 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; |
| 128 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; | 136 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| 129 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; | 137 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| ... | @@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 336 | ); | 344 | ); |
| 337 | }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); | 345 | }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); |
| 338 | const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 346 | const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 347 | const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; | ||
| 348 | // T3 owns /usage-limits only where Limits has data for the selected provider; | ||
| 349 | // elsewhere the name stays the provider's own and is sent through untouched. | ||
| 350 | const usageLimitsOffered = | ||
| 351 | selectedProviderStatus !== null && | ||
| 352 | hasProviderUsageLimits( | ||
| 353 | selectedProviderStatus.driver, | ||
| 354 | props.serverConfig?.providers ?? [], | ||
| 355 | props.serverConfig?.usageLimitSources ?? [], | ||
| 356 | ); | ||
| 357 | // Answered locally from the last Limits snapshot; the agent never sees it. | ||
| 358 | const openUsageLimits = useCallback(() => { | ||
| 359 | const report = collectProviderUsageLimits( | ||
| 360 | currentModelSelection.instanceId, | ||
| 361 | props.serverConfig?.providers ?? [], | ||
| 362 | props.serverConfig?.usageLimitSources ?? [], | ||
| 363 | Date.now(), | ||
| 364 | ); | ||
| 365 | onShowUsageLimits(report); | ||
| 366 | if (!report) { | ||
| 367 | Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); | ||
| 368 | } | ||
| 369 | return report !== null; | ||
| 370 | }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); | ||
| 339 | 371 | ||
| 340 | const composerMenu = useComposerCommandMenu({ | 372 | const composerMenu = useComposerCommandMenu({ |
| 341 | draftMessage: props.draftMessage, | 373 | draftMessage: props.draftMessage, |
| ... | @@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 350 | selectedProviderStatus?.showInteractionModeToggle === false | 382 | selectedProviderStatus?.showInteractionModeToggle === false |
| 351 | ? undefined | 383 | ? undefined |
| 352 | : props.onUpdateInteractionMode, | 384 | : props.onUpdateInteractionMode, |
| 385 | offersUsageLimits: usageLimitsOffered, | ||
| 386 | // With attachments aboard the pick just inserts the text, so it sends as a prompt. | ||
| 387 | onUsageLimits: | ||
| 388 | usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, | ||
| 353 | }); | 389 | }); |
| 354 | const voiceInput = useVoiceInputController({ | 390 | const voiceInput = useVoiceInputController({ |
| 355 | ownerKey: composerOwnerKey, | 391 | ownerKey: composerOwnerKey, |
| ... | @@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 428 | } | 464 | } |
| 429 | onEditorFocusChange?.(false); | 465 | onEditorFocusChange?.(false); |
| 430 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); | 466 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); |
| 431 | const { onSendMessage } = props; | ||
| 432 | |||
| 433 | const handleSend = useCallback(async () => { | 467 | const handleSend = useCallback(async () => { |
| 468 | // Typed out in full rather than picked from the menu. Attachments mean the | ||
| 469 | // user is sending a prompt, so those go through as usual. | ||
| 470 | if ( | ||
| 471 | usageLimitsOffered && | ||
| 472 | isUsageLimitsCommand(props.draftMessage) && | ||
| 473 | props.draftAttachments.length === 0 | ||
| 474 | ) { | ||
| 475 | if (openUsageLimits()) onChangeDraftMessage(""); | ||
| 476 | return; | ||
| 477 | } | ||
| 434 | if (voiceInput.blocksSubmission) return; | 478 | if (voiceInput.blocksSubmission) return; |
| 435 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 479 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 436 | if (inFlightThreadIdsRef.current.has(threadKey)) return; | 480 | if (inFlightThreadIdsRef.current.has(threadKey)) return; |
| ... | @@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 453 | inFlightThreadIdsRef.current.delete(threadKey); | 497 | inFlightThreadIdsRef.current.delete(threadKey); |
| 454 | } | 498 | } |
| 455 | }, [ | 499 | }, [ |
| 500 | props.draftMessage, | ||
| 501 | props.draftAttachments.length, | ||
| 502 | onChangeDraftMessage, | ||
| 503 | openUsageLimits, | ||
| 504 | usageLimitsOffered, | ||
| 456 | onSendMessage, | 505 | onSendMessage, |
| 457 | props.environmentId, | 506 | props.environmentId, |
| 458 | props.environmentLabel, | 507 | props.environmentLabel, |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+83| ... | @@ -19,6 +19,7 @@ import type { | ... | @@ -19,6 +19,7 @@ import type { |
| 19 | RuntimeMode, | 19 | RuntimeMode, |
| 20 | ServerConfig as T3ServerConfig, | 20 | ServerConfig as T3ServerConfig, |
| 21 | ThreadId, | 21 | ThreadId, |
| 22 | UsageLimitsReport, | ||
| 22 | UserInputQuestion, | 23 | UserInputQuestion, |
| 23 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 24 | import * as Haptics from "expo-haptics"; | 25 | import * as Haptics from "expo-haptics"; |
| ... | @@ -57,6 +58,7 @@ import Animated, { | ... | @@ -57,6 +58,7 @@ import Animated, { |
| 57 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 58 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 58 | 59 | ||
| 59 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 60 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 61 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; | ||
| 60 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 62 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 61 | import type { StatusTone } from "../../components/StatusPill"; | 63 | import type { StatusTone } from "../../components/StatusPill"; |
| 62 | import type { DraftComposerAttachment } from "../../lib/composerImages"; | 64 | import type { DraftComposerAttachment } from "../../lib/composerImages"; |
| ... | @@ -70,6 +72,7 @@ import type { | ... | @@ -70,6 +72,7 @@ import type { |
| 70 | ThreadFeedEntry, | 72 | ThreadFeedEntry, |
| 71 | } from "../../lib/threadActivity"; | 73 | } from "../../lib/threadActivity"; |
| 72 | import { PendingApprovalCard } from "./PendingApprovalCard"; | 74 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 75 | import { ComposerUsageLimits } from "./ComposerUsageLimits"; | ||
| 73 | import { PendingUserInputCard } from "./PendingUserInputCard"; | 76 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 74 | import { | 77 | import { |
| 75 | FLOATING_WORKING_CONTROL_COVERAGE, | 78 | FLOATING_WORKING_CONTROL_COVERAGE, |
| ... | @@ -359,6 +362,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -359,6 +362,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 359 | const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = | 362 | const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = |
| 360 | useState<ApprovalRequestId | null>(null); | 363 | useState<ApprovalRequestId | null>(null); |
| 361 | const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; | 364 | const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; |
| 365 | // The open /usage-limits panel for this thread, model and turn. Only the open | ||
| 366 | // moment is stored: the rows read live provider data, so a redeemed reset | ||
| 367 | // credit or refreshed probe shows through. Anything that spends quota closes | ||
| 368 | // it: a new turn from any source, or the agent resuming after an approval or | ||
| 369 | // answered question. | ||
| 370 | const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ | ||
| 371 | readonly key: string; | ||
| 372 | readonly threadKey: string; | ||
| 373 | readonly now: number; | ||
| 374 | } | null>(null); | ||
| 375 | // A pending approval or question is part of the key: once it is answered, | ||
| 376 | // from this client or any other, the agent resumes and spends quota. | ||
| 377 | const usageLimitsKey = [ | ||
| 378 | selectedThreadKey, | ||
| 379 | props.selectedThread.modelSelection.instanceId, | ||
| 380 | props.selectedThread.latestTurn?.turnId ?? "", | ||
| 381 | props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", | ||
| 382 | ].join(":"); | ||
| 383 | // Drop the snapshot as soon as the key changes so it cannot resurface stale. | ||
| 384 | if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { | ||
| 385 | setUsageLimitsPanel(null); | ||
| 386 | } | ||
| 387 | const usageLimitsReport = useMemo( | ||
| 388 | () => | ||
| 389 | usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey | ||
| 390 | ? collectProviderUsageLimits( | ||
| 391 | props.selectedThread.modelSelection.instanceId, | ||
| 392 | props.serverConfig?.providers ?? [], | ||
| 393 | props.serverConfig?.usageLimitSources ?? [], | ||
| 394 | usageLimitsPanel.now, | ||
| 395 | ) | ||
| 396 | : null, | ||
| 397 | [ | ||
| 398 | props.selectedThread.modelSelection.instanceId, | ||
| 399 | props.serverConfig, | ||
| 400 | usageLimitsKey, | ||
| 401 | usageLimitsPanel, | ||
| 402 | ], | ||
| 403 | ); | ||
| 404 | const showUsageLimits = useCallback( | ||
| 405 | (report: UsageLimitsReport | null) => | ||
| 406 | setUsageLimitsPanel( | ||
| 407 | report === null | ||
| 408 | ? null | ||
| 409 | : { | ||
| 410 | key: usageLimitsKey, | ||
| 411 | threadKey: selectedThreadKey, | ||
| 412 | now: Date.parse(report.createdAt), | ||
| 413 | }, | ||
| 414 | ), | ||
| 415 | [selectedThreadKey, usageLimitsKey], | ||
| 416 | ); | ||
| 417 | const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); | ||
| 418 | // A send may resolve after navigating away, so only the originating | ||
| 419 | // thread's panel is cleared; a panel opened elsewhere in the meantime stays. | ||
| 420 | const clearUsageLimitsFor = useCallback( | ||
| 421 | (threadKey: string) => | ||
| 422 | setUsageLimitsPanel((current) => | ||
| 423 | current !== null && current.threadKey === threadKey ? null : current, | ||
| 424 | ), | ||
| 425 | [], | ||
| 426 | ); | ||
| 362 | const userInputCollapsed = | 427 | const userInputCollapsed = |
| 363 | activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; | 428 | activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; |
| 364 | // The card's height RESERVES keyboard space at all times instead of | 429 | // The card's height RESERVES keyboard space at all times instead of |
| ... | @@ -623,6 +688,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -623,6 +688,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 623 | return messageId; | 688 | return messageId; |
| 624 | } | 689 | } |
| 625 | 690 | ||
| 691 | // A sent message makes the snapshot stale; a refused send leaves it in place. | ||
| 692 | clearUsageLimitsFor(targetThreadKey); | ||
| 693 | |||
| 626 | setSubmittedMessageId(messageId); | 694 | setSubmittedMessageId(messageId); |
| 627 | setAnchorMessageId( | 695 | setAnchorMessageId( |
| 628 | resolveThreadFeedSubmissionAnchor({ | 696 | resolveThreadFeedSubmissionAnchor({ |
| ... | @@ -637,6 +705,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -637,6 +705,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 637 | return messageId; | 705 | return messageId; |
| 638 | }, [ | 706 | }, [ |
| 639 | anchorMessageId, | 707 | anchorMessageId, |
| 708 | clearUsageLimitsFor, | ||
| 640 | props.onSendMessage, | 709 | props.onSendMessage, |
| 641 | props.selectedThread.latestTurn, | 710 | props.selectedThread.latestTurn, |
| 642 | props.selectedThreadQueueCount, | 711 | props.selectedThreadQueueCount, |
| ... | @@ -778,6 +847,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -778,6 +847,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 778 | onScrollToEnd={handleScrollToEnd} | 847 | onScrollToEnd={handleScrollToEnd} |
| 779 | /> | 848 | /> |
| 780 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> | 849 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> |
| 850 | {usageLimitsReport && activeUserInputRequestId === null ? ( | ||
| 851 | <Animated.View | ||
| 852 | className="shrink-0 px-4 pb-3" | ||
| 853 | entering={FadeInDown.duration(220)} | ||
| 854 | exiting={FadeOut.duration(140)} | ||
| 855 | > | ||
| 856 | <ComposerUsageLimits | ||
| 857 | report={usageLimitsReport} | ||
| 858 | environmentId={props.environmentId} | ||
| 859 | onClose={dismissUsageLimits} | ||
| 860 | /> | ||
| 861 | </Animated.View> | ||
| 862 | ) : null} | ||
| 781 | {props.activePendingApproval || props.activePendingUserInput ? ( | 863 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 782 | <Animated.View | 864 | <Animated.View |
| 783 | className="shrink-0 gap-3 px-4 pb-3" | 865 | className="shrink-0 gap-3 px-4 pb-3" |
| ... | @@ -846,6 +928,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -846,6 +928,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 846 | onRemoveDraftImage={props.onRemoveDraftImage} | 928 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 847 | onStopThread={props.onStopThread} | 929 | onStopThread={props.onStopThread} |
| 848 | onSendMessage={handleSendMessage} | 930 | onSendMessage={handleSendMessage} |
| 931 | onShowUsageLimits={showUsageLimits} | ||
| 849 | onReconnectEnvironment={props.onReconnectEnvironment} | 932 | onReconnectEnvironment={props.onReconnectEnvironment} |
| 850 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | 933 | onUpdateModelSelection={props.onUpdateThreadModelSelection} |
| 851 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | 934 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} |
apps/mobile/src/features/threads/use-composer-command-menu.ts+29| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; |
| 2 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; | ||
| 2 | import { | 3 | import { |
| 3 | detectComposerTrigger, | 4 | detectComposerTrigger, |
| 4 | replaceTextRange, | 5 | replaceTextRange, |
| ... | @@ -36,6 +37,8 @@ export function buildComposerSlashCommandItems(input: { | ... | @@ -36,6 +37,8 @@ export function buildComposerSlashCommandItems(input: { |
| 36 | readonly atMessageStart: boolean; | 37 | readonly atMessageStart: boolean; |
| 37 | readonly hasThread: boolean; | 38 | readonly hasThread: boolean; |
| 38 | readonly hasCompactableConversation?: boolean; | 39 | readonly hasCompactableConversation?: boolean; |
| 40 | /** Whether T3 itself offers /usage-limits for the selected provider. */ | ||
| 41 | readonly offersUsageLimits?: boolean; | ||
| 39 | readonly allowInteractionMode: boolean; | 42 | readonly allowInteractionMode: boolean; |
| 40 | readonly selectedProviderStatus: Pick< | 43 | readonly selectedProviderStatus: Pick< |
| 41 | ServerProvider, | 44 | ServerProvider, |
| ... | @@ -78,6 +81,11 @@ export function buildComposerSlashCommandItems(input: { | ... | @@ -78,6 +81,11 @@ export function buildComposerSlashCommandItems(input: { |
| 78 | for (const command of input.selectedProviderStatus?.slashCommands ?? []) { | 81 | for (const command of input.selectedProviderStatus?.slashCommands ?? []) { |
| 79 | if (!command.name.toLowerCase().includes(query)) continue; | 82 | if (!command.name.toLowerCase().includes(query)) continue; |
| 80 | if (command.name === "compact" && !input.hasCompactableConversation) continue; | 83 | if (command.name === "compact" && !input.hasCompactableConversation) continue; |
| 84 | // T3's own limits command is answered by the thread composer; New Task has | ||
| 85 | // nowhere to show it. A provider's same-named command is left alone. | ||
| 86 | if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) { | ||
| 87 | continue; | ||
| 88 | } | ||
| 81 | if ( | 89 | if ( |
| 82 | !input.hasThread && | 90 | !input.hasThread && |
| 83 | input.selectedProviderStatus?.driver === "codex" && | 91 | input.selectedProviderStatus?.driver === "codex" && |
| ... | @@ -143,9 +151,11 @@ export function useComposerCommandMenu({ | ... | @@ -143,9 +151,11 @@ export function useComposerCommandMenu({ |
| 143 | selectedProviderStatus, | 151 | selectedProviderStatus, |
| 144 | hasThread, | 152 | hasThread, |
| 145 | hasCompactableConversation, | 153 | hasCompactableConversation, |
| 154 | offersUsageLimits = false, | ||
| 146 | enabled = true, | 155 | enabled = true, |
| 147 | onChangeDraftMessage, | 156 | onChangeDraftMessage, |
| 148 | onUpdateInteractionMode, | 157 | onUpdateInteractionMode, |
| 158 | onUsageLimits, | ||
| 149 | }: { | 159 | }: { |
| 150 | readonly draftMessage: string; | 160 | readonly draftMessage: string; |
| 151 | readonly ownerKey: string | null; | 161 | readonly ownerKey: string | null; |
| ... | @@ -154,9 +164,13 @@ export function useComposerCommandMenu({ | ... | @@ -154,9 +164,13 @@ export function useComposerCommandMenu({ |
| 154 | readonly selectedProviderStatus: ServerProvider | null; | 164 | readonly selectedProviderStatus: ServerProvider | null; |
| 155 | readonly hasThread: boolean; | 165 | readonly hasThread: boolean; |
| 156 | readonly hasCompactableConversation: boolean; | 166 | readonly hasCompactableConversation: boolean; |
| 167 | /** Whether T3 itself offers /usage-limits for the selected provider. */ | ||
| 168 | readonly offersUsageLimits?: boolean; | ||
| 157 | readonly enabled?: boolean; | 169 | readonly enabled?: boolean; |
| 158 | readonly onChangeDraftMessage: (value: string) => void; | 170 | readonly onChangeDraftMessage: (value: string) => void; |
| 159 | readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; | 171 | readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; |
| 172 | /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */ | ||
| 173 | readonly onUsageLimits?: () => void; | ||
| 160 | }) { | 174 | }) { |
| 161 | const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); | 175 | const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); |
| 162 | const previousOwnerKeyRef = useRef(ownerKey); | 176 | const previousOwnerKeyRef = useRef(ownerKey); |
| ... | @@ -267,6 +281,7 @@ export function useComposerCommandMenu({ | ... | @@ -267,6 +281,7 @@ export function useComposerCommandMenu({ |
| 267 | atMessageStart: trigger.rangeStart === 0, | 281 | atMessageStart: trigger.rangeStart === 0, |
| 268 | hasThread, | 282 | hasThread, |
| 269 | hasCompactableConversation, | 283 | hasCompactableConversation, |
| 284 | offersUsageLimits, | ||
| 270 | allowInteractionMode: onUpdateInteractionMode !== undefined, | 285 | allowInteractionMode: onUpdateInteractionMode !== undefined, |
| 271 | selectedProviderStatus, | 286 | selectedProviderStatus, |
| 272 | }); | 287 | }); |
| ... | @@ -390,12 +405,25 @@ export function useComposerCommandMenu({ | ... | @@ -390,12 +405,25 @@ export function useComposerCommandMenu({ |
| 390 | selectedProviderStatus, | 405 | selectedProviderStatus, |
| 391 | skills, | 406 | skills, |
| 392 | trigger, | 407 | trigger, |
| 408 | offersUsageLimits, | ||
| 393 | ]); | 409 | ]); |
| 394 | 410 | ||
| 395 | const onSelect = useCallback( | 411 | const onSelect = useCallback( |
| 396 | (item: ComposerCommandItem) => { | 412 | (item: ComposerCommandItem) => { |
| 397 | if (!trigger) return; | 413 | if (!trigger) return; |
| 398 | 414 | ||
| 415 | if ( | ||
| 416 | item.type === "provider-slash-command" && | ||
| 417 | item.command.name === USAGE_LIMITS_COMMAND.name && | ||
| 418 | onUsageLimits | ||
| 419 | ) { | ||
| 420 | const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); | ||
| 421 | setSelection({ start: cleared.cursor, end: cleared.cursor }); | ||
| 422 | onChangeDraftMessage(cleared.text); | ||
| 423 | onUsageLimits(); | ||
| 424 | return; | ||
| 425 | } | ||
| 426 | |||
| 399 | const result = resolveComposerCommandSelection({ | 427 | const result = resolveComposerCommandSelection({ |
| 400 | draftMessage, | 428 | draftMessage, |
| 401 | trigger, | 429 | trigger, |
| ... | @@ -414,6 +442,7 @@ export function useComposerCommandMenu({ | ... | @@ -414,6 +442,7 @@ export function useComposerCommandMenu({ |
| 414 | draftMessage, | 442 | draftMessage, |
| 415 | onChangeDraftMessage, | 443 | onChangeDraftMessage, |
| 416 | onUpdateInteractionMode, | 444 | onUpdateInteractionMode, |
| 445 | onUsageLimits, | ||
| 417 | selectedProviderStatus?.showInteractionModeToggle, | 446 | selectedProviderStatus?.showInteractionModeToggle, |
| 418 | trigger, | 447 | trigger, |
| 419 | ], | 448 | ], |
apps/mobile/src/features/usage/UsageLimitsSection.tsx+32-10| ... | @@ -69,9 +69,9 @@ function WindowRow(props: { | ... | @@ -69,9 +69,9 @@ function WindowRow(props: { |
| 69 | <View | 69 | <View |
| 70 | className={ | 70 | className={ |
| 71 | used >= 90 | 71 | used >= 90 |
| 72 | ? "h-full rounded-full bg-destructive" | 72 | ? "h-full rounded-full bg-red-500" |
| 73 | : used >= 70 | 73 | : used >= 70 |
| 74 | ? "h-full rounded-full bg-warning" | 74 | ? "h-full rounded-full bg-amber-500" |
| 75 | : "h-full rounded-full bg-foreground" | 75 | : "h-full rounded-full bg-foreground" |
| 76 | } | 76 | } |
| 77 | style={[ | 77 | style={[ |
| ... | @@ -99,7 +99,7 @@ function WindowRow(props: { | ... | @@ -99,7 +99,7 @@ function WindowRow(props: { |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | /** One account: icon, name and plan on a single line, then its windows. */ | 101 | /** One account: icon, name and plan on a single line, then its windows. */ |
| 102 | function AccountLimits(props: { | 102 | export function AccountLimits(props: { |
| 103 | readonly driver: Driver; | 103 | readonly driver: Driver; |
| 104 | readonly label: string; | 104 | readonly label: string; |
| 105 | readonly instanceLabel: string; | 105 | readonly instanceLabel: string; |
| ... | @@ -107,14 +107,23 @@ function AccountLimits(props: { | ... | @@ -107,14 +107,23 @@ function AccountLimits(props: { |
| 107 | readonly limits: ServerProvider["usageLimits"]; | 107 | readonly limits: ServerProvider["usageLimits"]; |
| 108 | readonly now: number; | 108 | readonly now: number; |
| 109 | readonly first: boolean; | 109 | readonly first: boolean; |
| 110 | /** Tighter padding for the composer card. */ | ||
| 111 | readonly dense?: boolean; | ||
| 112 | /** Sits at the end of the heading row, such as a close control. */ | ||
| 113 | readonly trailing?: ReactNode; | ||
| 110 | readonly footer?: ReactNode; | 114 | readonly footer?: ReactNode; |
| 111 | }) { | 115 | }) { |
| 112 | const { limits, now } = props; | 116 | const { limits, now, dense = false } = props; |
| 113 | const color = useBarColor(props.driver); | 117 | const color = useBarColor(props.driver); |
| 114 | if (!limits) return null; | 118 | if (!limits) return null; |
| 115 | const notice = limitsNotice(limits); | 119 | const notice = limitsNotice(limits); |
| 120 | const padding = dense ? "px-4 py-3" : "p-4"; | ||
| 116 | return ( | 121 | return ( |
| 117 | <View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}> | 122 | <View |
| 123 | className={ | ||
| 124 | props.first ? `gap-3 ${padding}` : `gap-3 border-t border-border-subtle ${padding}` | ||
| 125 | } | ||
| 126 | > | ||
| 118 | <View className="flex-row items-center gap-2"> | 127 | <View className="flex-row items-center gap-2"> |
| 119 | <ProviderIcon provider={props.driver} size={16} /> | 128 | <ProviderIcon provider={props.driver} size={16} /> |
| 120 | <View className="min-w-0 flex-1 flex-row items-baseline gap-2"> | 129 | <View className="min-w-0 flex-1 flex-row items-baseline gap-2"> |
| ... | @@ -130,6 +139,7 @@ function AccountLimits(props: { | ... | @@ -130,6 +139,7 @@ function AccountLimits(props: { |
| 130 | </Text> | 139 | </Text> |
| 131 | ) : null} | 140 | ) : null} |
| 132 | </View> | 141 | </View> |
| 142 | {props.trailing} | ||
| 133 | </View> | 143 | </View> |
| 134 | {notice ? ( | 144 | {notice ? ( |
| 135 | <Text className="text-sm text-foreground-muted">{notice}</Text> | 145 | <Text className="text-sm text-foreground-muted">{notice}</Text> |
| ... | @@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { | ... | @@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 157 | * credit the provider granted the user, so it goes through the native | 167 | * credit the provider granted the user, so it goes through the native |
| 158 | * confirm alert rather than firing on a bare tap. | 168 | * confirm alert rather than firing on a bare tap. |
| 159 | */ | 169 | */ |
| 160 | function ResetCredits(props: { | 170 | export function ResetCredits(props: { |
| 161 | readonly environmentId: EnvironmentId; | 171 | readonly environmentId: EnvironmentId; |
| 162 | readonly instanceId: ProviderInstanceId; | 172 | readonly instanceId: ProviderInstanceId; |
| 163 | readonly credits: ServerProviderResetCredits; | 173 | readonly credits: ServerProviderResetCredits; |
| 164 | readonly now: number; | 174 | readonly now: number; |
| 175 | /** A smaller pill for the composer card. */ | ||
| 176 | readonly dense?: boolean; | ||
| 165 | }) { | 177 | }) { |
| 166 | const { environmentId, instanceId, credits, now } = props; | 178 | const { environmentId, instanceId, credits, now, dense = false } = props; |
| 167 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { | 179 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { |
| 168 | reportFailure: false, | 180 | reportFailure: false, |
| 169 | }); | 181 | }); |
| ... | @@ -217,10 +229,20 @@ function ResetCredits(props: { | ... | @@ -217,10 +229,20 @@ function ResetCredits(props: { |
| 217 | accessibilityState={{ disabled: busy }} | 229 | accessibilityState={{ disabled: busy }} |
| 218 | disabled={busy} | 230 | disabled={busy} |
| 219 | onPress={confirm} | 231 | onPress={confirm} |
| 220 | className="rounded-full bg-subtle-strong px-3 py-1.5" | 232 | className={ |
| 233 | dense | ||
| 234 | ? "rounded-full bg-subtle-strong px-2.5 py-1" | ||
| 235 | : "rounded-full bg-subtle-strong px-3 py-1.5" | ||
| 236 | } | ||
| 221 | > | 237 | > |
| 222 | <Text className="text-sm font-t3-medium text-foreground"> | 238 | <Text |
| 223 | {busy ? "Using credit…" : "Use a reset credit"} | 239 | className={ |
| 240 | dense | ||
| 241 | ? "text-xs font-t3-medium text-foreground" | ||
| 242 | : "text-sm font-t3-medium text-foreground" | ||
| 243 | } | ||
| 244 | > | ||
| 245 | {busy ? "Using…" : "Use reset"} | ||
| 224 | </Text> | 246 | </Text> |
| 225 | </Pressable> | 247 | </Pressable> |
| 226 | ) : null} | 248 | ) : null} |
apps/mobile/src/state/server.ts+1| ... | @@ -8,6 +8,7 @@ import { environmentSession } from "./session"; | ... | @@ -8,6 +8,7 @@ import { environmentSession } from "./session"; |
| 8 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { | 8 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { |
| 9 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, | 9 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, |
| 10 | usageLimitSources: true, | 10 | usageLimitSources: true, |
| 11 | usageLimitsCommand: true, | ||
| 11 | }); | 12 | }); |
| 12 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | 13 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 13 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 14 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
apps/server/src/server.test.ts+211-38| ... | @@ -39,6 +39,7 @@ import { | ... | @@ -39,6 +39,7 @@ import { |
| 39 | type ServerLifecycleStreamEvent, | 39 | type ServerLifecycleStreamEvent, |
| 40 | ThreadId, | 40 | ThreadId, |
| 41 | TurnId, | 41 | TurnId, |
| 42 | UsageLimitSourceId, | ||
| 42 | WS_METHODS, | 43 | WS_METHODS, |
| 43 | WsRpcGroup, | 44 | WsRpcGroup, |
| 44 | EditorId, | 45 | EditorId, |
| ... | @@ -498,6 +499,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -498,6 +499,7 @@ const buildAppUnderTest = (options?: { |
| 498 | keybindings?: Partial<Keybindings.Keybindings["Service"]>; | 499 | keybindings?: Partial<Keybindings.Keybindings["Service"]>; |
| 499 | environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>; | 500 | environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>; |
| 500 | providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>; | 501 | providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>; |
| 502 | usageLimitSources?: Partial<UsageLimitSources.UsageLimitSources["Service"]>; | ||
| 501 | providerService?: Partial<ProviderService.ProviderService["Service"]>; | 503 | providerService?: Partial<ProviderService.ProviderService["Service"]>; |
| 502 | providerAuth?: Partial<ProviderAuthService["Service"]>; | 504 | providerAuth?: Partial<ProviderAuthService["Service"]>; |
| 503 | providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>; | 505 | providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>; |
| ... | @@ -756,8 +758,9 @@ const buildAppUnderTest = (options?: { | ... | @@ -756,8 +758,9 @@ const buildAppUnderTest = (options?: { |
| 756 | }), | 758 | }), |
| 757 | Layer.mock(UsageLimitSources.UsageLimitSources)({ | 759 | Layer.mock(UsageLimitSources.UsageLimitSources)({ |
| 758 | current: Effect.succeed([]), | 760 | current: Effect.succeed([]), |
| 759 | streamChanges: Stream.empty, | 761 | streamChanges: Stream.make([]), |
| 760 | refresh: Effect.void, | 762 | refresh: Effect.void, |
| 763 | ...options?.layers?.usageLimitSources, | ||
| 761 | }), | 764 | }), |
| 762 | ), | 765 | ), |
| 763 | ), | 766 | ), |
| ... | @@ -6246,10 +6249,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -6246,10 +6249,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6246 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 6249 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 6247 | ); | 6250 | ); |
| 6248 | 6251 | ||
| 6249 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => | 6252 | it.effect.each([false, true])( |
| 6250 | Effect.gen(function* () { | 6253 | "routes websocket rpc subscribeServerConfig emits provider status updates (limits: %s)", |
| 6251 | const nextProviders = [ | 6254 | (hasLimits) => |
| 6252 | { | 6255 | Effect.gen(function* () { |
| 6256 | const nextProviders = [ | ||
| 6257 | { | ||
| 6258 | instanceId: ProviderInstanceId.make("codex"), | ||
| 6259 | driver: ProviderDriverKind.make("codex"), | ||
| 6260 | enabled: true, | ||
| 6261 | installed: true, | ||
| 6262 | version: "1.0.0", | ||
| 6263 | status: "ready" as const, | ||
| 6264 | auth: { status: "authenticated" as const }, | ||
| 6265 | checkedAt: "2026-04-11T00:00:00.000Z", | ||
| 6266 | models: [], | ||
| 6267 | slashCommands: [], | ||
| 6268 | skills: [], | ||
| 6269 | ...(hasLimits | ||
| 6270 | ? { | ||
| 6271 | usageLimits: { | ||
| 6272 | checkedAt: "2026-04-11T00:00:00.000Z", | ||
| 6273 | windows: [ | ||
| 6274 | { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, | ||
| 6275 | ], | ||
| 6276 | }, | ||
| 6277 | } | ||
| 6278 | : {}), | ||
| 6279 | }, | ||
| 6280 | ] as const; | ||
| 6281 | |||
| 6282 | yield* buildAppUnderTest({ | ||
| 6283 | layers: { | ||
| 6284 | keybindings: { | ||
| 6285 | loadConfigState: Effect.succeed({ | ||
| 6286 | keybindings: [], | ||
| 6287 | issues: [], | ||
| 6288 | }), | ||
| 6289 | streamChanges: Stream.empty, | ||
| 6290 | }, | ||
| 6291 | providerRegistry: { | ||
| 6292 | getProviders: Effect.succeed([]), | ||
| 6293 | streamChanges: Stream.succeed(nextProviders), | ||
| 6294 | }, | ||
| 6295 | }, | ||
| 6296 | }); | ||
| 6297 | |||
| 6298 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 6299 | const events = yield* Effect.scoped( | ||
| 6300 | withWsRpcClient(wsUrl, (client) => | ||
| 6301 | client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( | ||
| 6302 | Stream.take(2), | ||
| 6303 | Stream.runCollect, | ||
| 6304 | ), | ||
| 6305 | ), | ||
| 6306 | ); | ||
| 6307 | |||
| 6308 | const [first, second] = Array.from(events); | ||
| 6309 | assert.equal(first?.type, "snapshot"); | ||
| 6310 | if (first?.type === "snapshot") { | ||
| 6311 | assert.deepEqual(first.config.providers, []); | ||
| 6312 | } | ||
| 6313 | assert.deepEqual(second, { | ||
| 6314 | version: 1, | ||
| 6315 | type: "providerStatuses", | ||
| 6316 | payload: { | ||
| 6317 | providers: hasLimits | ||
| 6318 | ? [ | ||
| 6319 | { | ||
| 6320 | ...nextProviders[0], | ||
| 6321 | slashCommands: [ | ||
| 6322 | { | ||
| 6323 | name: "usage-limits", | ||
| 6324 | description: "Show this provider's usage limits", | ||
| 6325 | }, | ||
| 6326 | ], | ||
| 6327 | }, | ||
| 6328 | ] | ||
| 6329 | : nextProviders, | ||
| 6330 | }, | ||
| 6331 | }); | ||
| 6332 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 6333 | ); | ||
| 6334 | |||
| 6335 | it.effect( | ||
| 6336 | "routes websocket rpc subscribeServerConfig keeps the limits command from clients that do not ask for it", | ||
| 6337 | () => | ||
| 6338 | Effect.gen(function* () { | ||
| 6339 | const codex = { | ||
| 6253 | instanceId: ProviderInstanceId.make("codex"), | 6340 | instanceId: ProviderInstanceId.make("codex"), |
| 6254 | driver: ProviderDriverKind.make("codex"), | 6341 | driver: ProviderDriverKind.make("codex"), |
| 6255 | enabled: true, | 6342 | enabled: true, |
| ... | @@ -6261,43 +6348,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -6261,43 +6348,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6261 | models: [], | 6348 | models: [], |
| 6262 | slashCommands: [], | 6349 | slashCommands: [], |
| 6263 | skills: [], | 6350 | skills: [], |
| 6264 | }, | 6351 | usageLimits: { |
| 6265 | ] as const; | 6352 | checkedAt: "2026-04-11T00:00:00.000Z", |
| 6266 | 6353 | windows: [{ id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }], | |
| 6267 | yield* buildAppUnderTest({ | ||
| 6268 | layers: { | ||
| 6269 | keybindings: { | ||
| 6270 | loadConfigState: Effect.succeed({ | ||
| 6271 | keybindings: [], | ||
| 6272 | issues: [], | ||
| 6273 | }), | ||
| 6274 | streamChanges: Stream.empty, | ||
| 6275 | }, | 6354 | }, |
| 6276 | providerRegistry: { | 6355 | }; |
| 6277 | getProviders: Effect.succeed([]), | 6356 | yield* buildAppUnderTest({ |
| 6278 | streamChanges: Stream.succeed(nextProviders), | 6357 | layers: { |
| 6358 | keybindings: { | ||
| 6359 | loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), | ||
| 6360 | streamChanges: Stream.empty, | ||
| 6361 | }, | ||
| 6362 | providerRegistry: { | ||
| 6363 | getProviders: Effect.succeed([codex]), | ||
| 6364 | streamChanges: Stream.succeed([{ ...codex, version: "1.0.1" }]), | ||
| 6365 | }, | ||
| 6279 | }, | 6366 | }, |
| 6280 | }, | 6367 | }); |
| 6281 | }); | ||
| 6282 | 6368 | ||
| 6283 | const wsUrl = yield* getWsServerUrl("/ws"); | 6369 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 6284 | const events = yield* Effect.scoped( | 6370 | const events = yield* Effect.scoped( |
| 6285 | withWsRpcClient(wsUrl, (client) => | 6371 | withWsRpcClient(wsUrl, (client) => |
| 6286 | client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), | 6372 | client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), |
| 6287 | ), | 6373 | ), |
| 6288 | ); | 6374 | ); |
| 6289 | 6375 | ||
| 6290 | const [first, second] = Array.from(events); | 6376 | const [first, second] = Array.from(events); |
| 6291 | assert.equal(first?.type, "snapshot"); | 6377 | assert.equal(first?.type, "snapshot"); |
| 6292 | if (first?.type === "snapshot") { | 6378 | if (first?.type === "snapshot") { |
| 6293 | assert.deepEqual(first.config.providers, []); | 6379 | assert.deepEqual(first.config.providers, [codex]); |
| 6294 | } | 6380 | } |
| 6295 | assert.deepEqual(second, { | 6381 | assert.deepEqual(second, { |
| 6296 | version: 1, | 6382 | version: 1, |
| 6297 | type: "providerStatuses", | 6383 | type: "providerStatuses", |
| 6298 | payload: { providers: nextProviders }, | 6384 | payload: { providers: [{ ...codex, version: "1.0.1" }] }, |
| 6299 | }); | 6385 | }); |
| 6300 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 6386 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 6387 | ); | ||
| 6388 | |||
| 6389 | it.effect( | ||
| 6390 | "routes websocket rpc subscribeServerConfig republishes commands when only a limits source changes", | ||
| 6391 | () => | ||
| 6392 | Effect.gen(function* () { | ||
| 6393 | const codex = { | ||
| 6394 | instanceId: ProviderInstanceId.make("codex"), | ||
| 6395 | driver: ProviderDriverKind.make("codex"), | ||
| 6396 | enabled: true, | ||
| 6397 | installed: true, | ||
| 6398 | version: "1.0.0", | ||
| 6399 | status: "ready" as const, | ||
| 6400 | auth: { status: "authenticated" as const }, | ||
| 6401 | checkedAt: "2026-04-11T00:00:00.000Z", | ||
| 6402 | models: [], | ||
| 6403 | slashCommands: [], | ||
| 6404 | skills: [], | ||
| 6405 | }; | ||
| 6406 | const hub = { | ||
| 6407 | id: UsageLimitSourceId.make("hub"), | ||
| 6408 | kind: "cliproxy" as const, | ||
| 6409 | label: "Accounts", | ||
| 6410 | checkedAt: "2026-04-11T00:00:00.000Z", | ||
| 6411 | accounts: [ | ||
| 6412 | { | ||
| 6413 | id: "work", | ||
| 6414 | driver: ProviderDriverKind.make("codex"), | ||
| 6415 | usageLimits: { | ||
| 6416 | checkedAt: "2026-04-11T00:00:00.000Z", | ||
| 6417 | windows: [ | ||
| 6418 | { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, | ||
| 6419 | ], | ||
| 6420 | }, | ||
| 6421 | }, | ||
| 6422 | ], | ||
| 6423 | }; | ||
| 6424 | |||
| 6425 | yield* buildAppUnderTest({ | ||
| 6426 | layers: { | ||
| 6427 | keybindings: { | ||
| 6428 | loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), | ||
| 6429 | streamChanges: Stream.empty, | ||
| 6430 | }, | ||
| 6431 | // The registry emits no change: only the source refresh can carry it. | ||
| 6432 | providerRegistry: { | ||
| 6433 | getProviders: Effect.succeed([codex]), | ||
| 6434 | streamChanges: Stream.empty, | ||
| 6435 | }, | ||
| 6436 | usageLimitSources: { | ||
| 6437 | current: Effect.succeed([]), | ||
| 6438 | // Replay the empty snapshot, then a later refresh, as the live stream does. | ||
| 6439 | streamChanges: Stream.concat(Stream.make([]), Stream.make([hub])), | ||
| 6440 | }, | ||
| 6441 | }, | ||
| 6442 | }); | ||
| 6443 | |||
| 6444 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 6445 | const events = yield* Effect.scoped( | ||
| 6446 | withWsRpcClient(wsUrl, (client) => | ||
| 6447 | client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( | ||
| 6448 | Stream.take(2), | ||
| 6449 | Stream.runCollect, | ||
| 6450 | ), | ||
| 6451 | ), | ||
| 6452 | ); | ||
| 6453 | |||
| 6454 | const [first, second] = Array.from(events); | ||
| 6455 | assert.equal(first?.type, "snapshot"); | ||
| 6456 | if (first?.type === "snapshot") { | ||
| 6457 | assert.deepEqual(first.config.providers, [codex]); | ||
| 6458 | } | ||
| 6459 | assert.deepEqual(second, { | ||
| 6460 | version: 1, | ||
| 6461 | type: "providerStatuses", | ||
| 6462 | payload: { | ||
| 6463 | providers: [ | ||
| 6464 | { | ||
| 6465 | ...codex, | ||
| 6466 | slashCommands: [ | ||
| 6467 | { name: "usage-limits", description: "Show this provider's usage limits" }, | ||
| 6468 | ], | ||
| 6469 | }, | ||
| 6470 | ], | ||
| 6471 | }, | ||
| 6472 | }); | ||
| 6473 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 6301 | ); | 6474 | ); |
| 6302 | 6475 | ||
| 6303 | it.effect( | 6476 | it.effect( |
apps/server/src/ws.ts+102-62| ... | @@ -1,3 +1,7 @@ | ... | @@ -1,3 +1,7 @@ |
| 1 | import { | ||
| 2 | sameUsageLimitCommandCoverage, | ||
| 3 | withUsageLimitsCommands, | ||
| 4 | } from "@t3tools/shared/usageLimits"; | ||
| 1 | import * as Cause from "effect/Cause"; | 5 | import * as Cause from "effect/Cause"; |
| 2 | import * as Crypto from "effect/Crypto"; | 6 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as DateTime from "effect/DateTime"; | 7 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -1221,59 +1225,67 @@ const makeWsRpcLayer = ( | ... | @@ -1221,59 +1225,67 @@ const makeWsRpcLayer = ( |
| 1221 | ); | 1225 | ); |
| 1222 | }; | 1226 | }; |
| 1223 | 1227 | ||
| 1224 | const loadServerConfig = Effect.gen(function* () { | 1228 | // Only clients that answer /usage-limits themselves see it in the catalogs; |
| 1225 | const keybindingsConfig = yield* keybindings.loadConfigState; | 1229 | // an older client would send the injected command to the provider. |
| 1226 | const providers = yield* providerRegistry.getProviders; | 1230 | const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => |
| 1227 | const settings = ServerSettings.redactServerSettingsForClient( | 1231 | Effect.gen(function* () { |
| 1228 | yield* serverSettings.getSettings, | 1232 | const keybindingsConfig = yield* keybindings.loadConfigState; |
| 1229 | ); | 1233 | const currentProviders = yield* providerRegistry.getProviders; |
| 1230 | const environment = yield* serverEnvironment.getDescriptor; | 1234 | const providers = options.usageLimitsCommand |
| 1231 | const auth = yield* serverAuth.getDescriptor(); | 1235 | ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) |
| 1232 | const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig( | 1236 | : currentProviders; |
| 1233 | externalLauncher.resolveAvailableEditors(), | 1237 | const settings = ServerSettings.redactServerSettingsForClient( |
| 1234 | ); | 1238 | yield* serverSettings.getSettings, |
| 1235 | const fileManagerRevealKind = availableEditors.includes("file-manager") | 1239 | ); |
| 1236 | ? yield* resolveFileManagerRevealKindForConfig( | 1240 | const environment = yield* serverEnvironment.getDescriptor; |
| 1237 | externalLauncher.resolveFileManagerRevealKind(), | 1241 | const auth = yield* serverAuth.getDescriptor(); |
| 1238 | ) | 1242 | const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig( |
| 1239 | : undefined; | 1243 | externalLauncher.resolveAvailableEditors(), |
| 1240 | 1244 | ); | |
| 1241 | return { | 1245 | const fileManagerRevealKind = availableEditors.includes("file-manager") |
| 1242 | environment, | 1246 | ? yield* resolveFileManagerRevealKindForConfig( |
| 1243 | auth, | 1247 | externalLauncher.resolveFileManagerRevealKind(), |
| 1244 | cwd: config.cwd, | 1248 | ) |
| 1245 | keybindingsConfigPath: config.keybindingsConfigPath, | 1249 | : undefined; |
| 1246 | keybindings: keybindingsConfig.keybindings, | 1250 | |
| 1247 | issues: keybindingsConfig.issues, | 1251 | return { |
| 1248 | providers, | 1252 | environment, |
| 1249 | availableEditors, | 1253 | auth, |
| 1250 | // Same discovery-with-timeout treatment as editors: a slow probe | 1254 | cwd: config.cwd, |
| 1251 | // must not stall server.getConfig, so it degrades to no targets. | 1255 | keybindingsConfigPath: config.keybindingsConfigPath, |
| 1252 | remoteOpenTargets: yield* resolveAvailableEditorsForConfig( | 1256 | keybindings: keybindingsConfig.keybindings, |
| 1253 | remoteOpenTargets.resolveTargets(), | 1257 | issues: keybindingsConfig.issues, |
| 1254 | ), | 1258 | providers, |
| 1255 | observability: { | 1259 | availableEditors, |
| 1256 | logsDirectoryPath: config.logsDir, | 1260 | // Same discovery-with-timeout treatment as editors: a slow probe |
| 1257 | localTracingEnabled: true, | 1261 | // must not stall server.getConfig, so it degrades to no targets. |
| 1258 | ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), | 1262 | remoteOpenTargets: yield* resolveAvailableEditorsForConfig( |
| 1259 | otlpTracesEnabled: config.otlpTracesUrl !== undefined, | 1263 | remoteOpenTargets.resolveTargets(), |
| 1260 | ...(config.otlpMetricsUrl !== undefined | 1264 | ), |
| 1261 | ? { otlpMetricsUrl: config.otlpMetricsUrl } | 1265 | observability: { |
| 1262 | : {}), | 1266 | logsDirectoryPath: config.logsDir, |
| 1263 | otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, | 1267 | localTracingEnabled: true, |
| 1264 | }, | 1268 | ...(config.otlpTracesUrl !== undefined |
| 1265 | settings, | 1269 | ? { otlpTracesUrl: config.otlpTracesUrl } |
| 1266 | shellResumeCompletionMarker: true, | 1270 | : {}), |
| 1267 | ...(fileManagerRevealKind === undefined | 1271 | otlpTracesEnabled: config.otlpTracesUrl !== undefined, |
| 1268 | ? {} | 1272 | ...(config.otlpMetricsUrl !== undefined |
| 1269 | : { | 1273 | ? { otlpMetricsUrl: config.otlpMetricsUrl } |
| 1270 | shellRevealInFileManager: true, | 1274 | : {}), |
| 1271 | shellRevealInFileManagerKind: fileManagerRevealKind, | 1275 | otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, |
| 1272 | }), | 1276 | }, |
| 1273 | threadResumeCompletionMarker: true, | 1277 | settings, |
| 1274 | threadSnapshotPagination: true, | 1278 | shellResumeCompletionMarker: true, |
| 1275 | }; | 1279 | ...(fileManagerRevealKind === undefined |
| 1276 | }); | 1280 | ? {} |
| 1281 | : { | ||
| 1282 | shellRevealInFileManager: true, | ||
| 1283 | shellRevealInFileManagerKind: fileManagerRevealKind, | ||
| 1284 | }), | ||
| 1285 | threadResumeCompletionMarker: true, | ||
| 1286 | threadSnapshotPagination: true, | ||
| 1287 | }; | ||
| 1288 | }); | ||
| 1277 | 1289 | ||
| 1278 | const refreshGitStatus = (cwd: string) => | 1290 | const refreshGitStatus = (cwd: string) => |
| 1279 | vcsStatusBroadcaster | 1291 | vcsStatusBroadcaster |
| ... | @@ -1750,9 +1762,13 @@ const makeWsRpcLayer = ( | ... | @@ -1750,9 +1762,13 @@ const makeWsRpcLayer = ( |
| 1750 | "rpc.aggregate": "server", | 1762 | "rpc.aggregate": "server", |
| 1751 | }), | 1763 | }), |
| 1752 | [WS_METHODS.serverGetConfig]: (_input) => | 1764 | [WS_METHODS.serverGetConfig]: (_input) => |
| 1753 | observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { | 1765 | observeRpcEffect( |
| 1754 | "rpc.aggregate": "server", | 1766 | WS_METHODS.serverGetConfig, |
| 1755 | }), | 1767 | loadServerConfig({ usageLimitsCommand: false }), |
| 1768 | { | ||
| 1769 | "rpc.aggregate": "server", | ||
| 1770 | }, | ||
| 1771 | ), | ||
| 1756 | [WS_METHODS.serverRefreshProviders]: (input) => | 1772 | [WS_METHODS.serverRefreshProviders]: (input) => |
| 1757 | observeRpcEffect( | 1773 | observeRpcEffect( |
| 1758 | WS_METHODS.serverRefreshProviders, | 1774 | WS_METHODS.serverRefreshProviders, |
| ... | @@ -2692,6 +2708,8 @@ const makeWsRpcLayer = ( | ... | @@ -2692,6 +2708,8 @@ const makeWsRpcLayer = ( |
| 2692 | observeRpcStreamEffect( | 2708 | observeRpcStreamEffect( |
| 2693 | WS_METHODS.subscribeServerConfig, | 2709 | WS_METHODS.subscribeServerConfig, |
| 2694 | Effect.gen(function* () { | 2710 | Effect.gen(function* () { |
| 2711 | const usageLimitsCommand = input.usageLimitsCommand === true; | ||
| 2712 | const config = yield* loadServerConfig({ usageLimitsCommand }); | ||
| 2695 | const keybindingsUpdates = keybindings.streamChanges.pipe( | 2713 | const keybindingsUpdates = keybindings.streamChanges.pipe( |
| 2696 | Stream.map((event) => ({ | 2714 | Stream.map((event) => ({ |
| 2697 | version: 1 as const, | 2715 | version: 1 as const, |
| ... | @@ -2702,7 +2720,33 @@ const makeWsRpcLayer = ( | ... | @@ -2702,7 +2720,33 @@ const makeWsRpcLayer = ( |
| 2702 | }, | 2720 | }, |
| 2703 | })), | 2721 | })), |
| 2704 | ); | 2722 | ); |
| 2705 | const providerStatuses = providerRegistry.streamChanges.pipe( | 2723 | const providerStatuses = Stream.zipLatestWith( |
| 2724 | // The registry stream carries changes only. Seed it with the current | ||
| 2725 | // providers so a source refresh that lands before any provider change | ||
| 2726 | // still pairs up and reaches the client. | ||
| 2727 | Stream.concat( | ||
| 2728 | Stream.fromEffect(providerRegistry.getProviders), | ||
| 2729 | providerRegistry.streamChanges, | ||
| 2730 | ), | ||
| 2731 | usageLimitSources.streamChanges.pipe( | ||
| 2732 | // Quota updates already have their own stream. Republish the model | ||
| 2733 | // catalog only when the set of providers offered the command changes. | ||
| 2734 | Stream.changesWith( | ||
| 2735 | usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, | ||
| 2736 | ), | ||
| 2737 | ), | ||
| 2738 | (providers, sources) => | ||
| 2739 | usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, | ||
| 2740 | ).pipe( | ||
| 2741 | // Both sides replay their current value, so the first pairing normally | ||
| 2742 | // repeats the snapshot the client already holds. Compare against that | ||
| 2743 | // snapshot rather than dropping blindly: a refresh that landed between | ||
| 2744 | // the snapshot and the subscription still goes out. | ||
| 2745 | (updates) => Stream.concat(Stream.make(config.providers), updates), | ||
| 2746 | Stream.changesWith( | ||
| 2747 | (previous, next) => JSON.stringify(previous) === JSON.stringify(next), | ||
| 2748 | ), | ||
| 2749 | Stream.drop(1), | ||
| 2706 | Stream.map((providers) => ({ | 2750 | Stream.map((providers) => ({ |
| 2707 | version: 1 as const, | 2751 | version: 1 as const, |
| 2708 | type: "providerStatuses" as const, | 2752 | type: "providerStatuses" as const, |
| ... | @@ -2763,11 +2807,7 @@ const makeWsRpcLayer = ( | ... | @@ -2763,11 +2807,7 @@ const makeWsRpcLayer = ( |
| 2763 | ); | 2807 | ); |
| 2764 | 2808 | ||
| 2765 | return Stream.concat( | 2809 | return Stream.concat( |
| 2766 | Stream.make({ | 2810 | Stream.make({ version: 1 as const, type: "snapshot" as const, config }), |
| 2767 | version: 1 as const, | ||
| 2768 | type: "snapshot" as const, | ||
| 2769 | config: yield* loadServerConfig, | ||
| 2770 | }), | ||
| 2771 | liveUpdates, | 2811 | liveUpdates, |
| 2772 | ); | 2812 | ); |
| 2773 | }), | 2813 | }), |
apps/web/src/components/ChatView.tsx+156| ... | @@ -1,3 +1,10 @@ | ... | @@ -1,3 +1,10 @@ |
| 1 | import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | collectProviderUsageLimits, | ||
| 4 | hasProviderUsageLimits, | ||
| 5 | isUsageLimitsCommand, | ||
| 6 | } from "@t3tools/shared/usageLimits"; | ||
| 7 | import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; | ||
| 1 | import { | 8 | import { |
| 2 | type AssistantCitation, | 9 | type AssistantCitation, |
| 3 | type ApprovalRequestId, | 10 | type ApprovalRequestId, |
| ... | @@ -451,6 +458,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; | ... | @@ -451,6 +458,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; |
| 451 | 458 | ||
| 452 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 459 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 453 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 460 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 461 | const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; | ||
| 454 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | 462 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 455 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 463 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 456 | function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { | 464 | function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { |
| ... | @@ -1505,6 +1513,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1505,6 +1513,11 @@ export default function ChatView(props: ChatViewProps) { |
| 1505 | const draft = store.getComposerDraft(composerDraftTarget); | 1513 | const draft = store.getComposerDraft(composerDraftTarget); |
| 1506 | return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; | 1514 | return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; |
| 1507 | }); | 1515 | }); |
| 1516 | // Anything beyond the prompt text: attachments, terminal or element contexts, annotations. | ||
| 1517 | const composerHasNonPromptContent = useComposerDraftStore((store) => { | ||
| 1518 | const draft = store.getComposerDraft(composerDraftTarget); | ||
| 1519 | return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; | ||
| 1520 | }); | ||
| 1508 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | 1521 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 1509 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); | 1522 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 1510 | const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); | 1523 | const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); |
| ... | @@ -2620,6 +2633,111 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2620,6 +2633,111 @@ export default function ChatView(props: ChatViewProps) { |
| 2620 | hasComposerAttachments: composerHasAttachments, | 2633 | hasComposerAttachments: composerHasAttachments, |
| 2621 | }); | 2634 | }); |
| 2622 | const activePendingApproval = pendingApprovals[0] ?? null; | 2635 | const activePendingApproval = pendingApprovals[0] ?? null; |
| 2636 | // The open /usage-limits panel for this thread, model and turn. Only the open | ||
| 2637 | // moment is stored: the rows read live provider data, so a redeemed reset | ||
| 2638 | // credit or refreshed probe shows through. Anything that spends quota closes | ||
| 2639 | // it: a new turn from any source, or the agent resuming after an approval or | ||
| 2640 | // answered question. | ||
| 2641 | const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ | ||
| 2642 | readonly key: string; | ||
| 2643 | readonly threadKey: string; | ||
| 2644 | readonly now: number; | ||
| 2645 | } | null>(null); | ||
| 2646 | // Null while the provider list or the thread itself is unavailable, such as | ||
| 2647 | // during a reconnect; the panel then stays hidden rather than being dropped. | ||
| 2648 | // A pending approval or question is part of the key: once it is answered, | ||
| 2649 | // from this client or any other, the agent resumes and spends quota. | ||
| 2650 | const usageLimitsKey = | ||
| 2651 | activeProviderInstanceId === null || (isServerThread && activeThread === undefined) | ||
| 2652 | ? null | ||
| 2653 | : [ | ||
| 2654 | routeThreadKey, | ||
| 2655 | activeProviderInstanceId, | ||
| 2656 | activeThread?.latestTurn?.turnId ?? "", | ||
| 2657 | activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? "", | ||
| 2658 | ].join(":"); | ||
| 2659 | // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. | ||
| 2660 | if ( | ||
| 2661 | usageLimitsPanel !== null && | ||
| 2662 | usageLimitsKey !== null && | ||
| 2663 | usageLimitsPanel.key !== usageLimitsKey | ||
| 2664 | ) { | ||
| 2665 | setUsageLimitsPanel(null); | ||
| 2666 | } | ||
| 2667 | const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES; | ||
| 2668 | const usageLimitsReport = useMemo( | ||
| 2669 | () => | ||
| 2670 | usageLimitsPanel !== null && | ||
| 2671 | usageLimitsKey !== null && | ||
| 2672 | usageLimitsPanel.key === usageLimitsKey && | ||
| 2673 | activeProviderInstanceId !== null | ||
| 2674 | ? collectProviderUsageLimits( | ||
| 2675 | activeProviderInstanceId, | ||
| 2676 | providerStatuses, | ||
| 2677 | usageLimitSources, | ||
| 2678 | usageLimitsPanel.now, | ||
| 2679 | ) | ||
| 2680 | : null, | ||
| 2681 | [ | ||
| 2682 | activeProviderInstanceId, | ||
| 2683 | providerStatuses, | ||
| 2684 | usageLimitSources, | ||
| 2685 | usageLimitsKey, | ||
| 2686 | usageLimitsPanel, | ||
| 2687 | ], | ||
| 2688 | ); | ||
| 2689 | const usageLimitsBanner = useMemo( | ||
| 2690 | () => | ||
| 2691 | usageLimitsReport !== null && usageLimitsPanel !== null | ||
| 2692 | ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting". | ||
| 2693 | usageLimitsBannerItem( | ||
| 2694 | `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`, | ||
| 2695 | usageLimitsReport, | ||
| 2696 | environmentId, | ||
| 2697 | () => setUsageLimitsPanel(null), | ||
| 2698 | ) | ||
| 2699 | : null, | ||
| 2700 | [environmentId, usageLimitsPanel, usageLimitsReport], | ||
| 2701 | ); | ||
| 2702 | // T3 owns /usage-limits only where Limits has data for the selected provider; | ||
| 2703 | // elsewhere the name stays the provider's own and is sent through untouched. | ||
| 2704 | const usageLimitsOffered = | ||
| 2705 | activeProviderStatus !== null && | ||
| 2706 | hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources); | ||
| 2707 | // Answered locally from the last Limits snapshot; the agent never sees it. | ||
| 2708 | const openUsageLimits = useCallback(() => { | ||
| 2709 | const now = Date.now(); | ||
| 2710 | const report = | ||
| 2711 | activeProviderInstanceId !== null && usageLimitsKey !== null | ||
| 2712 | ? collectProviderUsageLimits( | ||
| 2713 | activeProviderInstanceId, | ||
| 2714 | providerStatuses, | ||
| 2715 | usageLimitSources, | ||
| 2716 | now, | ||
| 2717 | ) | ||
| 2718 | : null; | ||
| 2719 | if (report && usageLimitsKey !== null) { | ||
| 2720 | setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now }); | ||
| 2721 | return true; | ||
| 2722 | } | ||
| 2723 | setUsageLimitsPanel(null); | ||
| 2724 | toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); | ||
| 2725 | return false; | ||
| 2726 | }, [ | ||
| 2727 | activeProviderInstanceId, | ||
| 2728 | providerStatuses, | ||
| 2729 | routeThreadKey, | ||
| 2730 | usageLimitSources, | ||
| 2731 | usageLimitsKey, | ||
| 2732 | ]); | ||
| 2733 | // Responses can resolve after navigating away; only the originating thread's panel clears. | ||
| 2734 | const clearUsageLimitsFor = useCallback( | ||
| 2735 | (threadKey: string) => | ||
| 2736 | setUsageLimitsPanel((current) => | ||
| 2737 | current !== null && current.threadKey === threadKey ? null : current, | ||
| 2738 | ), | ||
| 2739 | [], | ||
| 2740 | ); | ||
| 2623 | const { | 2741 | const { |
| 2624 | beginLocalDispatch, | 2742 | beginLocalDispatch, |
| 2625 | resetLocalDispatch, | 2743 | resetLocalDispatch, |
| ... | @@ -5599,8 +5717,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -5599,8 +5717,11 @@ export default function ChatView(props: ChatViewProps) { |
| 5599 | resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; | 5717 | resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; |
| 5600 | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; | 5718 | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; |
| 5601 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; | 5719 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 5720 | // The user asked for this one, so it leads the notice tier instead of trailing it. | ||
| 5721 | const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; | ||
| 5602 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { | 5722 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 5603 | return [ | 5723 | return [ |
| 5724 | ...usageLimitsItems, | ||
| 5604 | ...systemComposerBannerItems, | 5725 | ...systemComposerBannerItems, |
| 5605 | ...backgroundLivenessItems, | 5726 | ...backgroundLivenessItems, |
| 5606 | ...resumeCompactionItems, | 5727 | ...resumeCompactionItems, |
| ... | @@ -5609,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -5609,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5609 | ]; | 5730 | ]; |
| 5610 | } | 5731 | } |
| 5611 | return [ | 5732 | return [ |
| 5733 | ...usageLimitsItems, | ||
| 5612 | ...systemComposerBannerItems, | 5734 | ...systemComposerBannerItems, |
| 5613 | ...backgroundLivenessItems, | 5735 | ...backgroundLivenessItems, |
| 5614 | ...resumeCompactionItems, | 5736 | ...resumeCompactionItems, |
| ... | @@ -5663,6 +5785,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -5663,6 +5785,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5663 | resumeCompactionBannerItem, | 5785 | resumeCompactionBannerItem, |
| 5664 | showBranchMismatchBanner, | 5786 | showBranchMismatchBanner, |
| 5665 | systemComposerBannerItems, | 5787 | systemComposerBannerItems, |
| 5788 | usageLimitsBanner, | ||
| 5666 | wokeThreadBannerItem, | 5789 | wokeThreadBannerItem, |
| 5667 | ]); | 5790 | ]); |
| 5668 | useEffect(() => { | 5791 | useEffect(() => { |
| ... | @@ -6076,6 +6199,23 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6076,6 +6199,23 @@ export default function ChatView(props: ChatViewProps) { |
| 6076 | }, | 6199 | }, |
| 6077 | ) => { | 6200 | ) => { |
| 6078 | e?.preventDefault(); | 6201 | e?.preventDefault(); |
| 6202 | // Typed out in full rather than picked from the menu. Attachments or contexts | ||
| 6203 | // mean the user is sending a prompt, so those go through as usual. | ||
| 6204 | if ( | ||
| 6205 | usageLimitsOffered && | ||
| 6206 | usageLimitsKey !== null && | ||
| 6207 | !directAnnotation && | ||
| 6208 | !composerHasNonPromptContent && | ||
| 6209 | isUsageLimitsCommand(promptRef.current) | ||
| 6210 | ) { | ||
| 6211 | if (openUsageLimits()) { | ||
| 6212 | promptRef.current = ""; | ||
| 6213 | setComposerDraftPrompt(composerDraftTarget, ""); | ||
| 6214 | composerRef.current?.resetCursorState(); | ||
| 6215 | } | ||
| 6216 | return; | ||
| 6217 | } | ||
| 6218 | |||
| 6079 | const notifyDirectAnnotationAttached = () => { | 6219 | const notifyDirectAnnotationAttached = () => { |
| 6080 | if (!directAnnotation) return; | 6220 | if (!directAnnotation) return; |
| 6081 | toastManager.add( | 6221 | toastManager.add( |
| ... | @@ -6718,6 +6858,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6718,6 +6858,10 @@ export default function ChatView(props: ChatViewProps) { |
| 6718 | failure = startResult; | 6858 | failure = startResult; |
| 6719 | } else { | 6859 | } else { |
| 6720 | turnStartSucceeded = true; | 6860 | turnStartSucceeded = true; |
| 6861 | // The turn is under way and will spend quota, so that thread's limits | ||
| 6862 | // snapshot is stale. Uploads may have outlasted a navigation, so only | ||
| 6863 | // the sending thread's panel clears. | ||
| 6864 | clearUsageLimitsFor(routeThreadKey); | ||
| 6721 | if (turnUsesAttachmentUploads) { | 6865 | if (turnUsesAttachmentUploads) { |
| 6722 | releaseDraftAttachments(composerAttachmentsSnapshot); | 6866 | releaseDraftAttachments(composerAttachmentsSnapshot); |
| 6723 | } | 6867 | } |
| ... | @@ -7138,6 +7282,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7138,6 +7282,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7138 | } | 7282 | } |
| 7139 | 7283 | ||
| 7140 | if (failure === null) { | 7284 | if (failure === null) { |
| 7285 | clearUsageLimitsFor(routeThreadKey); | ||
| 7141 | acknowledgeActiveThreadWoke(); | 7286 | acknowledgeActiveThreadWoke(); |
| 7142 | sendInFlightRef.current = false; | 7287 | sendInFlightRef.current = false; |
| 7143 | return; | 7288 | return; |
| ... | @@ -7174,6 +7319,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7174,6 +7319,8 @@ export default function ChatView(props: ChatViewProps) { |
| 7174 | startThreadTurn, | 7319 | startThreadTurn, |
| 7175 | environmentId, | 7320 | environmentId, |
| 7176 | composerRef, | 7321 | composerRef, |
| 7322 | clearUsageLimitsFor, | ||
| 7323 | routeThreadKey, | ||
| 7177 | ], | 7324 | ], |
| 7178 | ); | 7325 | ); |
| 7179 | 7326 | ||
| ... | @@ -7949,6 +8096,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7949,6 +8096,15 @@ export default function ChatView(props: ChatViewProps) { |
| 7949 | } | 8096 | } |
| 7950 | isPreparingWorktree={isPreparingWorktree} | 8097 | isPreparingWorktree={isPreparingWorktree} |
| 7951 | bannerItems={composerBannerItems} | 8098 | bannerItems={composerBannerItems} |
| 8099 | // With attachments or contexts aboard the pick just inserts the | ||
| 8100 | // text, so it sends as a prompt like the typed path would. | ||
| 8101 | onUsageLimitsCommand={ | ||
| 8102 | usageLimitsOffered && | ||
| 8103 | usageLimitsKey !== null && | ||
| 8104 | !composerHasNonPromptContent | ||
| 8105 | ? openUsageLimits | ||
| 8106 | : undefined | ||
| 8107 | } | ||
| 7952 | environmentUnavailable={activeEnvironmentUnavailableState} | 8108 | environmentUnavailable={activeEnvironmentUnavailableState} |
| 7953 | activePendingApproval={activePendingApproval} | 8109 | activePendingApproval={activePendingApproval} |
| 7954 | pendingApprovals={pendingApprovals} | 8110 | pendingApprovals={pendingApprovals} |
apps/web/src/components/chat/ChatComposer.tsx+16| ... | @@ -22,6 +22,7 @@ import { | ... | @@ -22,6 +22,7 @@ import { |
| 22 | import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; | 22 | import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 23 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | 23 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 24 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 24 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 25 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; | ||
| 25 | import { | 26 | import { |
| 26 | Fragment, | 27 | Fragment, |
| 27 | memo, | 28 | memo, |
| ... | @@ -1191,6 +1192,8 @@ export interface ChatComposerProps { | ... | @@ -1191,6 +1192,8 @@ export interface ChatComposerProps { |
| 1191 | sendDisabledReason: string | null; | 1192 | sendDisabledReason: string | null; |
| 1192 | isPreparingWorktree: boolean; | 1193 | isPreparingWorktree: boolean; |
| 1193 | bannerItems: readonly ComposerBannerStackItem[]; | 1194 | bannerItems: readonly ComposerBannerStackItem[]; |
| 1195 | /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ | ||
| 1196 | onUsageLimitsCommand?: (() => void) | undefined; | ||
| 1194 | environmentUnavailable: { | 1197 | environmentUnavailable: { |
| 1195 | readonly label: string; | 1198 | readonly label: string; |
| 1196 | readonly connection: EnvironmentConnectionPresentation; | 1199 | readonly connection: EnvironmentConnectionPresentation; |
| ... | @@ -2684,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2684,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2684 | }; | 2687 | }; |
| 2685 | }, [readComposerSnapshot]); | 2688 | }, [readComposerSnapshot]); |
| 2686 | 2689 | ||
| 2690 | const { onUsageLimitsCommand } = props; | ||
| 2687 | const onSelectComposerItem = useCallback( | 2691 | const onSelectComposerItem = useCallback( |
| 2688 | (item: ComposerCommandItem) => { | 2692 | (item: ComposerCommandItem) => { |
| 2689 | if (composerSelectLockRef.current) return; | 2693 | if (composerSelectLockRef.current) return; |
| ... | @@ -2734,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2734,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2734 | return; | 2738 | return; |
| 2735 | } | 2739 | } |
| 2736 | if (item.type === "provider-slash-command") { | 2740 | if (item.type === "provider-slash-command") { |
| 2741 | if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { | ||
| 2742 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | ||
| 2743 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | ||
| 2744 | focusEditorAfterReplace: false, | ||
| 2745 | }); | ||
| 2746 | if (applied) { | ||
| 2747 | setComposerHighlightedItemId(null); | ||
| 2748 | onUsageLimitsCommand(); | ||
| 2749 | } | ||
| 2750 | return; | ||
| 2751 | } | ||
| 2737 | const replacement = `/${item.command.name} `; | 2752 | const replacement = `/${item.command.name} `; |
| 2738 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | 2753 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| 2739 | snapshot.value, | 2754 | snapshot.value, |
| ... | @@ -2774,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2774,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2774 | applyPromptReplacement, | 2789 | applyPromptReplacement, |
| 2775 | handleInteractionModeChange, | 2790 | handleInteractionModeChange, |
| 2776 | planModeUiEnabled, | 2791 | planModeUiEnabled, |
| 2792 | onUsageLimitsCommand, | ||
| 2777 | resolveActiveComposerTrigger, | 2793 | resolveActiveComposerTrigger, |
| 2778 | ], | 2794 | ], |
| 2779 | ); | 2795 | ); |
apps/web/src/components/chat/ComposerUsageLimits.tsx created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; | ||
| 2 | import { limitsNotice } from "@t3tools/shared/usageLimits"; | ||
| 3 | import { GaugeIcon } from "lucide-react"; | ||
| 4 | |||
| 5 | import { getDriverOption } from "../settings/providerDriverMeta"; | ||
| 6 | import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; | ||
| 7 | import { ComposerBanner } from "./ComposerBanner"; | ||
| 8 | import type { ComposerBannerStackItem } from "./ComposerBannerStack"; | ||
| 9 | |||
| 10 | /** Driver name, then the instance when there could be more than one of that driver. */ | ||
| 11 | function accountLabel(account: UsageLimitsReport["accounts"][number]): string { | ||
| 12 | if (!account.instanceId) return account.label; | ||
| 13 | const driver = getDriverOption(account.driver)?.label ?? String(account.driver); | ||
| 14 | const instance = | ||
| 15 | account.displayName?.trim() || | ||
| 16 | (String(account.instanceId) !== String(account.driver) ? account.instanceId : ""); | ||
| 17 | // The default instance is often named after its driver; saying it twice adds nothing. | ||
| 18 | return instance && instance.toLowerCase() !== driver.toLowerCase() | ||
| 19 | ? `${driver} · ${instance}` | ||
| 20 | : driver; | ||
| 21 | } | ||
| 22 | |||
| 23 | /** The /usage-limits result as a composer notice: it stacks under warnings and dismisses like one. */ | ||
| 24 | export function usageLimitsBannerItem( | ||
| 25 | id: string, | ||
| 26 | report: UsageLimitsReport, | ||
| 27 | environmentId: EnvironmentId, | ||
| 28 | onDismiss: () => void, | ||
| 29 | ): ComposerBannerStackItem { | ||
| 30 | const [first] = report.accounts; | ||
| 31 | const single = report.accounts.length === 1 && first ? first : null; | ||
| 32 | const summary = single | ||
| 33 | ? [accountLabel(single), single.plan].filter(Boolean).join(" · ") | ||
| 34 | : `${report.accounts.length} accounts`; | ||
| 35 | return { | ||
| 36 | id, | ||
| 37 | variant: "info", | ||
| 38 | priority: "notice", | ||
| 39 | icon: <GaugeIcon />, | ||
| 40 | title: "Usage limits", | ||
| 41 | description: summary, | ||
| 42 | dismissLabel: "Dismiss usage limits", | ||
| 43 | onDismiss, | ||
| 44 | children: <UsageLimitsBannerBody report={report} environmentId={environmentId} />, | ||
| 45 | }; | ||
| 46 | } | ||
| 47 | |||
| 48 | function UsageLimitsBannerBody({ | ||
| 49 | report, | ||
| 50 | environmentId, | ||
| 51 | }: { | ||
| 52 | readonly report: UsageLimitsReport; | ||
| 53 | readonly environmentId: EnvironmentId; | ||
| 54 | }) { | ||
| 55 | const now = Date.parse(report.createdAt); | ||
| 56 | return ( | ||
| 57 | <ComposerBanner.Scroll> | ||
| 58 | <ComposerBanner.Body className="flex flex-col gap-2 pt-1 pb-1.5 pe-2"> | ||
| 59 | {report.accounts.map((account) => { | ||
| 60 | const notice = limitsNotice(account.limits); | ||
| 61 | return ( | ||
| 62 | <div key={account.id} className="flex min-w-0 flex-col gap-1"> | ||
| 63 | {report.accounts.length > 1 ? ( | ||
| 64 | <span className="truncate text-xs text-muted-foreground"> | ||
| 65 | {[accountLabel(account), account.plan].filter(Boolean).join(" · ")} | ||
| 66 | </span> | ||
| 67 | ) : null} | ||
| 68 | {notice ? ( | ||
| 69 | <span className="text-xs text-muted-foreground">{notice}</span> | ||
| 70 | ) : ( | ||
| 71 | <LimitWindows | ||
| 72 | compact | ||
| 73 | driver={account.driver} | ||
| 74 | windows={account.limits.windows} | ||
| 75 | now={now} | ||
| 76 | /> | ||
| 77 | )} | ||
| 78 | {account.instanceId && account.limits.resetCredits ? ( | ||
| 79 | <ResetCredits | ||
| 80 | environmentId={environmentId} | ||
| 81 | instanceId={account.instanceId} | ||
| 82 | credits={account.limits.resetCredits} | ||
| 83 | now={now} | ||
| 84 | /> | ||
| 85 | ) : null} | ||
| 86 | </div> | ||
| 87 | ); | ||
| 88 | })} | ||
| 89 | {report.notices.map((notice) => ( | ||
| 90 | <span key={notice} className="text-xs text-muted-foreground"> | ||
| 91 | {notice} | ||
| 92 | </span> | ||
| 93 | ))} | ||
| 94 | </ComposerBanner.Body> | ||
| 95 | </ComposerBanner.Scroll> | ||
| 96 | ); | ||
| 97 | } | ||
apps/web/src/components/usage/UsageLimits.tsx+19-12| ... | @@ -153,24 +153,31 @@ function WindowBar({ | ... | @@ -153,24 +153,31 @@ function WindowBar({ |
| 153 | ); | 153 | ); |
| 154 | } | 154 | } |
| 155 | 155 | ||
| 156 | /** One account's windows as rows: label and percent, bar, pace and countdown. */ | 156 | /** |
| 157 | function LimitWindows({ | 157 | * One account's windows as rows: label and percent, bar, pace and countdown. |
| 158 | * Compact rows fit the composer panel with narrower columns. | ||
| 159 | */ | ||
| 160 | export function LimitWindows({ | ||
| 158 | driver, | 161 | driver, |
| 159 | windows, | 162 | windows, |
| 160 | now, | 163 | now, |
| 164 | compact = false, | ||
| 161 | }: { | 165 | }: { |
| 162 | readonly driver: ServerProvider["driver"]; | 166 | readonly driver: ServerProvider["driver"]; |
| 163 | readonly windows: ReadonlyArray<ServerProviderUsageWindow>; | 167 | readonly windows: ReadonlyArray<ServerProviderUsageWindow>; |
| 164 | readonly now: number; | 168 | readonly now: number; |
| 169 | readonly compact?: boolean; | ||
| 165 | }) { | 170 | }) { |
| 166 | const color = barColor(driver); | 171 | const color = barColor(driver); |
| 167 | return ( | 172 | return ( |
| 168 | <div className="grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1"> | 173 | <div |
| 169 | {windows.map((window, index) => { | 174 | className={ |
| 170 | // Windows that reset together show the countdown once. | 175 | compact |
| 171 | const previous = windows[index - 1]; | 176 | ? "grid grid-cols-[minmax(0,9rem)_minmax(3rem,1fr)_auto] gap-x-3 gap-y-0.5" |
| 172 | const sharesReset = | 177 | : "grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1" |
| 173 | previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt; | 178 | } |
| 179 | > | ||
| 180 | {windows.map((window) => { | ||
| 174 | const pace = paceOf(window, now); | 181 | const pace = paceOf(window, now); |
| 175 | const resetsIn = formatResetsIn(window, now); | 182 | const resetsIn = formatResetsIn(window, now); |
| 176 | return ( | 183 | return ( |
| ... | @@ -182,9 +189,9 @@ function LimitWindows({ | ... | @@ -182,9 +189,9 @@ function LimitWindows({ |
| 182 | </span> | 189 | </span> |
| 183 | </span> | 190 | </span> |
| 184 | <WindowBar color={color} window={window} now={now} /> | 191 | <WindowBar color={color} window={window} now={now} /> |
| 185 | <span className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums"> | 192 | <span className="flex items-center gap-2 text-xs whitespace-nowrap text-muted-foreground tabular-nums"> |
| 186 | {pace ? <PaceIcon pace={pace} /> : null} | 193 | {pace ? <PaceIcon pace={pace} /> : null} |
| 187 | <span className="ms-auto shrink-0">{sharesReset ? "" : (resetsIn ?? "")}</span> | 194 | <span className="ms-auto shrink-0">{resetsIn ?? ""}</span> |
| 188 | </span> | 195 | </span> |
| 189 | </Fragment> | 196 | </Fragment> |
| 190 | ); | 197 | ); |
| ... | @@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { | ... | @@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 292 | * Banked reset credits with a confirmed redeem action. Redeeming spends a | 299 | * Banked reset credits with a confirmed redeem action. Redeeming spends a |
| 293 | * credit the provider granted the user, so it never fires on a bare click. | 300 | * credit the provider granted the user, so it never fires on a bare click. |
| 294 | */ | 301 | */ |
| 295 | function ResetCredits({ | 302 | export function ResetCredits({ |
| 296 | environmentId, | 303 | environmentId, |
| 297 | instanceId, | 304 | instanceId, |
| 298 | credits, | 305 | credits, |
| ... | @@ -341,7 +348,7 @@ function ResetCredits({ | ... | @@ -341,7 +348,7 @@ function ResetCredits({ |
| 341 | <span className="tabular-nums">{summary}</span> | 348 | <span className="tabular-nums">{summary}</span> |
| 342 | {credits.availableCount > 0 ? ( | 349 | {credits.availableCount > 0 ? ( |
| 343 | <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}> | 350 | <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}> |
| 344 | {busy ? "Using credit…" : "Use a reset credit"} | 351 | {busy ? "Using…" : "Use reset"} |
| 345 | </Button> | 352 | </Button> |
| 346 | ) : null} | 353 | ) : null} |
| 347 | {status ? <span className="text-foreground">{status}</span> : null} | 354 | {status ? <span className="text-foreground">{status}</span> : null} |
apps/web/src/connection/runtime.ts+5-1| ... | @@ -32,7 +32,11 @@ type ConnectionLayerSource = | ... | @@ -32,7 +32,11 @@ type ConnectionLayerSource = |
| 32 | 32 | ||
| 33 | const providedClientConnectionLayer = snapshotLoaderLayer.pipe( | 33 | const providedClientConnectionLayer = snapshotLoaderLayer.pipe( |
| 34 | Layer.provideMerge( | 34 | Layer.provideMerge( |
| 35 | Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }), | 35 | Connection.layerWithOptions({ |
| 36 | environmentThemes: true, | ||
| 37 | usageLimitSources: true, | ||
| 38 | usageLimitsCommand: true, | ||
| 39 | }), | ||
| 36 | ), | 40 | ), |
| 37 | Layer.provideMerge( | 41 | Layer.provideMerge( |
| 38 | Layer.mergeAll( | 42 | Layer.mergeAll( |
apps/web/src/state/server.ts+1| ... | @@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt | ... | @@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt |
| 29 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, | 29 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, |
| 30 | environmentThemes: true, | 30 | environmentThemes: true, |
| 31 | usageLimitSources: true, | 31 | usageLimitSources: true, |
| 32 | usageLimitsCommand: true, | ||
| 32 | }); | 33 | }); |
| 33 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | 34 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 34 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 35 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
docs/user/usage.md+5| ... | @@ -45,6 +45,11 @@ next reset. | ... | @@ -45,6 +45,11 @@ next reset. |
| 45 | 45 | ||
| 46 | If a window looks stale, refresh Limits to re-check every provider and hub. | 46 | If a window looks stale, refresh Limits to re-check every provider and hub. |
| 47 | 47 | ||
| 48 | Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the | ||
| 49 | current model's limits without leaving the conversation. The result opens above the composer and | ||
| 50 | closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh | ||
| 51 | anything. The command is offered only for providers that appear under **Usage → Limits**. | ||
| 52 | |||
| 48 | API-key accounts may not report subscription limits. This also applies to Claude connections | 53 | API-key accounts may not report subscription limits. This also applies to Claude connections |
| 49 | using a proxy through `ANTHROPIC_AUTH_TOKEN`. | 54 | using a proxy through `ANTHROPIC_AUTH_TOKEN`. |
| 50 | 55 |
packages/client-runtime/src/rpc/session.ts+3| ... | @@ -57,6 +57,8 @@ export interface RpcSession { | ... | @@ -57,6 +57,8 @@ export interface RpcSession { |
| 57 | export interface RpcSessionOptions { | 57 | export interface RpcSessionOptions { |
| 58 | readonly environmentThemes?: boolean; | 58 | readonly environmentThemes?: boolean; |
| 59 | readonly usageLimitSources?: boolean; | 59 | readonly usageLimitSources?: boolean; |
| 60 | /** This client answers /usage-limits itself, so the server may advertise it. */ | ||
| 61 | readonly usageLimitsCommand?: boolean; | ||
| 60 | } | 62 | } |
| 61 | 63 | ||
| 62 | export class RpcSessionFactory extends Context.Service< | 64 | export class RpcSessionFactory extends Context.Service< |
| ... | @@ -153,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( | ... | @@ -153,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( |
| 153 | const serverConfigInput: ServerConfigSubscriptionInput = { | 155 | const serverConfigInput: ServerConfigSubscriptionInput = { |
| 154 | ...(options.environmentThemes === true ? { environmentThemes: true } : {}), | 156 | ...(options.environmentThemes === true ? { environmentThemes: true } : {}), |
| 155 | ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), | 157 | ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), |
| 158 | ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), | ||
| 156 | }; | 159 | }; |
| 157 | 160 | ||
| 158 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { | 161 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { |
packages/client-runtime/src/state/server.ts+4| ... | @@ -358,6 +358,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven | ... | @@ -358,6 +358,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven |
| 358 | export interface ServerConfigSubscriptionOptions { | 358 | export interface ServerConfigSubscriptionOptions { |
| 359 | readonly environmentThemes?: boolean; | 359 | readonly environmentThemes?: boolean; |
| 360 | readonly usageLimitSources?: boolean; | 360 | readonly usageLimitSources?: boolean; |
| 361 | readonly usageLimitsCommand?: boolean; | ||
| 361 | } | 362 | } |
| 362 | 363 | ||
| 363 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( | 364 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( |
| ... | @@ -425,6 +426,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf | ... | @@ -425,6 +426,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 425 | yield* subscribe(WS_METHODS.subscribeServerConfig, { | 426 | yield* subscribe(WS_METHODS.subscribeServerConfig, { |
| 426 | ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), | 427 | ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), |
| 427 | ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), | 428 | ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), |
| 429 | ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), | ||
| 428 | }).pipe( | 430 | }).pipe( |
| 429 | Stream.runForEach((event) => | 431 | Stream.runForEach((event) => |
| 430 | Effect.gen(function* () { | 432 | Effect.gen(function* () { |
| ... | @@ -621,6 +623,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -621,6 +623,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 621 | readonly environmentThemes?: boolean; | 623 | readonly environmentThemes?: boolean; |
| 622 | /** Whether this surface renders quota from configured usage-limit sources. */ | 624 | /** Whether this surface renders quota from configured usage-limit sources. */ |
| 623 | readonly usageLimitSources?: boolean; | 625 | readonly usageLimitSources?: boolean; |
| 626 | readonly usageLimitsCommand?: boolean; | ||
| 624 | }, | 627 | }, |
| 625 | ) { | 628 | ) { |
| 626 | const configScheduler = createAtomCommandScheduler(); | 629 | const configScheduler = createAtomCommandScheduler(); |
| ... | @@ -636,6 +639,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -636,6 +639,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 636 | serverConfigStateChanges(environmentId, { | 639 | serverConfigStateChanges(environmentId, { |
| 637 | ...(options.environmentThemes === true ? { environmentThemes: true } : {}), | 640 | ...(options.environmentThemes === true ? { environmentThemes: true } : {}), |
| 638 | ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), | 641 | ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), |
| 642 | ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), | ||
| 639 | }), | 643 | }), |
| 640 | ) | 644 | ) |
| 641 | .pipe( | 645 | .pipe( |
packages/contracts/src/providerUsageLimits.ts+21| ... | @@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({ | ... | @@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({ |
| 121 | outcome: ProviderConsumeResetCreditOutcome, | 121 | outcome: ProviderConsumeResetCreditOutcome, |
| 122 | }); | 122 | }); |
| 123 | export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; | 123 | export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; |
| 124 | |||
| 125 | /** A point-in-time view of one provider's limits, built for the /usage-limits panel. */ | ||
| 126 | export const UsageLimitsReport = Schema.Struct({ | ||
| 127 | createdAt: IsoDateTime, | ||
| 128 | accounts: Schema.Array( | ||
| 129 | Schema.Struct({ | ||
| 130 | id: TrimmedNonEmptyString, | ||
| 131 | driver: ProviderDriverKind, | ||
| 132 | label: TrimmedNonEmptyString, | ||
| 133 | plan: Schema.optional(TrimmedNonEmptyString), | ||
| 134 | email: Schema.optional(TrimmedNonEmptyString), | ||
| 135 | sourceLabel: Schema.optional(TrimmedNonEmptyString), | ||
| 136 | instanceId: Schema.optional(ProviderInstanceId), | ||
| 137 | displayName: Schema.optional(Schema.String), | ||
| 138 | accentColor: Schema.optional(Schema.String), | ||
| 139 | limits: ServerProviderUsageLimits, | ||
| 140 | }), | ||
| 141 | ), | ||
| 142 | notices: Schema.Array(Schema.String), | ||
| 143 | }); | ||
| 144 | export type UsageLimitsReport = typeof UsageLimitsReport.Type; |
packages/contracts/src/rpc.ts+6| ... | @@ -1132,6 +1132,12 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon | ... | @@ -1132,6 +1132,12 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon |
| 1132 | environmentThemes: Schema.optional(Schema.Boolean), | 1132 | environmentThemes: Schema.optional(Schema.Boolean), |
| 1133 | /** Whether this client understands `usageLimitSourcesUpdated` events. */ | 1133 | /** Whether this client understands `usageLimitSourcesUpdated` events. */ |
| 1134 | usageLimitSources: Schema.optional(Schema.Boolean), | 1134 | usageLimitSources: Schema.optional(Schema.Boolean), |
| 1135 | /** | ||
| 1136 | * Whether this client answers `/usage-limits` itself. The server injects | ||
| 1137 | * that command into provider catalogs only for such clients; an older | ||
| 1138 | * client would send it to the provider as an ordinary prompt. | ||
| 1139 | */ | ||
| 1140 | usageLimitsCommand: Schema.optional(Schema.Boolean), | ||
| 1135 | }), | 1141 | }), |
| 1136 | success: ServerConfigStreamEvent, | 1142 | success: ServerConfigStreamEvent, |
| 1137 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), | 1143 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), |
packages/shared/src/usageLimits.test.ts+197| ... | @@ -9,6 +9,10 @@ import { | ... | @@ -9,6 +9,10 @@ import { |
| 9 | import { describe, expect, it } from "vite-plus/test"; | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 12 | isUsageLimitsCommand, | ||
| 13 | collectProviderUsageLimits, | ||
| 14 | sameUsageLimitCommandCoverage, | ||
| 15 | withUsageLimitsCommands, | ||
| 12 | collectLimitSources, | 16 | collectLimitSources, |
| 13 | collectLimitsGroups, | 17 | collectLimitsGroups, |
| 14 | elapsedShare, | 18 | elapsedShare, |
| ... | @@ -304,3 +308,196 @@ describe("collectLimitSources", () => { | ... | @@ -304,3 +308,196 @@ describe("collectLimitSources", () => { |
| 304 | ]); | 308 | ]); |
| 305 | }); | 309 | }); |
| 306 | }); | 310 | }); |
| 311 | |||
| 312 | describe("/usage-limits", () => { | ||
| 313 | const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; | ||
| 314 | const selected = provider({ | ||
| 315 | usageLimits: limits, | ||
| 316 | auth: { status: "authenticated", email: "same@example.com" }, | ||
| 317 | }); | ||
| 318 | const sources = [ | ||
| 319 | { | ||
| 320 | id: UsageLimitSourceId.make("hub"), | ||
| 321 | kind: "cliproxy" as const, | ||
| 322 | label: "Accounts", | ||
| 323 | checkedAt: limits.checkedAt, | ||
| 324 | accounts: [ | ||
| 325 | { | ||
| 326 | id: "duplicate", | ||
| 327 | driver: selected.driver, | ||
| 328 | email: "SAME@example.com", | ||
| 329 | usageLimits: limits, | ||
| 330 | }, | ||
| 331 | { id: "oss", driver: selected.driver, plan: "Codex OSS", usageLimits: limits }, | ||
| 332 | { id: "other-provider", driver: ProviderDriverKind.make("claude"), usageLimits: limits }, | ||
| 333 | ], | ||
| 334 | }, | ||
| 335 | ]; | ||
| 336 | |||
| 337 | it("keeps accounts and custom instances separate, filtering by driver", () => { | ||
| 338 | const report = collectProviderUsageLimits( | ||
| 339 | selected.instanceId, | ||
| 340 | [ | ||
| 341 | selected, | ||
| 342 | provider({ | ||
| 343 | instanceId: ProviderInstanceId.make("codex-work"), | ||
| 344 | displayName: "Work", | ||
| 345 | usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, | ||
| 346 | }), | ||
| 347 | provider({ | ||
| 348 | driver: ProviderDriverKind.make("claude"), | ||
| 349 | instanceId: ProviderInstanceId.make("claude"), | ||
| 350 | usageLimits: limits, | ||
| 351 | }), | ||
| 352 | ], | ||
| 353 | sources, | ||
| 354 | now, | ||
| 355 | ); | ||
| 356 | expect(report?.createdAt).toBe("2026-09-03T12:00:00.000Z"); | ||
| 357 | expect(report?.accounts.map((account) => account.id)).toEqual([ | ||
| 358 | "codex", | ||
| 359 | "codex-work", | ||
| 360 | "hub:oss", | ||
| 361 | ]); | ||
| 362 | expect(report?.accounts[0]).toMatchObject({ | ||
| 363 | instanceId: selected.instanceId, | ||
| 364 | email: selected.auth.email, | ||
| 365 | }); | ||
| 366 | expect(report?.accounts[1]).toMatchObject({ | ||
| 367 | displayName: "Work", | ||
| 368 | limits: { resetCredits: { availableCount: 2 } }, | ||
| 369 | }); | ||
| 370 | expect(report?.accounts[2]).toMatchObject({ | ||
| 371 | label: "Accounts · oss", | ||
| 372 | sourceLabel: "CLI Proxy", | ||
| 373 | plan: "Codex OSS", | ||
| 374 | }); | ||
| 375 | expect(report?.notices).toEqual([]); | ||
| 376 | }); | ||
| 377 | |||
| 378 | it("supports a source-only provider and keeps duplicates when the native probe failed", () => { | ||
| 379 | expect( | ||
| 380 | collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map( | ||
| 381 | (account) => account.id, | ||
| 382 | ), | ||
| 383 | ).toEqual(["hub:duplicate", "hub:oss"]); | ||
| 384 | const failed = provider({ usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }); | ||
| 385 | expect( | ||
| 386 | collectProviderUsageLimits(selected.instanceId, [failed], sources, now)?.accounts.map( | ||
| 387 | (account) => account.id, | ||
| 388 | ), | ||
| 389 | ).toEqual(["codex", "hub:duplicate", "hub:oss"]); | ||
| 390 | expect(collectProviderUsageLimits(selected.instanceId, [provider({})], [], now)).toBeNull(); | ||
| 391 | expect( | ||
| 392 | collectProviderUsageLimits( | ||
| 393 | selected.instanceId, | ||
| 394 | [provider({ enabled: false, usageLimits: limits })], | ||
| 395 | [], | ||
| 396 | now, | ||
| 397 | ), | ||
| 398 | ).toBeNull(); | ||
| 399 | }); | ||
| 400 | |||
| 401 | it("surfaces source errors only for sources that carry the selected driver", () => { | ||
| 402 | const failing = { ...sources[0]!, error: "token expired" }; | ||
| 403 | expect( | ||
| 404 | collectProviderUsageLimits(selected.instanceId, [selected], [failing], now)?.notices, | ||
| 405 | ).toEqual(["Accounts: token expired"]); | ||
| 406 | const claudeOnly = { ...failing, accounts: failing.accounts.slice(2) }; | ||
| 407 | expect( | ||
| 408 | collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, | ||
| 409 | ).toEqual([]); | ||
| 410 | // A read failure clears the accounts, so the error must not depend on a match. | ||
| 411 | const unreadable = { ...failing, accounts: [] }; | ||
| 412 | expect( | ||
| 413 | collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, | ||
| 414 | ).toEqual(["Accounts: token expired"]); | ||
| 415 | // A source-only provider still gets the report, carrying only the error. | ||
| 416 | const sourceOnly = collectProviderUsageLimits( | ||
| 417 | selected.instanceId, | ||
| 418 | [provider({})], | ||
| 419 | [unreadable], | ||
| 420 | now, | ||
| 421 | ); | ||
| 422 | expect(sourceOnly?.accounts).toEqual([]); | ||
| 423 | expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]); | ||
| 424 | }); | ||
| 425 | |||
| 426 | it("advertises global and workspace commands only for providers present in Limits", () => { | ||
| 427 | const withWorkspace = provider({ | ||
| 428 | workspaceSnapshots: [ | ||
| 429 | { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] }, | ||
| 430 | ], | ||
| 431 | }); | ||
| 432 | const [supported] = withUsageLimitsCommands([withWorkspace], sources); | ||
| 433 | expect(supported?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]); | ||
| 434 | expect( | ||
| 435 | supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), | ||
| 436 | ).toEqual(["usage-limits"]); | ||
| 437 | expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); | ||
| 438 | // A provider's own command of the same name is left alone without coverage. | ||
| 439 | const ownCommand = provider({ | ||
| 440 | slashCommands: [{ name: "usage-limits", description: "Provider's own" }], | ||
| 441 | }); | ||
| 442 | expect(withUsageLimitsCommands([ownCommand], [])[0]?.slashCommands).toEqual([ | ||
| 443 | { name: "usage-limits", description: "Provider's own" }, | ||
| 444 | ]); | ||
| 445 | const unreadable = { ...sources[0]!, accounts: [], error: "token expired" }; | ||
| 446 | expect( | ||
| 447 | withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map( | ||
| 448 | (command) => command.name, | ||
| 449 | ), | ||
| 450 | ).toEqual(["usage-limits"]); | ||
| 451 | expect( | ||
| 452 | withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), | ||
| 453 | ).toEqual(["usage-limits"]); | ||
| 454 | }); | ||
| 455 | }); | ||
| 456 | |||
| 457 | describe("sameUsageLimitCommandCoverage", () => { | ||
| 458 | const codexAccount = { | ||
| 459 | id: "a", | ||
| 460 | driver: ProviderDriverKind.make("codex"), | ||
| 461 | usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }, | ||
| 462 | }; | ||
| 463 | const base = { | ||
| 464 | id: UsageLimitSourceId.make("hub"), | ||
| 465 | kind: "cliproxy" as const, | ||
| 466 | label: "Accounts", | ||
| 467 | checkedAt: "2026-09-03T11:00:00.000Z", | ||
| 468 | }; | ||
| 469 | it("ignores quota movement but not the drivers offered the command", () => { | ||
| 470 | const withCodex = [{ ...base, accounts: [codexAccount] }]; | ||
| 471 | const withCodexLater = [ | ||
| 472 | { | ||
| 473 | ...base, | ||
| 474 | accounts: [ | ||
| 475 | { | ||
| 476 | ...codexAccount, | ||
| 477 | usageLimits: { ...codexAccount.usageLimits, checkedAt: "2026-09-03T12:00:00.000Z" }, | ||
| 478 | }, | ||
| 479 | ], | ||
| 480 | }, | ||
| 481 | ]; | ||
| 482 | expect(sameUsageLimitCommandCoverage(withCodex, withCodexLater)).toBe(true); | ||
| 483 | expect(sameUsageLimitCommandCoverage(withCodex, [{ ...base, accounts: [] }])).toBe(false); | ||
| 484 | }); | ||
| 485 | it("treats a failed read as a change in coverage, in both directions", () => { | ||
| 486 | const empty = [{ ...base, accounts: [] }]; | ||
| 487 | const failed = [{ ...base, accounts: [], error: "token expired" }]; | ||
| 488 | expect(sameUsageLimitCommandCoverage(empty, failed)).toBe(false); | ||
| 489 | expect(sameUsageLimitCommandCoverage(failed, empty)).toBe(false); | ||
| 490 | expect( | ||
| 491 | sameUsageLimitCommandCoverage(failed, [{ ...base, accounts: [], error: "still down" }]), | ||
| 492 | ).toBe(true); | ||
| 493 | }); | ||
| 494 | }); | ||
| 495 | |||
| 496 | describe("isUsageLimitsCommand", () => { | ||
| 497 | it("recognizes only the standalone local action", () => { | ||
| 498 | expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); | ||
| 499 | expect(isUsageLimitsCommand("/usage-limits explain")).toBe(false); | ||
| 500 | expect(isUsageLimitsCommand("Explain /usage-limits")).toBe(false); | ||
| 501 | expect(isUsageLimitsCommand("/usage")).toBe(false); | ||
| 502 | }); | ||
| 503 | }); |
packages/shared/src/usageLimits.ts+145-1| ... | @@ -7,6 +7,9 @@ | ... | @@ -7,6 +7,9 @@ |
| 7 | */ | 7 | */ |
| 8 | import { | 8 | import { |
| 9 | type EnvironmentId, | 9 | type EnvironmentId, |
| 10 | type UsageLimitsReport, | ||
| 11 | type ProviderInstanceId, | ||
| 12 | type ServerProviderSlashCommand, | ||
| 10 | isProviderAvailable, | 13 | isProviderAvailable, |
| 11 | type ServerProvider, | 14 | type ServerProvider, |
| 12 | type ServerProviderUsageLimits, | 15 | type ServerProviderUsageLimits, |
| ... | @@ -15,6 +18,8 @@ import { | ... | @@ -15,6 +18,8 @@ import { |
| 15 | type UsageLimitSourceSnapshots, | 18 | type UsageLimitSourceSnapshots, |
| 16 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 17 | 20 | ||
| 21 | import * as DateTime from "effect/DateTime"; | ||
| 22 | |||
| 18 | const MINUTE = 60_000; | 23 | const MINUTE = 60_000; |
| 19 | const HOUR = 60 * MINUTE; | 24 | const HOUR = 60 * MINUTE; |
| 20 | const DAY = 24 * HOUR; | 25 | const DAY = 24 * HOUR; |
| ... | @@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) | ... | @@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) |
| 144 | 149 | ||
| 145 | /** The instance's configured name, else the driver's, else its raw kind. */ | 150 | /** The instance's configured name, else the driver's, else its raw kind. */ |
| 146 | export function providerLimitsLabel( | 151 | export function providerLimitsLabel( |
| 147 | provider: ServerProvider, | 152 | provider: Pick<ServerProvider, "driver" | "displayName">, |
| 148 | driverLabel: (driver: ServerProvider["driver"]) => string | undefined, | 153 | driverLabel: (driver: ServerProvider["driver"]) => string | undefined, |
| 149 | ): string { | 154 | ): string { |
| 150 | return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); | 155 | return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); |
| ... | @@ -209,3 +214,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): | ... | @@ -209,3 +214,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): |
| 209 | if (resetsAt === null) return null; | 214 | if (resetsAt === null) return null; |
| 210 | return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; | 215 | return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; |
| 211 | } | 216 | } |
| 217 | |||
| 218 | /** Limit commands are served by T3 from the same snapshots as Usage → Limits. */ | ||
| 219 | export const USAGE_LIMITS_COMMAND = { | ||
| 220 | name: "usage-limits", | ||
| 221 | description: "Show this provider's usage limits", | ||
| 222 | } satisfies ServerProviderSlashCommand; | ||
| 223 | |||
| 224 | /** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ | ||
| 225 | export function isUsageLimitsCommand(prompt: string): boolean { | ||
| 226 | return prompt.trim().toLowerCase() === "/usage-limits"; | ||
| 227 | } | ||
| 228 | |||
| 229 | /** | ||
| 230 | * Whether Limits has anything to say about this driver. A source that failed to | ||
| 231 | * read keeps no accounts, so its error counts for every driver rather than | ||
| 232 | * disappearing until the next successful refresh. | ||
| 233 | */ | ||
| 234 | export function hasProviderUsageLimits( | ||
| 235 | driver: ServerProvider["driver"], | ||
| 236 | providers: readonly ServerProvider[], | ||
| 237 | sources: UsageLimitSourceSnapshots, | ||
| 238 | ): boolean { | ||
| 239 | return ( | ||
| 240 | providersWithLimits(providers).some((provider) => provider.driver === driver) || | ||
| 241 | sources.some( | ||
| 242 | (source) => | ||
| 243 | source.accounts.some((account) => account.driver === driver) || | ||
| 244 | (source.error !== undefined && source.accounts.length === 0), | ||
| 245 | ) | ||
| 246 | ); | ||
| 247 | } | ||
| 248 | |||
| 249 | /** | ||
| 250 | * The drivers a set of sources would offer the command to, where a source that | ||
| 251 | * failed to read counts for every driver. Two snapshots with the same coverage | ||
| 252 | * need no catalog republish, however much their quotas moved. | ||
| 253 | */ | ||
| 254 | export function sameUsageLimitCommandCoverage( | ||
| 255 | previous: UsageLimitSourceSnapshots, | ||
| 256 | next: UsageLimitSourceSnapshots, | ||
| 257 | ): boolean { | ||
| 258 | const coverage = (sources: UsageLimitSourceSnapshots) => | ||
| 259 | new Set( | ||
| 260 | sources.flatMap((source) => | ||
| 261 | source.error !== undefined && source.accounts.length === 0 | ||
| 262 | ? ["*"] | ||
| 263 | : source.accounts.map((account) => String(account.driver)), | ||
| 264 | ), | ||
| 265 | ); | ||
| 266 | const before = coverage(previous); | ||
| 267 | const after = coverage(next); | ||
| 268 | return before.size === after.size && [...before].every((driver) => after.has(driver)); | ||
| 269 | } | ||
| 270 | |||
| 271 | /** Advertise on workspace catalogs too, which replace the global command list. */ | ||
| 272 | export function withUsageLimitsCommands( | ||
| 273 | providers: readonly ServerProvider[], | ||
| 274 | sources: UsageLimitSourceSnapshots, | ||
| 275 | ): ServerProvider[] { | ||
| 276 | return providers.map((provider) => { | ||
| 277 | if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider; | ||
| 278 | const commands = (items: readonly ServerProviderSlashCommand[]) => [ | ||
| 279 | ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name), | ||
| 280 | USAGE_LIMITS_COMMAND, | ||
| 281 | ]; | ||
| 282 | return { | ||
| 283 | ...provider, | ||
| 284 | slashCommands: commands(provider.slashCommands), | ||
| 285 | ...(provider.workspaceSnapshots | ||
| 286 | ? { | ||
| 287 | workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ | ||
| 288 | ...snapshot, | ||
| 289 | slashCommands: commands(snapshot.slashCommands), | ||
| 290 | })), | ||
| 291 | } | ||
| 292 | : {}), | ||
| 293 | }; | ||
| 294 | }); | ||
| 295 | } | ||
| 296 | |||
| 297 | /** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */ | ||
| 298 | export function collectProviderUsageLimits( | ||
| 299 | instanceId: ProviderInstanceId, | ||
| 300 | providers: readonly ServerProvider[], | ||
| 301 | sources: UsageLimitSourceSnapshots, | ||
| 302 | now: number, | ||
| 303 | ): UsageLimitsReport | null { | ||
| 304 | const selected = providers.find((provider) => provider.instanceId === instanceId); | ||
| 305 | if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; | ||
| 306 | const native = providersWithLimits(providers).filter( | ||
| 307 | (provider) => provider.driver === selected.driver, | ||
| 308 | ); | ||
| 309 | const nativeAccounts = new Set( | ||
| 310 | native.flatMap((provider) => { | ||
| 311 | const key = accountKey(provider.driver, provider.auth.email); | ||
| 312 | return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable | ||
| 313 | ? [key] | ||
| 314 | : []; | ||
| 315 | }), | ||
| 316 | ); | ||
| 317 | const accounts: Array<UsageLimitsReport["accounts"][number]> = []; | ||
| 318 | const notices: string[] = []; | ||
| 319 | for (const provider of native) { | ||
| 320 | if (!provider.usageLimits) continue; | ||
| 321 | accounts.push({ | ||
| 322 | id: provider.instanceId, | ||
| 323 | driver: provider.driver, | ||
| 324 | label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`, | ||
| 325 | ...(provider.auth.label ? { plan: provider.auth.label } : {}), | ||
| 326 | instanceId: provider.instanceId, | ||
| 327 | ...(provider.displayName ? { displayName: provider.displayName } : {}), | ||
| 328 | ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), | ||
| 329 | ...(provider.auth.email ? { email: provider.auth.email } : {}), | ||
| 330 | limits: provider.usageLimits, | ||
| 331 | }); | ||
| 332 | } | ||
| 333 | for (const source of sources) { | ||
| 334 | const matching = source.accounts.filter((account) => account.driver === selected.driver); | ||
| 335 | for (const account of matching) { | ||
| 336 | const key = accountKey(account.driver, account.email); | ||
| 337 | if (key && nativeAccounts.has(key)) continue; | ||
| 338 | accounts.push({ | ||
| 339 | id: `${source.id}:${account.id}`, | ||
| 340 | driver: account.driver, | ||
| 341 | label: `${source.label} · ${account.id}`, | ||
| 342 | sourceLabel: "CLI Proxy", | ||
| 343 | ...(account.plan ? { plan: account.plan } : {}), | ||
| 344 | ...(account.email ? { email: account.email } : {}), | ||
| 345 | limits: account.usageLimits, | ||
| 346 | }); | ||
| 347 | } | ||
| 348 | // A source that failed to read has no accounts left to match on, so its | ||
| 349 | // error is reported to every provider rather than silently dropped. | ||
| 350 | if (source.error && (matching.length > 0 || source.accounts.length === 0)) { | ||
| 351 | notices.push(`${source.label}: ${source.error}`); | ||
| 352 | } | ||
| 353 | } | ||
| 354 | return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; | ||
| 355 | } |