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
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