Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d41b1ee
chore(frontend): bump A2A SDK, MCP SDK, AI SDK v6, streamdown v2
malinskibeniamin Apr 19, 2026
f7a51d0
fix(frontend): adapt useContextUsage to ai v6 LanguageModelUsage shape
malinskibeniamin Apr 19, 2026
09e9059
feat(frontend): adopt MCP approval states and dynamic tools in ai-ele…
malinskibeniamin Apr 19, 2026
7de96be
feat(frontend): adopt ConversationDownload and messagesToMarkdown
malinskibeniamin Apr 19, 2026
a58da54
refactor(frontend): re-sync low-risk ai-elements with upstream
malinskibeniamin Apr 19, 2026
853c6b3
chore(frontend): revert MCP SDK bump to keep it in separate PR
malinskibeniamin Apr 19, 2026
87c8531
fix(frontend): guard Context components against divide-by-zero
malinskibeniamin Apr 19, 2026
588f687
test(frontend): extend Tool approval-flow and output-rendering coverage
malinskibeniamin Apr 19, 2026
a0cb227
test(frontend): cover Shimmer motion-cache and Response streamdown v2
malinskibeniamin Apr 19, 2026
40004d9
test(frontend): cover A2A adapter mapFinishReason and getResponseMeta…
malinskibeniamin Apr 19, 2026
1da7d40
test(frontend): add showcase browser tests + screenshots for PR #2389
malinskibeniamin Apr 19, 2026
61d4116
style(frontend): apply biome formatter to a2a adapter test
malinskibeniamin Apr 19, 2026
7acf04c
refactor(frontend): drop stale @ts-ignore and dead activeTextIds in A…
malinskibeniamin Apr 19, 2026
38b359f
refactor(frontend): remove no-op text-delta handler
malinskibeniamin Apr 19, 2026
821e52a
fix(frontend): copy button title uses displayName not raw toolName
malinskibeniamin Apr 19, 2026
8fbe849
docs(frontend): regenerate PR screenshots with real theme styling
malinskibeniamin Apr 20, 2026
c4bdcb2
refactor(frontend): extract pure a2aEventToV2StreamParts from doStream
malinskibeniamin Apr 20, 2026
2f5cdb2
test(frontend): cover a2aEventToV2StreamParts per-state branches
malinskibeniamin Apr 20, 2026
59b32eb
refactor(frontend): extract parseA2AError into its own util
malinskibeniamin Apr 20, 2026
1e1ec24
test(frontend): table-driven coverage for parseA2AError
malinskibeniamin Apr 20, 2026
34b9465
chore(frontend): host PR screenshots on orphan branch instead of comm…
malinskibeniamin Apr 20, 2026
dc6a13f
chore(frontend): follow master to MCP SDK 1.29.0
malinskibeniamin Apr 20, 2026
09624c5
chore(repo): drop unneeded frontend/docs/pr-screenshots gitignore entry
malinskibeniamin Apr 20, 2026
3dfe7e0
chore(frontend): bump copyright year to 2026 across PR-added files
malinskibeniamin Apr 20, 2026
fc0e4e9
fix(frontend): harden Context divide-by-zero guard against NaN, Infin…
malinskibeniamin Apr 20, 2026
8b3498c
test(frontend): align ai-elements browser tests with ADP UI utility c…
malinskibeniamin Apr 20, 2026
4b0ad30
docs(frontend): clarify why Image drops uint8Array and renders from b…
malinskibeniamin Apr 20, 2026
3a84337
feat(frontend): reject unknown A2A stream events with UnsupportedFunc…
malinskibeniamin Apr 20, 2026
fcae41e
feat(frontend): extend parseA2AError with title and actionable hint f…
malinskibeniamin Apr 20, 2026
ed95e1c
style(frontend): satisfy biome formatter and top-level-regex rule on …
malinskibeniamin Apr 20, 2026
ec89a98
chore(frontend): remove committed mcp inspector screenshot baseline
malinskibeniamin Apr 20, 2026
81f3e6f
fix(frontend): stop double-dispatching prompts in A2A doStream
malinskibeniamin Apr 20, 2026
47069d9
fix(frontend): distinguish ToolOutput error container from success
malinskibeniamin Apr 20, 2026
65573f9
fix(frontend): capture nested Data payloads in parseA2AError regex
malinskibeniamin Apr 20, 2026
a566c64
refactor(frontend): extract chooseA2ASourceStream for testable transp…
malinskibeniamin Apr 20, 2026
9452184
style(frontend): satisfy biome rules in a2a tests (ci-lint fix)
malinskibeniamin Apr 20, 2026
52d70db
test(frontend): wrap MCP streaming hook state-updates in act()
malinskibeniamin Apr 20, 2026
7491134
style(frontend): reorder imports per biome autoformat
malinskibeniamin Apr 20, 2026
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ requests.txt
# Local build tools installed via Taskfiles
build

.cursor
.cursor
86 changes: 34 additions & 52 deletions frontend/bun.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"immutable": "^5.1.5"
},
"dependencies": {
"@a2a-js/sdk": "^0.3.10",
"@a2a-js/sdk": "^0.3.13",
"@autoform/react": "^4.0.0",
"@autoform/zod": "^5.0.0",
"@buf/redpandadata_ai-gateway.bufbuild_es": "^2.11.0-20260313141452-dbbaece03f76.1",
Expand Down Expand Up @@ -101,7 +101,7 @@
"@tanstack/zod-adapter": "^1.158.0",
"@types/prismjs": "^1.26.5",
"@xyflow/react": "^12.9.2",
"ai": "^5.0.101",
"ai": "^6.0.168",
"array-move": "^4.0.0",
"chakra-react-select": "5.0.5",
"class-variance-authority": "^0.7.1",
Expand Down Expand Up @@ -149,7 +149,7 @@
"shiki": "^3.15.0",
"sonner": "^2.0.7",
"stacktrace-js": "^2.0.2",
"streamdown": "^1.4.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tokenlens": "^1.3.1",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/__tests__/browser-test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,25 @@ export function ScreenshotFrame({ children, width = 1200 }: { children: React.Re
</MotionConfig>
);
}

/**
* Default directory (relative to a browser test file) where PR showcase
* screenshots are written. Mirrors the ADP UI convention of keeping
* documentation screenshots in a single repository-level folder rather
* than scattering them next to the tests.
*/
export const PR_SCREENSHOT_DIR = '../../../docs/pr-screenshots';

/**
* Capture a PNG of the stable `ScreenshotFrame` wrapper for use in PR
* documentation. Use this for showcase / documentation screenshots that
* are not compared against a baseline. For visual regression assertions
* prefer Vitest's `expect(locator).toMatchScreenshot(...)` instead.
*/
export async function captureScreenshotFrame(
locator: { screenshot: (opts: { path: string }) => Promise<unknown> },
name: string,
dir = PR_SCREENSHOT_DIR
): Promise<void> {
await locator.screenshot({ path: `${dir}/${name}.png` });
}
134 changes: 134 additions & 0 deletions frontend/src/components/ai-elements/context.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Copyright 2026 Redpanda Data, Inc.
*
* Use of this software is governed by the Business Source License
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
*
* As of the Change Date specified in that file, in accordance with
* the Business Source License, use of this software will be governed
* by the Apache License, Version 2.0
*/

import type { LanguageModelUsage } from 'ai';
import { useRef, useState, useEffect, type ReactNode } from 'react';
import { afterEach, describe, test } from 'vitest';
import { page } from 'vitest/browser';
import { cleanup, render } from 'vitest-browser-react';

import { captureScreenshotFrame, ScreenshotFrame } from '../../__tests__/browser-test-utils';
import {
Context,
ContextCacheUsage,
ContextContent,
ContextContentBody,
ContextContentFooter,
ContextContentHeader,
ContextInputUsage,
ContextOutputUsage,
ContextReasoningUsage,
ContextTrigger,
} from './context';

afterEach(() => {
cleanup();
});

// Render the HoverCardContent portal inside the screenshot frame so the whole
// card (trigger + popup body) is captured by a single element screenshot.
const PortalWithinFrame = ({
children,
open,
}: {
children: (container: Element | undefined) => ReactNode;
open: boolean;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const [container, setContainer] = useState<Element | undefined>(undefined);
useEffect(() => {
if (ref.current) {
setContainer(ref.current);
}
}, []);
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
{open ? children(container) : children(undefined)}
</div>
);
};

const ContextPanel = ({
usedTokens,
maxTokens,
usage,
modelId,
}: {
usedTokens: number;
maxTokens: number;
usage?: LanguageModelUsage;
modelId?: string;
}) => (
<ScreenshotFrame width={520}>
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
<PortalWithinFrame open={true}>
{(container) => (
<Context
maxTokens={maxTokens}
modelId={modelId}
open={true}
usage={usage}
usedTokens={usedTokens}
>
<ContextTrigger />
<ContextContent align="start" container={container} side="bottom" sideOffset={8}>
<ContextContentHeader />
<ContextContentBody>
<ContextInputUsage />
<ContextOutputUsage />
<ContextReasoningUsage />
<ContextCacheUsage />
</ContextContentBody>
<ContextContentFooter />
</ContextContent>
</Context>
)}
</PortalWithinFrame>
</div>
</ScreenshotFrame>
);

const shot = (name: string) =>
captureScreenshotFrame(page.getByTestId('screenshot-frame'), name);

describe('Context hover-card screenshots', () => {
test('zero tokens (guards hide sub-rows)', async () => {
render(<ContextPanel maxTokens={200_000} usedTokens={0} />);
await shot('context-zero-tokens');
});

test('populated usage with all sub-objects', async () => {
render(
<ContextPanel
maxTokens={200_000}
modelId="anthropic/claude-3-5-sonnet"
usage={{
inputTokens: 18_420,
outputTokens: 2_316,
reasoningTokens: 412,
cachedInputTokens: 5_800,
totalTokens: 21_148,
inputTokenDetails: {
noCacheTokens: 12_620,
cacheReadTokens: 5_800,
cacheWriteTokens: 0,
},
outputTokenDetails: {
textTokens: 1_904,
reasoningTokens: 412,
},
}}
usedTokens={21_148}
/>
);
await shot('context-populated');
});
});
199 changes: 199 additions & 0 deletions frontend/src/components/ai-elements/context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* Copyright 2026 Redpanda Data, Inc.
*
* Use of this software is governed by the Business Source License
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
*
* As of the Change Date specified in that file, in accordance with
* the Business Source License, use of this software will be governed
* by the Apache License, Version 2.0
*/

import { render, screen } from '@testing-library/react';
import type { LanguageModelUsage } from 'ai';
import { describe, expect, test } from 'vitest';

import {
Context,
ContextContent,
ContextContentFooter,
ContextContentHeader,
ContextInputUsage,
ContextOutputUsage,
ContextTrigger,
} from './context';

// ---------------------------------------------------------------------------
// Zero-token / edge-case guards for ContextContentHeader
// ---------------------------------------------------------------------------

describe('ContextContentHeader', () => {
test('renders 0% and 0 / 0 when both used and max tokens are zero', () => {
// This is the degenerate case that shows up when a conversation hasn't
// emitted any usage events yet. It must not render NaN, Infinity, or
// throw a divide-by-zero.
render(
<Context maxTokens={0} usedTokens={0}>
<ContextTrigger />
<ContextContent>
<ContextContentHeader />
</ContextContent>
</Context>
);

// The trigger's percentage label should be 0% (Intl formats NaN as "NaN%"
// which is a regression signal for a divide-by-zero).
const triggerPct = screen.getByRole('button');
expect(triggerPct.textContent ?? '').not.toMatch(/NaN|Infinity/);
});

test('renders a sane percentage when usage is partial', () => {
render(
<Context maxTokens={1000} usedTokens={250}>
<ContextTrigger />
</Context>
);
const trigger = screen.getByRole('button');
expect(trigger.textContent ?? '').toContain('25%');
});

// Defensive: guard against every non-finite / invalid input the backend
// could send us. In all of these cases the rendered label must collapse to
// 0% rather than NaN%, Infinity%, or a negative percentage.
test.each<[string, number, number]>([
['maxTokens is NaN', 100, Number.NaN],
['maxTokens is Infinity', 100, Number.POSITIVE_INFINITY],
['maxTokens is -Infinity', 100, Number.NEGATIVE_INFINITY],
['maxTokens is negative', 100, -1],
['usedTokens is NaN', Number.NaN, 100],
['usedTokens is Infinity', Number.POSITIVE_INFINITY, 100],
['usedTokens is negative', -50, 100],
])('renders 0%% and never NaN/Infinity when %s', (_label, usedTokens, maxTokens) => {
render(
<Context maxTokens={maxTokens} usedTokens={usedTokens}>
<ContextTrigger />
</Context>
);
const trigger = screen.getByRole('button');
const text = trigger.textContent ?? '';
expect(text).not.toMatch(/NaN|Infinity/);
expect(text).toContain('0%');
});
});

// ---------------------------------------------------------------------------
// ContextInputUsage / ContextOutputUsage zero-token suppression
// ---------------------------------------------------------------------------

describe('ContextInputUsage / ContextOutputUsage zero-token guard', () => {
const zeroUsage: LanguageModelUsage = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};

test('ContextInputUsage renders nothing when inputTokens is 0', () => {
const { container } = render(
<Context maxTokens={100} usage={zeroUsage} usedTokens={0}>
<ContextContent>
<ContextInputUsage />
</ContextContent>
</Context>
);
// The component short-circuits to `null` for zero input tokens; the
// HoverCard content is closed by default, so the input row never renders.
expect(container.querySelector('[data-slot="input-usage"]')).toBeNull();
// Explicitly, no "Input" label should leak out.
expect(screen.queryByText('Input')).toBeNull();
});

test('ContextOutputUsage renders nothing when outputTokens is 0', () => {
render(
<Context maxTokens={100} usage={zeroUsage} usedTokens={0}>
<ContextContent>
<ContextOutputUsage />
</ContextContent>
</Context>
);
expect(screen.queryByText('Output')).toBeNull();
});

test('ContextContentFooter renders $0.00 when no modelId is provided', () => {
// Without a modelId we can't look up per-token pricing; the footer should
// fall back to $0.00 (not NaN) even when usage is zero.
render(
<Context maxTokens={100} usage={zeroUsage} usedTokens={0}>
<ContextContent>
<ContextContentFooter />
</ContextContent>
</Context>
);
// Footer is inside a closed hover card by default, so we just assert no
// NaN / Infinity leaks out of the subtree.
const pretty = document.body.textContent ?? '';
expect(pretty).not.toMatch(/NaN|Infinity/);
});
});

// ---------------------------------------------------------------------------
// Memoisation of provider value
// ---------------------------------------------------------------------------

describe('Context provider memoisation', () => {
test('provider value identity is stable across re-renders when props do not change', () => {
// We read the internal React context by swapping in a probe that captures
// the value reference seen on each render. Because Context's state
// container is module-private, we instead assert stability indirectly:
// the `useContextUsage`-style consumer would re-memoise its derived work
// from the stable value — we observe that here by checking the identity
// of the usage object we pass through remains referentially stable and
// that consumer renders the same count of text nodes.

const usage: LanguageModelUsage = {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
};

const { rerender, container } = render(
<Context maxTokens={100} usage={usage} usedTokens={15}>
<ContextTrigger />
</Context>
);
const firstTriggerText = container.textContent;
// Re-render with the exact same object references — no changes.
rerender(
<Context maxTokens={100} usage={usage} usedTokens={15}>
<ContextTrigger />
</Context>
);
expect(container.textContent).toBe(firstTriggerText);
});

test('changing usage props produces a new derived output', () => {
const usageA: LanguageModelUsage = {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
};
const usageB: LanguageModelUsage = {
inputTokens: 20,
outputTokens: 10,
totalTokens: 30,
};

const { rerender, container } = render(
<Context maxTokens={100} usage={usageA} usedTokens={15}>
<ContextTrigger />
</Context>
);
const first = container.textContent ?? '';
rerender(
<Context maxTokens={100} usage={usageB} usedTokens={30}>
<ContextTrigger />
</Context>
);
const second = container.textContent ?? '';
expect(first).not.toBe(second);
});
});
Loading
Loading