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
19 changes: 17 additions & 2 deletions apps/web/src/features/chat/kit/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import {
isPaidFeatureTool,
} from "#renderer/lib/bot-turns/turns";
import { cn } from "#renderer/lib/cn";
import { creditsTier } from "#renderer/lib/credits";
import { useAccount } from "#renderer/lib/use-account";
import {
Attachment,
AttachmentContent,
Expand Down Expand Up @@ -389,14 +391,27 @@ const UserMessage = ({
};

const Credits = ({ message }: { message: UIMessage }) => {
const { t } = useTranslation();
const credits = (
message.metadata as
| { abacus?: { credits?: Array<{ creditsUsed: number }> } }
| undefined
)?.abacus?.credits;
if (credits == null || credits.length === 0) return null;
const total = credits.reduce((sum, item) => sum + item.creditsUsed, 0);
return (
<CreditsLine
total={credits.reduce((sum, item) => sum + item.creditsUsed, 0)}
/>
);
};

/**
* Paid plans only: a free account sees no per-message count (its low-balance
* cards say when it runs low), and nothing shows while the plan is unread.
*/
const CreditsLine = ({ total }: { total: number }) => {
const { t } = useTranslation();
const tier = creditsTier(useAccount().data);
if (tier === "free" || tier === "unknown") return null;
return (
<div
className="text-muted-foreground text-xs select-none"
Expand Down
47 changes: 45 additions & 2 deletions apps/web/src/features/chat/kit/parts.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { contract } from "@abacus-ai/contract/contract";
import { v1ToUiMessages } from "@abacus-ai/contract/transcript/v1-to-ui-messages";
/**
* R2-T11 (spec 02 §5.3): the exported `SessionUI`/`BotUI` configurations
Expand All @@ -6,6 +7,7 @@ import { v1ToUiMessages } from "@abacus-ai/contract/transcript/v1-to-ui-messages
* helpers (reminders stripped, routine fires hidden, attachment chips); an
* empty assistant message renders nothing (F12).
*/
import { implement } from "@orpc/server";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

Expand Down Expand Up @@ -65,13 +67,54 @@ describe("R2-T11 parts", () => {
).toBeTruthy();
});

it("credits footers", async () => {
await migrated("credits");
const onPlan = (
subscription_tier: string
): Parameters<typeof renderRelay>[5] => {
const os = implement(contract);
return {
account: {
abacus: os.account.abacus.handler(
() =>
({
user_id: "u1",
plan: subscription_tier,
subscription_tier,
}) as never
),
},
};
};

it("credits footers on a paid plan", async () => {
current = await renderRelay(
new FakeRelay({ history: fixture("credits") }),
"session",
{},
{},
undefined,
onPlan("basic")
);
expect(
(await screen.findAllByText(/Used \d+ credits?/)).length
).toBeGreaterThan(0);
});

it("no credits footer on the free plan", async () => {
current = await renderRelay(
new FakeRelay({ history: fixture("credits") }),
"session",
{},
{},
undefined,
onPlan("free")
);
await waitFor(() =>
expect(document.querySelector('[data-role="assistant"]')).toBeTruthy()
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.queryByText(/Used \d+ credits?/)).toBeNull();
});

it("thinking, collapsible and feature limit", async () => {
await migrated("display-segments");
await screen.findByText("Done.");
Expand Down
26 changes: 12 additions & 14 deletions apps/web/src/features/chat/kit/status.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1852,6 +1852,7 @@ describe("the turn's credits", () => {
credits_used: 618,
credits_granted: 2000,
};
const BASIC = { ...ACCOUNT, plan: "Basic", subscription_tier: "basic" };
const turn = (credits: unknown) => {
const relay = new FakeRelay();
relay.emitAll([
Expand Down Expand Up @@ -1888,16 +1889,11 @@ describe("the turn's credits", () => {
afterEach(() => closeUpgradeDialog());

it.each([
["a Basic turn, with what is left", BASIC, 18.4, "18 credits · 1,382 left"],
["a tiny turn", BASIC, 0.3, "<1 credit · 1,382 left"],
[
"a free turn, with what is left",
ACCOUNT,
18.4,
"18 credits · 1,382 left",
],
["a tiny turn", ACCOUNT, 0.3, "<1 credit · 1,382 left"],
[
"a paid turn",
{ ...ACCOUNT, plan: "Basic", subscription_tier: "basic" },
"a Pro turn",
{ ...ACCOUNT, plan: "Pro", subscription_tier: "pro" },
1240.6,
"1,241 credits · 1,382 left",
],
Expand All @@ -1917,9 +1913,11 @@ describe("the turn's credits", () => {
});

it.each([
["an own-key turn (no Abacus request)", ACCOUNT, undefined],
["a turn whose charge is unknown", ACCOUNT, null],
["a free-model turn", ACCOUNT, 0],
["a free account's Abacus turn", ACCOUNT, 18.4],
["a free account's tiny turn", ACCOUNT, 0.3],
["an own-key turn (no Abacus request)", BASIC, undefined],
["a turn whose charge is unknown", BASIC, null],
["a free-model turn", BASIC, 0],
[
"an account with no plan to offer",
{ ...ACCOUNT, plan: null, subscription_tier: null },
Expand Down Expand Up @@ -1947,7 +1945,7 @@ describe("the turn's credits", () => {
{},
{},
undefined,
served(ACCOUNT, funnel)
served(BASIC, funnel)
);
fireEvent.click(await screen.findByRole("button", { name: /^18 credits/ }));
expect(upgradeDialogStore.state.request).toMatchObject({
Expand All @@ -1957,7 +1955,7 @@ describe("the turn's credits", () => {
await waitFor(() =>
expect(funnel).toHaveBeenCalledWith({
step: "turn_credits_clicked",
detail: "free",
detail: "basic",
})
);
});
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/features/chat/kit/status/turn-credits.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* A finished turn's Abacus credits beside its "Done in", and on the latest
* turn what is left; a click opens the plan (free) or the top-up and next
* plan (paid). Only a turn whose own requests reported their charge shows it.
* A paid turn's Abacus credits beside its "Done in", and on the latest turn
* what is left; a click opens the top-up and next plan. Only a turn whose own
* requests reported their charge shows it. A free account sees no per-turn
* count: its credit cards and the out-of-credits dialog say when it runs low.
*/
import type { RunOutcomeRecord } from "@abacus-ai/contract/contract/ai-thread";
import { creditAccountKey } from "@abacus-ai/contract/credit-thresholds";
Expand Down Expand Up @@ -67,7 +68,7 @@ export const TurnCredits = ({
}, [latest, shown, outcome.runId, outcome.startedAt, credits]);

const tier = creditsTier(data);
if (!shown || tier === "unknown") return null;
if (!shown || tier === "free" || tier === "unknown") return null;
const left = latest ? leftAfter(ceiling, key, outcome) : null;
const number = new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
Expand Down
Loading