Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import { Suspense } from 'react';
import { CloudChatPage } from '@/components/cloud-agent-next/CloudChatPage';

export function CloudChatPageWrapperNext() {
export function CloudChatPageWrapperNext({ currentUserId }: { currentUserId: string }) {
return (
<Suspense
fallback={<div className="flex h-screen items-center justify-center">Loading...</div>}
>
<CloudChatPage />
<CloudChatPage currentUserId={currentUserId} />
</Suspense>
);
}
4 changes: 2 additions & 2 deletions apps/web/src/app/(app)/cloud/(agent)/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ type PageProps = {
};

export default async function PersonalCloudChatPage({ searchParams }: PageProps) {
await getUserFromAuthOrRedirect('/users/sign_in?callbackPath=/cloud/chat');
const user = await getUserFromAuthOrRedirect('/users/sign_in?callbackPath=/cloud/chat');
const { sessionId } = await searchParams;

if (!sessionId || isNewSession(sessionId)) {
return <CloudChatPageWrapperNext />;
return <CloudChatPageWrapperNext currentUserId={user.id} />;
}

return <LegacySessionViewer sessionId={sessionId} />;
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/(app)/cloud/(agent)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ export default async function PersonalCloudPage() {
user.id
);

return <NewSessionPanel isDevcontainerAvailable={isDevcontainerAvailable} />;
return (
<NewSessionPanel currentUserId={user.id} isDevcontainerAvailable={isDevcontainerAvailable} />
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,31 @@

import { Suspense } from 'react';
import { CloudChatPage } from '@/components/cloud-agent-next/CloudChatPage';
import type { OrganizationRole } from '@/lib/organizations/organization-types';

type CloudChatPageWrapperNextProps = {
organizationId: string;
organizationName?: string;
organizationRole: OrganizationRole;
currentUserId: string;
};

export function CloudChatPageWrapperNext({ organizationId }: CloudChatPageWrapperNextProps) {
export function CloudChatPageWrapperNext({
organizationId,
organizationName,
organizationRole,
currentUserId,
}: CloudChatPageWrapperNextProps) {
return (
<Suspense
fallback={<div className="flex h-screen items-center justify-center">Loading...</div>}
>
<CloudChatPage organizationId={organizationId} />
<CloudChatPage
organizationId={organizationId}
organizationName={organizationName}
organizationRole={organizationRole}
currentUserId={currentUserId}
/>
</Suspense>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ export default async function OrganizationCloudChatPage({ params, searchParams }
const { sessionId } = await searchParams;

if (!sessionId || isNewSession(sessionId)) {
return <CloudChatPageWrapperNext organizationId={organizationId} />;
return (
<CloudChatPageWrapperNext
organizationId={organizationId}
organizationName={result.data.organization.name}
organizationRole={result.data.user.role}
currentUserId={result.data.user.id}
/>
);
}

return <LegacySessionViewer sessionId={sessionId} organizationId={organizationId} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default async function OrganizationCloudPage({
}) {
const { id } = await params;
const organizationId = decodeURIComponent(id);
await getUserFromAuthOrRedirect(
const user = await getUserFromAuthOrRedirect(
`/users/sign_in?callbackPath=${encodeURIComponent(`/organizations/${organizationId}/cloud`)}`
);
const isDevcontainerAvailable = await isFeatureFlagEnabledOrDevelopment(
Expand All @@ -21,9 +21,12 @@ export default async function OrganizationCloudPage({
return (
<OrganizationByPageLayout
params={params}
render={({ organization }) => (
render={({ organization, role }) => (
<NewSessionPanel
currentUserId={user.id}
organizationId={organization.id}
organizationName={organization.name}
organizationRole={role}
isDevcontainerAvailable={isDevcontainerAvailable}
/>
)}
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/cloud-agent-next/ChatHeader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from '@jest/globals';
import { computeBillingLabel, computeBillingRefetchInterval } from './ChatHeader';

const active = {
billingMode: 'paid' as const,
phase: 'active' as const,
estimatedHourlyRateMicrodollars: null,
estimatedIntervalAmountMicrodollars: null,
};

describe('computeBillingLabel', () => {
it.each([
['payer_shared', 'Shared compute so far · pricing unavailable'],
['session', 'Compute so far · pricing unavailable'],
] as const)('does not show $0.00 when active %s pricing is unavailable', (attribution, label) => {
expect(computeBillingLabel({ ...active, attribution })).toBe(label);
});
});

describe('computeBillingRefetchInterval', () => {
it('stops polling while idle and resumes when the live session becomes active', () => {
expect(computeBillingRefetchInterval(false, 'idle')).toBe(false);
expect(computeBillingRefetchInterval(true, 'idle')).toBe(5_000);
});
});
101 changes: 96 additions & 5 deletions apps/web/src/components/cloud-agent-next/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
Expand All @@ -15,6 +16,56 @@ import { SessionActionsDialog } from './SessionActionsDialog';
import { SoundToggleButton } from '@/components/shared/SoundToggleButton';
import { FeedbackDialog } from './FeedbackDialog';
import { buildRepoBrowseUrl, detectGitPlatform } from './utils/git-utils';
import { useTRPC } from '@/lib/trpc/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';

function formatRate(microdollars: number | null): string {
return microdollars === null
? 'pricing unavailable'
: `$${(microdollars / 1_000_000).toFixed(2)}/hour`;
}

export function computeBillingLabel(
computeStatus:
| {
billingMode: 'shadow' | 'paid' | null;
phase: 'idle' | 'active' | 'stopping' | 'settling' | 'unavailable';
attribution: 'payer_shared' | 'session';
estimatedHourlyRateMicrodollars: number | null;
estimatedIntervalAmountMicrodollars: number | null;
}
| null
| undefined
): string | null {
if (computeStatus?.billingMode === 'shadow') {
return `Compute est. ${formatRate(computeStatus.estimatedHourlyRateMicrodollars)} · Not currently charged`;
}
if (computeStatus?.phase === 'active') {
const prefix =
computeStatus.attribution === 'payer_shared' ? 'Shared compute so far' : 'Compute so far';
return computeStatus.estimatedIntervalAmountMicrodollars === null
? `${prefix} · pricing unavailable`
: `${prefix} · est. $${(computeStatus.estimatedIntervalAmountMicrodollars / 1_000_000).toFixed(2)}`;
}
if (computeStatus?.phase === 'stopping' || computeStatus?.phase === 'settling') {
return 'Saving and stopping compute';
}
if (computeStatus?.phase === 'idle') {
return computeStatus.estimatedHourlyRateMicrodollars === null
? 'Compute pricing unavailable'
: `Compute est. ${formatRate(computeStatus.estimatedHourlyRateMicrodollars)}`;
}
return null;
}

export function computeBillingRefetchInterval(
sessionActive: boolean,
phase: 'idle' | 'active' | 'stopping' | 'settling' | 'unavailable' | undefined
): number | false {
return sessionActive || phase === 'active' || phase === 'stopping' || phase === 'settling'
? 5_000
: false;
}

type ChatHeaderProps = {
cloudAgentSessionId: string;
Expand All @@ -25,10 +76,11 @@ type ChatHeaderProps = {
gitUrl?: string | null;
model?: string;
modelDisplayName?: string;
totalCost?: number;
tokenUsage?: number;
soundEnabled?: boolean;
onToggleSound?: () => void;
sessionTitle?: string;
sessionActive: boolean;
};

export function ChatHeader({
Expand All @@ -38,15 +90,36 @@ export function ChatHeader({
gitUrl,
model = 'Unknown',
modelDisplayName,
totalCost = 0,
tokenUsage = 0,
soundEnabled = true,
onToggleSound,
kiloSessionId,
organizationId,
sessionTitle,
sessionActive,
}: ChatHeaderProps) {
const [showInfoDialog, setShowInfoDialog] = useState(false);
const [showActionsDialog, setShowActionsDialog] = useState(false);
const trpc = useTRPC();
const computeQuery = useQuery({
...(organizationId
? trpc.organizations.cloudAgentNext.getComputeBillingStatus.queryOptions({
organizationId,
cloudAgentSessionId,
})
: trpc.cloudAgentNext.getComputeBillingStatus.queryOptions({ cloudAgentSessionId })),
enabled: cloudAgentSessionId.startsWith('agent_'),
refetchInterval: query => {
return computeBillingRefetchInterval(sessionActive, query.state.data?.phase);
},
});
const wasSessionActive = useRef(sessionActive);
useEffect(() => {
if (sessionActive && !wasSessionActive.current) void computeQuery.refetch();
wasSessionActive.current = sessionActive;
}, [computeQuery.refetch, sessionActive]);
const computeStatus = computeQuery.data;
const computeLabel = computeBillingLabel(computeStatus);

const browseUrl = buildRepoBrowseUrl(gitUrl);
const repoUrl =
Expand All @@ -63,7 +136,8 @@ export function ChatHeader({
kiloSessionId={kiloSessionId}
model={model}
modelDisplayName={modelDisplayName}
cost={totalCost * 1_000_000}
tokenUsageMicrodollars={tokenUsage * 1_000_000}
computeStatus={computeStatus}
/>
<SessionActionsDialog
open={showActionsDialog}
Expand All @@ -72,7 +146,24 @@ export function ChatHeader({
sessionTitle={sessionTitle}
repository={repository}
/>
<div className="flex items-center gap-1">
<div className="flex min-w-0 items-center gap-1">
{computeLabel && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground max-w-48 truncate px-1 font-mono text-xs tabular-nums">
{computeLabel}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{computeLabel}</p>
<p>
{computeStatus?.attribution === 'payer_shared'
? 'Based on how long the shared sandbox has run. It may include other sessions. Final after it stops.'
: 'Based on how long this sandbox has run. Final after it stops.'}
</p>
</TooltipContent>
</Tooltip>
)}
{onToggleSound && (
<SoundToggleButton enabled={soundEnabled} onToggle={onToggleSound} size="sm" />
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from '@jest/globals';
import { createElement, type ComponentProps } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) }));

import {
billingBalanceCopy,
CloudAgentBillingError,
currentPaymentReturnPath,
} from './CloudAgentBillingError';

type BillingErrorProps = ComponentProps<typeof CloudAgentBillingError>;

const personal = { payerName: 'Your account', action: { href: '/credits', label: 'Add credits' } };
const orgMember = {
payerName: 'Acme Engineering',
action: {
href: '/organizations/org-1/payment-details',
label: 'View organization billing',
memberGuidance: true,
},
};

function renderBillingError(props: BillingErrorProps): string {
return renderToStaticMarkup(createElement(CloudAgentBillingError, props));
}

describe('CloudAgentBillingError', () => {
it('renders an accessible personal 402 with both balances and action', () => {
const html = renderBillingError({
failure: {
code: 'INSUFFICIENT_CREDITS',
payer: { type: 'user', id: 'u1' },
retryable: false,
remainingMicrodollars: 1_250_000,
minimumRequiredMicrodollars: 2_000_000,
},
presentation: personal,
});
expect(html).toContain('role="alert"');
expect(html).toContain('Your account needs more credits to start Cloud Agent compute.');
expect(html).toContain('Available: $1.25 · Need more than $2.00');
expect(html).toContain('Your prompt did not start.');
expect(html).toContain('Add credits');
});

it.each([
['remainingMicrodollars', 'Available: $1.00'],
['minimumRequiredMicrodollars', 'Need more than $2.00'],
] as const)('renders %s independently', (key, text) => {
const html = renderBillingError({
failure: {
code: 'INSUFFICIENT_CREDITS',
payer: { type: 'org', id: 'org-1' },
retryable: false,
[key]: key === 'remainingMicrodollars' ? 1_000_000 : 2_000_000,
},
presentation: orgMember,
});
expect(html).toContain(text);
expect(html).toContain('Acme Engineering');
expect(html).toContain('View organization billing');
expect(html).toContain('An organization owner, admin, or billing manager can add credits.');
});

it.each([
[
'COMPUTE_STOPPING',
'Cloud Agent is saving and stopping compute. Your prompt has not started. Try again after shutdown completes.',
],
[
'BILLING_UNAVAILABLE',
'Cloud Agent cannot verify compute billing right now. Your prompt has not started and you have not been charged.',
],
] as const)('renders exact %s core copy without an action', (code, copy) => {
const html = renderBillingError({
failure: { code, payer: { type: 'user', id: 'u1' }, retryable: true },
presentation: { payerName: 'Your account' },
});
expect(html).toContain(copy);
expect(html).not.toContain('href=');
});

it('keeps the complete query string for a payment return', () => {
expect(
currentPaymentReturnPath({
pathname: '/cloud/chat',
search: '?sessionId=ses-1&tab=chat',
} as Location)
).toBe('/cloud/chat?sessionId=ses-1&tab=chat');
});

it('makes an equal balance threshold unambiguous', () => {
expect(
billingBalanceCopy({
code: 'INSUFFICIENT_CREDITS',
payer: { type: 'user', id: 'u1' },
retryable: false,
remainingMicrodollars: 5_000_000,
minimumRequiredMicrodollars: 5_000_000,
})
).toBe('Available: $5.00 · Need more than $5.00');
});
});
Loading