Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
boringSidebarEnabled: false,
boringSidebarMultilineRows: false,
threadDetailsUsageEnabled: false,
legacySidebarEnabled: false,
sidebarWorkingShelfEnabled: false,
loadBalancingEnabled: false,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,9 @@ export default function ChatView(props: ChatViewProps) {
const canChangeEnvironment =
hasMultipleEnvironments && !envLocked && draftId !== null && !draftThread?.promotedTo;

const threadDetailsUsageEnabled = useClientSettings(
(settings) => settings.threadDetailsUsageEnabled,
);
const loadBalancingSettings = useClientSettings((settings) => ({
loadBalancingEnabled: settings.loadBalancingEnabled,
loadBalancingWeights: settings.loadBalancingWeights,
Expand Down Expand Up @@ -3770,6 +3773,16 @@ export default function ChatView(props: ChatViewProps) {
onAddProjectScript={saveProjectScriptStable}
onUpdateProjectScript={updateProjectScriptStable}
onDeleteProjectScript={deleteProjectScriptStable}
usage={
threadDetailsUsageEnabled
? {
providerInstanceId: activeProviderInstanceId,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
providers: providerStatuses,
sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES,
contextWindow: activeContextWindow,
}
: undefined
}
/>

<PullRequestThreadDialogHost
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection";
import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel";
import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl";
import { ThreadServersPanel } from "./ThreadServersPanel";
import { ThreadDetailsUsage, type ThreadDetailsUsageInput } from "./ThreadDetailsUsage";

const GitActionsControl = memo(BaseGitActionsControl);

Expand Down Expand Up @@ -67,6 +68,8 @@ interface ThreadDetailsPanelProps extends Pick<
input: NewProjectScriptInput,
) => Promise<ProjectScriptActionResult>;
onDeleteProjectScript: (scriptId: string) => Promise<ProjectScriptActionResult>;
/** Set only while the Labs usage readout is on. */
usage?: ThreadDetailsUsageInput | undefined;
}

export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
Expand Down Expand Up @@ -195,6 +198,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
threadId={props.threadId}
/>
) : null}

{props.usage ? <ThreadDetailsUsage {...props.usage} /> : null}
</>
)}
</ThreadDetailsCard>
Expand Down
228 changes: 228 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsUsage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import type {
ProviderInstanceId,
ServerProvider,
ServerProviderUsageWindow,
UsageLimitSourceSnapshots,
} from "@t3tools/contracts";
import {
collectProviderUsageLimits,
formatDuration,
type LimitPace,
limitsNotice,
paceOf,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import type { ReactNode } from "react";

import { usePrimarySettings } from "../../hooks/useSettings";
import { useNowMinute } from "../../hooks/useNowMinute";
import { type ContextWindowSnapshot, formatContextWindowTokens } from "../../lib/contextWindow";
import { cn } from "../../lib/utils";
import { formatUpcomingTimestamp } from "../../timestampFormat";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ThreadDetailsSection } from "./ThreadDetailsSection";

const PACE_LABEL: Record<LimitPace, string> = {
ahead: "Ahead of pace",
on: "On pace",
under: "Under pace",
};

export interface ThreadDetailsUsageInput {
readonly providerInstanceId: ProviderInstanceId | null;
readonly providers: readonly ServerProvider[];
readonly sources: UsageLimitSourceSnapshots;
readonly contextWindow: ContextWindowSnapshot | null;
}

/**
* Labs readout of the selected provider's limit windows and the thread's context window.
* Every row reads as room left, so a longer bar always means more headroom.
*/
export function ThreadDetailsUsage({
providerInstanceId,
providers,
sources,
contextWindow,
}: ThreadDetailsUsageInput) {
const nowMinute = useNowMinute();
const now = Date.parse(`${nowMinute}:00Z`);
const report =
providerInstanceId !== null
? collectProviderUsageLimits(providerInstanceId, providers, sources, now)
: null;
// Only this instance's data: the report keeps one instance per login, and any other entry may be
// a different login or workspace, so fall back to the instance's own snapshot.
const limits =
report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ??
providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits;
const notice = limits ? limitsNotice(limits) : null;
if (!limits && !contextWindow) return null;

return (
<ThreadDetailsSection
headingId="thread-details-usage-heading"
title="Usage"
showHeading={false}
>
<div className="grid grid-cols-[auto_minmax(3rem,1fr)_auto] gap-x-3 text-xs">
{notice ? (
<UsageRow
label="Limits"
left={null}
value="Unavailable"
tooltip={<span className="text-foreground">{notice}</span>}
/>
) : null}
{limits && !notice
? limits.windows.map((window) => <LimitRow key={window.id} window={window} now={now} />)
: null}
{contextWindow ? <ContextRow usage={contextWindow} spaced={limits !== undefined} /> : null}
</div>
</ThreadDetailsSection>
);
}

function LimitRow({
window,
now,
}: {
readonly window: ServerProviderUsageWindow;
readonly now: number;
}) {
const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
const left = remainingPercent(window);
const pace = paceOf(window, now);
const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN;
return (
<UsageRow
label={window.label}
left={left}
tooltip={
<>
<span className="text-foreground">
{window.label}: {left}% left
</span>
{window.resetsAt && resetsAt > now ? (
<span className="text-muted-foreground">
Resets in {formatDuration(resetsAt - now)} ·{" "}
{formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)}
</span>
) : null}
{pace ? <span className="text-muted-foreground">{PACE_LABEL[pace]}</span> : null}
</>
}
/>
);
}

function ContextRow({
usage,
spaced,
}: {
readonly usage: ContextWindowSnapshot;
readonly spaced: boolean;
}) {
const maxTokens = usage.maxTokens ?? null;
const left =
maxTokens !== null && usage.usedPercentage !== null
? Math.round(100 - Math.max(0, Math.min(100, usage.usedPercentage)))
: null;
const used = formatContextWindowTokens(usage.usedTokens);
return (
<UsageRow
label="Context"
left={left}
muted
className={cn(spaced && "mt-2")}
tooltip={
<>
{left !== null ? <span className="text-foreground">Context: {left}% left</span> : null}
<span className={left !== null ? "text-muted-foreground" : "text-foreground"}>
{maxTokens !== null
? `${used} of ${formatContextWindowTokens(maxTokens)} tokens used`
: `${used} tokens used`}
</span>
</>
}
value={left === null ? used : undefined}
/>
);
}

/** One label, hairline, and value row; the whole row opens the details tooltip. */
function UsageRow({
label,
left,
tooltip,
value,
muted = false,
className,
}: {
readonly label: string;
readonly left: number | null;
readonly tooltip: ReactNode;
/** Replaces the percentage when there is none. */
readonly value?: string | undefined;
readonly muted?: boolean;
readonly className?: string | undefined;
}) {
const tone = left === null ? null : left <= 10 ? "error" : left <= 20 ? "warning" : null;
return (
<Tooltip>
<TooltipTrigger
render={
<div
tabIndex={0}
aria-label={`${label}: ${value ?? `${left}% left`}`}
className={cn(
"col-span-3 grid h-6 cursor-default grid-cols-subgrid items-center rounded-lg px-2.5 outline-none hover:bg-black/[0.035] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-popup-open:bg-black/[0.035] dark:hover:bg-white/[0.08] dark:data-popup-open:bg-white/[0.08]",
className,
)}
/>
}
>
<span className="max-w-28 truncate text-muted-foreground">{label}</span>
{left !== null ? (
<span className="relative h-[3px] rounded-full bg-muted-foreground/30 dark:bg-muted-foreground/20">
{left > 0 ? (
<span
className={cn(
"absolute inset-y-0 left-0 rounded-full",
tone === "error"
? "bg-error"
: tone === "warning"
? "bg-warning"
: muted
? "bg-muted-foreground"
: "bg-foreground/80",
)}
style={{ width: `max(${left}%, 6px)` }}
/>
) : null}
</span>
) : null}
<span
className={cn(
// Text-only rows span the bar column so they never widen the shared value column.
"text-end font-medium tabular-nums",
left === null && "col-span-2",
tone === "error"
? "text-error-foreground"
: tone === "warning"
? "text-warning-foreground"
: left === null
? "text-muted-foreground"
: "text-foreground/80",
)}
>
{value ?? `${left}%`}
</span>
</TooltipTrigger>
{/* Rows sit 8px inside the card; clear its edge instead of overlapping it. */}
<TooltipPopup side="left" sideOffset={16}>
<div className="flex flex-col gap-0.5">{tooltip}</div>
</TooltipPopup>
</Tooltip>
);
}
21 changes: 21 additions & 0 deletions apps/web/src/components/settings/LabsSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function LabsSettingsPanel() {
<SettingsSection id="beta" title="Beta">
<BoringSidebarRow />
<BoringSidebarMultilineRow />
<ThreadDetailsUsageRow />
{/* Clerk is only mounted when this build has T3 Connect. */}
{hasCloudPublicConfig() ? <GuestLinksRowWithSignIn /> : <GuestLinksRow />}
<LinkedEnvironmentsRow />
Expand Down Expand Up @@ -69,6 +70,26 @@ function BoringSidebarMultilineRow() {
);
}

function ThreadDetailsUsageRow() {
const settings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
return (
<SettingsRow
{...searchableSetting("thread-details-usage")}
description="Show the current provider's usage limits and the thread's context window at the bottom of the thread details card."
control={
<Switch
checked={settings.threadDetailsUsageEnabled}
onCheckedChange={(checked) =>
updateSettings({ threadDetailsUsageEnabled: Boolean(checked) })
}
aria-label="Usage in thread details"
/>
}
/>
);
}

/** Signed out of T3 Connect, the row offers to sign in instead of its switch. */
function GuestLinksRowWithSignIn() {
const { isSignedIn } = useAuth();
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/labs",
searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"],
},
{
id: "thread-details-usage",
title: "Usage in thread details",
to: "/settings/labs",
searchTerms: ["beta limits 5 hour weekly context window tokens quota rate limit"],
},
{
id: "guest-links",
title: "Guest links",
Expand Down
6 changes: 6 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ current model's limits without leaving the conversation. The result opens above
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
anything. The command is offered only for providers that appear under **Usage → Limits**.

To keep them in view, turn on **Settings → Labs → Usage in thread details** on web and desktop.
The bottom of the thread details card then shows how much is left in each limit window of the
current provider account and in the thread's context window. Some windows, such as a weekly Opus
limit, count only one model. Hover a row to see when it resets, your pace, or
the token count.

OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in
the environment. T3 cannot report limits for external OpenCode servers because their credentials
belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ export const ClientSettingsSchema = Schema.Struct({
boringSidebarMultilineRows: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
),
threadDetailsUsageEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
// Opt-in for the removed legacy sidebar. Still read so those users land in
// the boring sidebar; the boring sidebar toggle clears it.
legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
Expand Down Expand Up @@ -1720,6 +1721,7 @@ export const ClientSettingsPatch = Schema.Struct({
showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
boringSidebarEnabled: Schema.optionalKey(Schema.Boolean),
boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean),
threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean),
legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
Expand Down
Loading