Skip to content

Commit 486065e

Browse files
authored
Merge pull request #91 from pylon-code/feat/composer-usage-prime
feat(web): composer capacity follows the composer's account, including on Prime
2 parents 264060b + 90c17eb commit 486065e

17 files changed

Lines changed: 1170 additions & 288 deletions

apps/web/src/components/BranchToolbar.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2-
import type { EnvironmentId, ServerProvider, ThreadId } from "@t3tools/contracts";
2+
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
33
import {
44
ChevronDownIcon,
55
CloudIcon,
@@ -15,7 +15,7 @@ import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
1515
import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
1616
import { useIsMobile } from "../hooks/useMediaQuery";
1717
import { ComposerUsageIndicator } from "./ComposerUsageIndicator";
18-
import type { ProviderUsageAccount } from "./providerUsage/ProviderUsageAccounts";
18+
import type { ComposerUsage } from "../providerUsageAccounts";
1919
import type { TimestampFormat } from "@t3tools/contracts/settings";
2020
import {
2121
type EnvMode,
@@ -60,10 +60,10 @@ interface BranchToolbarProps {
6060
onComposerFocusRequest?: () => void;
6161
availableEnvironments?: readonly EnvironmentOption[];
6262
onEnvironmentChange?: (environmentId: EnvironmentId) => void;
63-
/** Snapshot for the account this thread runs on; drives the usage readout. */
64-
providerStatus?: ServerProvider | null;
65-
/** All accounts for the thread's driver, for the capacity popover. */
66-
providerUsageAccounts?: readonly ProviderUsageAccount[] | undefined;
63+
/** Capacity for the account the composer will send to, and its siblings. */
64+
composerUsage: ComposerUsage;
65+
/** How old a capacity reading may get before the readout dims. */
66+
usageStaleAfterMs: number;
6767
timestampFormat: TimestampFormat;
6868
}
6969

@@ -397,8 +397,8 @@ export const BranchToolbar = memo(function BranchToolbar({
397397
onComposerFocusRequest,
398398
availableEnvironments,
399399
onEnvironmentChange,
400-
providerStatus,
401-
providerUsageAccounts,
400+
composerUsage,
401+
usageStaleAfterMs,
402402
timestampFormat,
403403
}: BranchToolbarProps) {
404404
const threadRef = useMemo(
@@ -561,8 +561,9 @@ export const BranchToolbar = memo(function BranchToolbar({
561561

562562
<ComposerUsageIndicator
563563
className="ml-auto"
564-
provider={providerStatus}
565-
usageAccounts={providerUsageAccounts}
564+
environmentId={environmentId}
565+
usage={composerUsage}
566+
staleAfterMs={usageStaleAfterMs}
566567
timestampFormat={timestampFormat}
567568
/>
568569
</div>

apps/web/src/components/ChatView.tsx

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
type AtomCommandResult,
9292
} from "@t3tools/client-runtime/state/runtime";
9393
import * as Cause from "effect/Cause";
94+
import * as Duration from "effect/Duration";
9495
import * as Schema from "effect/Schema";
9596
import { AsyncResult } from "effect/unstable/reactivity";
9697
import { isElectron } from "../env";
@@ -218,15 +219,19 @@ import {
218219
applyProviderInstanceSettings,
219220
deriveProviderInstanceEntries,
220221
NO_PROVIDER_MODEL_SELECTION,
222+
sortProviderInstanceEntries,
221223
} from "../providerInstances";
224+
import { resolveComposerInstanceSelection } from "../composerInstanceSelection";
222225
import {
223226
buildThreadHandoffSeed,
224227
getThreadContinuationLinks,
225228
getThreadHandoffOffer,
226229
summarizeHandoffDiff,
227230
} from "./chat/ThreadHandoff.logic";
228231
import { ThreadContinuationBanner } from "./chat/ThreadContinuationBanner";
229-
import { deriveProviderUsageAccounts } from "../providerUsageAccounts";
232+
import { deriveComposerUsage } from "../providerUsageAccounts";
233+
import { usageStaleAfterMs } from "./providerUsage/ProviderUsageMatrix.logic";
234+
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
230235
import { useCheckpointDiff } from "../lib/checkpointDiffState";
231236
import {
232237
useClientSettings,
@@ -260,6 +265,7 @@ import {
260265
finalizePromotedDraftThreadByRef,
261266
markPromotedDraftThreadByRef,
262267
useComposerDraftStore,
268+
useEffectiveComposerModelState,
263269
type DraftId,
264270
} from "../composerDraftStore";
265271
import {
@@ -4790,24 +4796,70 @@ function ChatViewContent(props: ChatViewProps) {
47904796
[threadHandoffDiff.data?.diff],
47914797
);
47924798
const [isContinuingThreadOnAccount, setIsContinuingThreadOnAccount] = useState(false);
4793-
// Capacity for every account of this thread's driver, for the composer
4794-
// strip's popover. Deciding where the next thread goes needs the comparison,
4795-
// not just the account in front of you.
4796-
const activeProviderUsageAccounts = useMemo(
4799+
// Capacity for the account the composer will actually send to, resolved the
4800+
// same way the composer resolves it — picker choice, session binding, drain
4801+
// routing and all — so the strip never names one account while the send
4802+
// goes to another. For a Prime Agent thread the selected model decides
4803+
// whose capacity applies.
4804+
const composerInstanceEntries = useMemo(
4805+
() => sortProviderInstanceEntries(threadHandoffEntries),
4806+
[threadHandoffEntries],
4807+
);
4808+
const composerInstanceSelection = useMemo(
47974809
() =>
4798-
deriveProviderUsageAccounts({
4799-
providerStatuses,
4800-
activeInstanceId:
4801-
activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection?.instanceId,
4802-
enabled: settings.showProviderUsageInContextPopover,
4810+
resolveComposerInstanceSelection({
4811+
entries: composerInstanceEntries,
4812+
draftActiveProvider: composerActiveProvider,
4813+
sessionInstanceId: activeThread?.session?.providerInstanceId,
4814+
threadInstanceId: activeThread?.modelSelection?.instanceId,
4815+
projectInstanceId: activeProject?.defaultModelSelection?.instanceId,
4816+
lockedProvider,
4817+
nowMs: Date.now(),
48034818
}),
48044819
[
4820+
activeProject?.defaultModelSelection?.instanceId,
48054821
activeThread?.modelSelection?.instanceId,
48064822
activeThread?.session?.providerInstanceId,
4823+
composerActiveProvider,
4824+
composerInstanceEntries,
4825+
lockedProvider,
4826+
],
4827+
);
4828+
const { selectedModel: composerSelectedModel } = useEffectiveComposerModelState({
4829+
threadRef: composerDraftTarget,
4830+
providers: providerStatuses,
4831+
selectedProvider: composerInstanceSelection.driverKind,
4832+
selectedInstanceId: composerInstanceSelection.instanceId,
4833+
threadModelSelection: activeThread?.modelSelection,
4834+
projectModelSelection: activeProject?.defaultModelSelection,
4835+
settings,
4836+
});
4837+
const composerUsage = useMemo(
4838+
() =>
4839+
deriveComposerUsage({
4840+
providerStatuses,
4841+
selectedInstanceId: composerInstanceSelection.instanceId,
4842+
selectedModel: composerSelectedModel,
4843+
enabled: settings.showProviderUsageInContextPopover,
4844+
}),
4845+
[
4846+
composerInstanceSelection.instanceId,
4847+
composerSelectedModel,
48074848
providerStatuses,
48084849
settings.showProviderUsageInContextPopover,
48094850
],
48104851
);
4852+
// The strip dims a reading the server should already have replaced, so the
4853+
// bound follows the server's own poll rather than a fixed number.
4854+
const composerUsageStaleAfterMs = useMemo(
4855+
() =>
4856+
usageStaleAfterMs(
4857+
Duration.toMillis(
4858+
resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval,
4859+
),
4860+
),
4861+
[settings],
4862+
);
48114863
// Both ends of the seam, read from shells the client already holds. Keyed on
48124864
// the two ids rather than the thread object so a streaming turn does not
48134865
// rescan every shell on each token.
@@ -8162,8 +8214,8 @@ function ChatViewContent(props: ChatViewProps) {
81628214
? { onCheckoutPullRequestRequest: openPullRequestDialog }
81638215
: {})}
81648216
{...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
8165-
providerStatus={activeProviderStatus}
8166-
providerUsageAccounts={activeProviderUsageAccounts}
8217+
composerUsage={composerUsage}
8218+
usageStaleAfterMs={composerUsageStaleAfterMs}
81678219
timestampFormat={settings.timestampFormat}
81688220
availableEnvironments={logicalProjectEnvironments}
81698221
/>

apps/web/src/components/ComposerUsageIndicator.logic.test.ts

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,25 @@
1-
import {
2-
ProviderDriverKind,
3-
ProviderInstanceId,
4-
type ServerProvider,
5-
type ServerProviderUsageWindow,
6-
} from "@t3tools/contracts";
1+
import { ProviderInstanceId, type ServerProviderUsageWindow } from "@t3tools/contracts";
72
import { describe, expect, it } from "vite-plus/test";
83

94
import { formatTimeUntilReset, getComposerUsageView } from "./ComposerUsageIndicator.logic";
5+
import type { ProviderUsageAccount } from "./providerUsage/ProviderUsageAccounts";
106

11-
function provider(input: {
12-
windows?: ReadonlyArray<ServerProviderUsageWindow>;
7+
function account(input: {
8+
windows: ReadonlyArray<ServerProviderUsageWindow>;
139
displayName?: string;
1410
accentColor?: string;
15-
}): ServerProvider {
11+
checkedAt?: string;
12+
}): ProviderUsageAccount {
1613
return {
1714
instanceId: ProviderInstanceId.make("claudeAgent"),
18-
driver: ProviderDriverKind.make("claudeAgent"),
19-
...(input.displayName ? { displayName: input.displayName } : {}),
15+
displayName: input.displayName ?? "Claude",
2016
...(input.accentColor ? { accentColor: input.accentColor } : {}),
21-
...(input.windows
22-
? {
23-
usageLimits: {
24-
source: "claudeOAuth",
25-
checkedAt: "2026-08-04T21:00:00.000Z",
26-
windows: input.windows,
27-
},
28-
}
29-
: {}),
30-
enabled: true,
31-
installed: true,
32-
version: null,
33-
status: "ready" as const,
34-
auth: { status: "authenticated" as const },
35-
checkedAt: "2026-08-04T21:00:00.000Z",
36-
models: [],
37-
slashCommands: [],
38-
skills: [],
17+
usageLimits: {
18+
source: "claudeOAuth",
19+
checkedAt: input.checkedAt ?? "2026-08-06T11:59:00.000Z",
20+
windows: input.windows,
21+
},
22+
isActive: true,
3923
};
4024
}
4125

@@ -66,7 +50,7 @@ const NOW = Date.parse("2026-08-06T12:00:00.000Z");
6650

6751
describe("getComposerUsageView", () => {
6852
it("shows the session and account-wide weekly only", () => {
69-
const view = getComposerUsageView(provider({ windows: CLAUDE_WINDOWS }), NOW);
53+
const view = getComposerUsageView(account({ windows: CLAUDE_WINDOWS }), NOW);
7054

7155
expect(view?.entries).toEqual([
7256
{
@@ -88,15 +72,15 @@ describe("getComposerUsageView", () => {
8872

8973
// The strip is a glance; the popover carries the model-scoped weeklies.
9074
it("leaves model-scoped weeklies out", () => {
91-
const view = getComposerUsageView(provider({ windows: CLAUDE_WINDOWS }), NOW);
75+
const view = getComposerUsageView(account({ windows: CLAUDE_WINDOWS }), NOW);
9276

9377
expect(view?.entries.some((entry) => entry.detail === "Weekly (Fable)")).toBe(false);
9478
});
9579

9680
// Windows are matched on duration, so Codex's differently-named ones work.
9781
it("reads Codex's window labels", () => {
9882
const view = getComposerUsageView(
99-
provider({
83+
account({
10084
windows: [
10185
{ label: "Session", usedPercent: 25, windowDurationMins: 300 },
10286
{ label: "Weekly", usedPercent: 82, windowDurationMins: 10_080 },
@@ -114,7 +98,7 @@ describe("getComposerUsageView", () => {
11498

11599
it("rounds a fractional percentage", () => {
116100
const view = getComposerUsageView(
117-
provider({ windows: [{ label: "Session", usedPercent: 14.6, windowDurationMins: 300 }] }),
101+
account({ windows: [{ label: "Session", usedPercent: 14.6, windowDurationMins: 300 }] }),
118102
NOW,
119103
);
120104

@@ -123,7 +107,7 @@ describe("getComposerUsageView", () => {
123107

124108
it("carries the account name and accent for the tooltip and dot", () => {
125109
const view = getComposerUsageView(
126-
provider({ windows: CLAUDE_WINDOWS, displayName: "Claude Work", accentColor: "#0088ff" }),
110+
account({ windows: CLAUDE_WINDOWS, displayName: "Claude Work", accentColor: "#0088ff" }),
127111
NOW,
128112
);
129113

@@ -133,22 +117,41 @@ describe("getComposerUsageView", () => {
133117

134118
it("renders one entry when only a session window is reported", () => {
135119
const view = getComposerUsageView(
136-
provider({ windows: [{ label: "Session", usedPercent: 5, windowDurationMins: 300 }] }),
120+
account({ windows: [{ label: "Session", usedPercent: 5, windowDurationMins: 300 }] }),
137121
NOW,
138122
);
139123

140124
expect(view?.entries).toHaveLength(1);
141125
expect(view?.entries[0]?.label).toBe("5h");
142126
});
143127

128+
// Dimming is the strip's whole way of admitting a number has fallen behind
129+
// the server's own poll, so the bound must track that poll.
130+
it("marks a reading stale only past the caller's bound", () => {
131+
const aged = account({ windows: CLAUDE_WINDOWS, checkedAt: "2026-08-06T11:54:00.000Z" });
132+
133+
expect(getComposerUsageView(aged, NOW, 5 * 60_000)?.stale).toBe(true);
134+
expect(getComposerUsageView(aged, NOW, 10 * 60_000)?.stale).toBe(false);
135+
expect(getComposerUsageView(aged, NOW)?.age).toBe("6m");
136+
});
137+
138+
it("has no age for a reading under a minute old", () => {
139+
const view = getComposerUsageView(
140+
account({ windows: CLAUDE_WINDOWS, checkedAt: "2026-08-06T11:59:30.000Z" }),
141+
NOW,
142+
);
143+
144+
expect(view?.age).toBeUndefined();
145+
expect(view?.stale).toBe(false);
146+
});
147+
144148
// Nothing to say beats a placeholder in a strip this dense.
145149
it.each([
146-
["no provider", null],
147-
["a provider with no usage", provider({})],
148-
["a provider reporting zero windows", provider({ windows: [] })],
150+
["no account", null],
151+
["an account reporting zero windows", account({ windows: [] })],
149152
[
150153
"windows with no duration to classify",
151-
provider({ windows: [{ label: "Mystery", usedPercent: 10 }] }),
154+
account({ windows: [{ label: "Mystery", usedPercent: 10 }] }),
152155
],
153156
])("returns null for %s", (_label, input) => {
154157
expect(getComposerUsageView(input, NOW)).toBeNull();

0 commit comments

Comments
 (0)