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
4 changes: 2 additions & 2 deletions app/src/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface GlobalConfig {
};
isDev: boolean;
features: {
/** The admin-only "Use with my data" agent chat (`/debug/agent`). */
/** The "Use with my data" agent chat (`/agent`, and `/debug/agent` for admins). */
agentChat: boolean;
};
}
Expand All @@ -22,7 +22,7 @@ const apiBaseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
/**
* Build-time switch for the agent chat (`docs/concepts/agent-network.md`).
* Vite inlines the variable, so with the flag unset this is the literal
* `false` and the bundler drops every branch behind it, the `.adapt()` probe
* `false` and the bundler drops every branch behind it, the `.use_my_data()` probe
* included. The route in `src/routes/index.tsx` reads the variable inline for
* the same reason, so the chat page's chunk is not even built.
*/
Expand Down
4 changes: 2 additions & 2 deletions app/src/hooks/useAgentLaneOpen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Whether the public lane of the agent chat is open (`GET /agent/lane`), for
* the entry points: the plot page's button, the landing page's line and the
* public chat page itself.
* the entry points: the plot page's `.use_my_data()`, the landing page's line
* and the public chat page itself.
*
* The API answers `{"open": true}` while the owner has the lane open and 404
* while it is closed, so one switch on the server opens and closes every entry
Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/DebugPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('DebugPage', () => {
expect(screen.getAllByText('scatter-basic').length).toBeGreaterThan(0);
});
// A 200 from /debug/status marks this browser as an admin's, which lets
// the plot page show the agent chat's `.adapt()` button.
// the plot page show the agent chat's `.use_my_data()` entry.
expect(localStorage.getItem('anyplot.adminHint')).toBe('1');
});

Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/DebugPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const PING_HISTORY = 30;
// in sessionStorage so it survives reloads of the same tab without
// persisting across browser sessions (src/utils/adminAuth.ts).
// A 200 from /debug/status also sets the admin hint that lets the plot page
// show the agent chat's `.adapt()` button; a refusal clears it.
// show the agent chat's `.use_my_data()` entry; a refusal clears it.

export function DebugPage() {
const { trackPageview } = useAnalytics();
Expand Down
23 changes: 23 additions & 0 deletions app/src/pages/SpecPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ vi.mock('src/sections/spec-detail/SpecDetailView', () => ({
SpecDetailView: () => <div data-testid="spec-detail-view">SpecDetailView</div>,
}));

// The lane `useAgentEligibility` names for the pair; null is no entry (also while it probes).
const agentEligibility: { lane: 'admin' | 'public' | null } = { lane: null };
vi.mock('src/hooks/useAgentEligibility', () => ({
useAgentEligibility: () => agentEligibility.lane,
}));

const mockSpecData = {
id: 'scatter-basic',
title: 'Basic Scatter Plot',
Expand Down Expand Up @@ -85,6 +91,7 @@ beforeEach(() => {
vi.restoreAllMocks();
mockParams = { specId: 'scatter-basic' };
mockNavigate.mockReset();
agentEligibility.lane = null;
});

function mockFetchSuccess(data = mockSpecData) {
Expand Down Expand Up @@ -235,6 +242,22 @@ describe('SpecPage', () => {
});
});

it('puts .use_my_data() beside the carousel only for an eligible pair', async () => {
mockParams = { specId: 'scatter-basic', language: 'python', library: 'matplotlib' };
mockFetchSuccess();
const { rerender } = render(<SpecPage />);

await screen.findByRole('button', { name: 'Back to library comparison' });
expect(screen.queryByRole('button', { name: 'Use with my data' })).not.toBeInTheDocument();

agentEligibility.lane = 'public';
rerender(<SpecPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Use with my data' }));
expect(mockNavigate).toHaveBeenCalledWith(
'/agent?spec=scatter-basic&library=matplotlib&language=python&source=plot_page'
);
});

it('does not report a miss when the implementation exists', async () => {
mockParams = { specId: 'scatter-basic', language: 'python', library: 'matplotlib' };
mockFetchSuccess();
Expand Down
12 changes: 6 additions & 6 deletions app/src/pages/SpecPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,11 @@ export function SpecPage() {
[specId, trackEvent, mode, fetchCode]
);

// "Use with my data": for an eligible pair, while the agent chat is built.
// An admin gets the admin chat, through a full navigation so Cloudflare
// Access can intercept the /debug path; everyone else gets the public chat
// while its lane is open, through the router. The chat page records
// `agent_open{source: plot_page}`.
// "Use with my data" (`.use_my_data()` beside the library carousel): for an
// eligible pair, while the agent chat is built. An admin gets the admin chat,
// through a full navigation so Cloudflare Access can intercept the /debug
// path; everyone else gets the public chat while its lane is open, through
// the router. The chat page records `agent_open{source: plot_page}`.
const agentLane = useAgentEligibility(mode === 'detail' ? specId : null, currentImpl?.library_id);
const handleUseWithMyData = useCallback(() => {
if (!specId || !currentImpl) return;
Expand Down Expand Up @@ -624,6 +624,7 @@ export function SpecPage() {
selectedLibrary={selectedLibrary || ''}
onSelect={handleLibrarySelect}
onAll={() => navigate(specPath(specId!))}
onUseWithMyData={agentLane ? handleUseWithMyData : undefined}
/>

<SpecDetailView
Expand All @@ -641,7 +642,6 @@ export function SpecPage() {
onCopyCode={handleCopyCode}
onDownload={handleDownload}
onTrackEvent={trackEvent}
onUseWithMyData={agentLane ? handleUseWithMyData : undefined}
/>

<SpecTabs
Expand Down
29 changes: 21 additions & 8 deletions app/src/sections/landing/HeroSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,38 @@ vi.mock('src/sections/landing/TypewriterText', () => ({
const lane = vi.hoisted(() => ({ open: false as boolean | null }));
vi.mock('src/hooks/useAgentLaneOpen', () => ({ useAgentLaneOpen: () => lane.open }));

import { AGENT_LANE_LINE, HeroSection } from 'src/sections/landing/HeroSection';
import { AGENT_LANE_LABEL, AGENT_LANE_LINE, HeroSection } from 'src/sections/landing/HeroSection';

describe('HeroSection', () => {
beforeEach(() => {
trackEvent.mockClear();
lane.open = false;
});

it('points to the public agent chat only while its lane is open', () => {
it('announces .use_my_data() only while the public lane is open, leading to the catalogue', async () => {
const user = userEvent.setup();
const { rerender } = render(<HeroSection potd={null} />);
expect(screen.queryByText(new RegExp(AGENT_LANE_LINE))).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: AGENT_LANE_LABEL })).not.toBeInTheDocument();

lane.open = true;
rerender(<HeroSection potd={null} />);
const link = screen.getByRole('link', { name: new RegExp(AGENT_LANE_LINE) });
expect(link).toHaveAttribute(
'href',
'/agent?spec=scatter-basic&library=matplotlib&language=python&source=landing'
);
const link = screen.getByRole('link', { name: AGENT_LANE_LABEL });
expect(link).toHaveTextContent(`# ${AGENT_LANE_LINE} →`);
expect(link).toHaveTextContent('# new: pick any plot, then .use_my_data() →');
expect(link).not.toHaveTextContent(/experimental/i);
expect(link).toHaveAttribute('href', '/plots');

await user.click(link);
expect(trackEvent).toHaveBeenCalledWith('nav_click', {
source: 'hero_use_my_data',
target: '/plots',
});
});

it('keeps the line hidden while the lane probe is pending', () => {
lane.open = null;
render(<HeroSection potd={null} />);
expect(screen.queryByRole('link', { name: AGENT_LANE_LABEL })).not.toBeInTheDocument();
});

it('tracks the primary browse CTA, mcp link and github link', async () => {
Expand Down
29 changes: 21 additions & 8 deletions app/src/sections/landing/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,11 @@ export function HeroSection({ potd = null }: HeroSectionProps) {
/>
</Box>
</Box>
<AgentLaneLine />
<AgentLaneLine
onClick={() =>
trackEvent('nav_click', { source: 'hero_use_my_data', target: paths.plots })
}
/>
</Box>

{/* Right: terminal plot */}
Expand All @@ -222,19 +226,25 @@ export function HeroSection({ potd = null }: HeroSectionProps) {
}

/**
* The one line that points to the public agent chat, shown only while its lane is open
* (`useAgentLaneOpen`), so closing the lane on the server removes it too. It opens the chat
* on a plot everyone knows; `source=landing` feeds `agent_open`.
* The one line that announces "use with my data", shown only while the public agent lane
* is open (`useAgentLaneOpen`), so closing the lane on the server removes it too. It leads
* to the catalogue rather than into the chat: the entry is `.use_my_data()` beside a plot
* page's library carousel, so the visitor picks the plot first. Tracked as
* `nav_click{source: hero_use_my_data}`. No "experimental" here: the chat's notice says it.
*/
export const AGENT_LANE_LINE = 'experimental: adapt any plot to your own data';
export const AGENT_LANE_LINE = 'new: pick any plot, then .use_my_data()';
/** The line's accessible name, in words rather than the method-call style. */
export const AGENT_LANE_LABEL = 'New: pick any plot, then use it with my data';

function AgentLaneLine() {
function AgentLaneLine({ onClick }: { onClick: () => void }) {
const open = useAgentLaneOpen();
if (open !== true) return null;
return (
<Box
component={RouterLink}
to={paths.publicAgentChat('scatter-basic', 'matplotlib', 'python', 'landing')}
to={paths.plots}
onClick={onClick}
aria-label={AGENT_LANE_LABEL}
sx={{
animation: 'rise 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) 0.35s backwards',
display: 'inline-block',
Expand All @@ -252,7 +262,10 @@ function AgentLaneLine() {
},
}}
>
# {AGENT_LANE_LINE} →
{/* A no-break space keeps the arrow with `.use_my_data()` when a narrow phone wraps
the line. */}
# {AGENT_LANE_LINE}
{' '}→
</Box>
);
}
Expand Down
50 changes: 49 additions & 1 deletion app/src/sections/spec-detail/LibraryPills.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';

import { LibraryPills } from 'src/sections/spec-detail/LibraryPills';
import { LibraryPills, USE_MY_DATA_LABEL } from 'src/sections/spec-detail/LibraryPills';
import { render, screen, userEvent } from 'src/test-utils';

const mockImplementations = [
Expand Down Expand Up @@ -89,4 +89,52 @@ describe('LibraryPills', () => {

expect(screen.getAllByText('matplotlib').length).toBeGreaterThan(0);
});

it('shows .use_my_data() only when SpecPage passes onUseWithMyData', async () => {
const onUseWithMyData = vi.fn();
const user = userEvent.setup();
const props = {
implementations: mockImplementations,
selectedLibrary: 'matplotlib',
onSelect: vi.fn(),
onAll: vi.fn(),
};
const { rerender } = render(<LibraryPills {...props} />);
expect(screen.queryByRole('button', { name: USE_MY_DATA_LABEL })).not.toBeInTheDocument();

rerender(<LibraryPills {...props} onUseWithMyData={onUseWithMyData} />);
const action = screen.getByRole('button', { name: 'Use with my data' });
// Label and arrow are two spans set apart by the flex gap, not by a space.
expect(action).toHaveTextContent('.use_my_data()→');
// "experimental" is the chat's notice to make, not the entry's.
expect(screen.queryByText(/experimental/i)).not.toBeInTheDocument();
await user.click(action);
expect(onUseWithMyData).toHaveBeenCalledTimes(1);
expect(props.onAll).not.toHaveBeenCalled();
});

it('opens .use_my_data() from the keyboard, after the carousel in the tab order', async () => {
const onUseWithMyData = vi.fn();
const user = userEvent.setup();
render(
<LibraryPills
implementations={mockImplementations}
selectedLibrary="matplotlib"
onSelect={vi.fn()}
onAll={vi.fn()}
onUseWithMyData={onUseWithMyData}
/>
);

await user.tab();
expect(screen.getByRole('button', { name: 'Back to library comparison' })).toHaveFocus();
await user.tab();
await user.tab();
await user.tab();
const action = screen.getByRole('button', { name: USE_MY_DATA_LABEL });
expect(action).toHaveFocus();
await user.keyboard('{Enter}');
await user.keyboard(' ');
expect(onUseWithMyData).toHaveBeenCalledTimes(2);
});
});
Loading
Loading