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
2 changes: 2 additions & 0 deletions app/[locale]/agent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Suspense } from 'react';
import { setRequestLocale } from 'next-intl/server';
import { AppShell } from '@/components/AppShell';
import { AgentConnect } from '@/components/agent/AgentConnect';
import { AgentTryPrompts } from '@/components/agent/AgentTryPrompts';
import { AgentConfigGenerator } from '@/components/agent/AgentConfigGenerator';
import { AgentWalletCard } from '@/components/agent/AgentWalletCard';
import { AgentSafety } from '@/components/agent/AgentSafety';
Expand Down Expand Up @@ -31,6 +32,7 @@ export default async function AgentPage({ params }: { params: Promise<{ locale:
{/* fallback は実カードの復元前 (外枠 + 見出し) と同じ形にする — 大きな予約 → 縮む → 伸びる、の 2 回シフトを避ける。 */}
<Suspense fallback={<section className="min-w-0 rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200/70 sm:p-6"><h2 className="text-xl font-bold text-slate-900">{c.wallet.title}</h2></section>}><AgentWalletCard c={c.wallet} activity={c.activity} /></Suspense>
<AgentConnect locale={locale} c={c.connect} />
<AgentTryPrompts locale={locale} c={c.tryPrompts} />
<section>
<h2 className="text-xl font-bold text-slate-900">{c.modes.title}</h2>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
Expand Down
49 changes: 49 additions & 0 deletions components/agent/AgentTryPrompts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import { useState } from 'react';
import { useCopyToClipboard, useHydrationSafeAvailable } from '@/hooks/useCopyToClipboard';
import type { AgentPageContent } from '@/lib/agentPage';
import { trackAgentEvent } from '@/lib/agentTrack';

const tagColors = {
free: 'bg-slate-100 text-slate-700',
paid: 'bg-amber-100 text-amber-900',
human: 'bg-emerald-100 text-emerald-900',
};

// 文言は server page から受け取り、lib/agentPage → lib/legal を client bundle に入れない。
export function AgentTryPrompts({ locale, c }: { locale: string; c: AgentPageContent['tryPrompts'] }) {
const { copy, copied, available: clipboardAvailable } = useCopyToClipboard();
const available = useHydrationSafeAvailable(clipboardAvailable);
const [copiedId, setCopiedId] = useState<AgentPageContent['tryPrompts']['items'][number]['id'] | null>(null);

return (
// 1 枚のカードに行を並べる (依頼文ごとにカードを分けると mobile で 1,100px を超え、磨き上げで削った全長を戻してしまう)。
<section className="min-w-0 break-words rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200/70 sm:p-6">
<h2 className="text-xl font-bold text-slate-900">{c.title}</h2>
<p className="mt-3 text-sm text-slate-600">{c.lead}</p>
<ul className="mt-2 divide-y divide-slate-200/80">
{c.items.map((item) => (
<li key={item.id} className="min-w-0 py-3 last:pb-0">
{/* タグとコピーを 1 行に並べ、依頼文はその下 (ボタンを依頼文の下に積むと mobile で 1 行ぶんずつ伸びる)。 */}
<div className="flex min-w-0 items-center justify-between gap-3">
<span className={`inline-block min-w-0 max-w-full rounded-full px-2.5 py-0.5 text-xs font-medium ${tagColors[item.kind]}`}>{item.tag}</span>
{available ? (
<button type="button" aria-describedby={`agent-try-prompt-${item.id}`} className="min-h-[44px] shrink-0 rounded-lg px-2 text-sm font-semibold text-emerald-700 underline underline-offset-2 transition hover:text-emerald-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600" onClick={async () => {
if (!await copy(item.prompt)) return;
setCopiedId(item.id);
// 依頼文の本文は送らず id だけ。計測の失敗は trackAgentEvent が隔離する (掟 13)。
trackAgentEvent('agent_try_prompt_copy', { locale, id: item.id });
}}>{copied && copiedId === item.id ? c.copied : c.copy}</button>
) : null}
</div>
<p id={`agent-try-prompt-${item.id}`} className="break-words text-sm leading-relaxed text-slate-800">{item.prompt}</p>
{item.kind === 'paid' ? <p className="mt-1 text-xs leading-relaxed text-slate-600">{c.paidNote}</p> : null}
</li>
))}
</ul>
{/* 結果の読み上げは 1 か所だけ。ボタンの名前そのものを live region にすると、名前の変更と二重に読まれる。 */}
<p role="status" className="sr-only">{copied && copiedId !== null ? c.copied : ''}</p>
</section>
);
}
44 changes: 44 additions & 0 deletions lib/agentPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ export type AgentPageContent = {
readonly statsPartial: string;
readonly publicNote: string;
};
/** セットアップ後に Agent へそのまま貼れる依頼文 (user 承認 2026-09-22)。 */
readonly tryPrompts: {
readonly title: string;
readonly lead: string;
readonly copy: string;
readonly copied: string;
/** 支払いが起きる依頼文にだけ出す注記。 */
readonly paidNote: string;
readonly items: readonly {
readonly id: 'catalog' | 'buy-monitor' | 'order' | 'history' | 'limits';
/** free = 支払いなし / paid = Agent が支払う / human = 人が支払う。 */
readonly kind: 'free' | 'paid' | 'human';
readonly tag: string;
readonly prompt: string;
}[];
};
readonly next: {
readonly title: string;
readonly body: string;
Expand Down Expand Up @@ -346,6 +362,20 @@ const ja: AgentPageContent = {
statsPartial: '50 件より前は集計できません',
publicNote: 'Polygon 上の JPYC の送受信 (公開情報) です。何を購入したかは表示しません。0 JPYC の送信は除いています。',
},
tryPrompts: {
title: 'Agent に頼めること',
lead: 'セットアップが済んだら、そのまま話しかけてください。コピーして Agent に貼るだけです。「Agent が支払う」で接続したときの例で、店の注文は「人が支払う」でも使えます。',
copy: 'プロンプトをコピー',
copied: 'コピーしました',
paidNote: '依頼文の上限を超える支払いは行われません。Agent 側の上限のほうが小さいときは、支払いは拒否されます。',
items: [
{ id: 'catalog', kind: 'free', tag: '無料', prompt: 'OpenPay で今買える JPYC のデータと API を一覧にして、それぞれの価格と利用料を教えてください。支払いはしないでください。' },
{ id: 'buy-monitor', kind: 'paid', tag: '支払いあり・3 JPYC', prompt: 'JPYC Service Monitor を上限 3 JPYC で購入して、この 1 か月に変わった点を 5 行にまとめてください。' },
{ id: 'order', kind: 'human', tag: '支払いは自分で', prompt: 'JPYC で注文できる店を探して、メニューと合計額を見せてください。支払いは私がします。' },
{ id: 'history', kind: 'free', tag: '無料', prompt: '最近なにを買ったか、金額と取引ハッシュつきで見せてください。' },
{ id: 'limits', kind: 'free', tag: '無料', prompt: 'いまの支払い上限と、今日使った額を教えてください。' },
],
},
next: {
title: '買えるものを見る',
body: 'セットアップが済んだら、Agent が JPYC で購入できるリソースを AI ストアで確認できます。',
Expand Down Expand Up @@ -532,6 +562,20 @@ const en: AgentPageContent = {
statsPartial: 'Can’t total beyond the latest 50',
publicNote: 'JPYC transfers on Polygon (public data). What was purchased is not shown. 0 JPYC transfers are left out.',
},
tryPrompts: {
title: 'What you can ask your agent',
lead: 'Once setup is done, just talk to it. Copy a prompt and paste it to your agent. These examples are for the “Agent pays” setup; ordering from a shop also works with “You pay”.',
copy: 'Copy prompt',
copied: 'Copied',
paidNote: 'Nothing above the cap in the prompt is paid. If the limit on the agent side is lower, the payment is refused.',
items: [
{ id: 'catalog', kind: 'free', tag: 'Free', prompt: 'List the JPYC data and APIs I can buy on OpenPay right now, with the price and fee for each. Do not pay.' },
{ id: 'buy-monitor', kind: 'paid', tag: 'Pays · 3 JPYC', prompt: 'Buy the JPYC Service Monitor with a 3 JPYC cap and summarize what changed in the last month in five lines.' },
{ id: 'order', kind: 'human', tag: 'You pay yourself', prompt: 'Find shops where I can order with JPYC and show me the menu and the total. I will pay myself.' },
{ id: 'history', kind: 'free', tag: 'Free', prompt: 'Show me what you bought recently, with amounts and transaction hashes.' },
{ id: 'limits', kind: 'free', tag: 'Free', prompt: 'Tell me my current spending limits and how much I have spent today.' },
],
},
next: {
title: 'See what it can buy',
body: 'Once set up, browse the AI Store for the resources your agent can buy with JPYC.',
Expand Down
10 changes: 7 additions & 3 deletions lib/agentTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
import { track } from '@vercel/analytics';
import type { AgentClient, AgentMode, AgentOpenInApp } from '@/lib/agentSetup';

type AgentEventName = 'agent_prompt_copy' | 'agent_open_in' | 'agent_config_generate' | 'agent_config_copy' | 'agent_store_click' | 'agent_fund_send';
type AgentEventName = 'agent_prompt_copy' | 'agent_open_in' | 'agent_config_generate' | 'agent_config_copy' | 'agent_store_click' | 'agent_fund_send' | 'agent_try_prompt_copy';
type ConfigProperties = { locale: string; client: AgentClient; mode: AgentMode };

export function trackAgentEvent(name: 'agent_config_generate' | 'agent_config_copy', properties: ConfigProperties): void;
export function trackAgentEvent(name: 'agent_prompt_copy' | 'agent_store_click' | 'agent_fund_send', properties: { locale: string }): void;
export function trackAgentEvent(name: 'agent_open_in', properties: { locale: string; app: AgentOpenInApp }): void;
export function trackAgentEvent(name: AgentEventName, properties: { locale: string; client?: AgentClient; mode?: AgentMode; app?: AgentOpenInApp }): void {
/** 「Agent に頼めること」のコピー。送るのは依頼文の id だけで、本文は送らない。 */
export function trackAgentEvent(name: 'agent_try_prompt_copy', properties: { locale: string; id: string }): void;
export function trackAgentEvent(name: AgentEventName, properties: { locale: string; client?: AgentClient; mode?: AgentMode; app?: AgentOpenInApp; id?: string }): void {
try {
// 掟 13: 計測障害をコピーやリンク遷移などの UI 操作へ波及させない隔離。
// 明示したプロパティだけを送信し、アドレス・上限値・ホスト名は送らない。
const data = name === 'agent_config_generate' || name === 'agent_config_copy'
? { locale: properties.locale, client: properties.client!, mode: properties.mode! }
: name === 'agent_open_in'
? { locale: properties.locale, app: properties.app! }
: { locale: properties.locale };
: name === 'agent_try_prompt_copy'
? { locale: properties.locale, id: properties.id! }
: { locale: properties.locale };
track(name, data);
} catch {
// 付帯処理の失敗で本来の操作を止めない。
Expand Down
144 changes: 144 additions & 0 deletions tests/components/agent/AgentTryPrompts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useLayoutEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hydrateRoot, type Root } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { track } from '@vercel/analytics';
import { AgentTryPrompts } from '@/components/agent/AgentTryPrompts';
import { COPIED_FEEDBACK_MS } from '@/hooks/useCopyToClipboard';
import { agentPageContentFor } from '@/lib/agentPage';

vi.mock('@vercel/analytics', () => ({ track: vi.fn() }));

beforeEach(() => vi.clearAllMocks());
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => {
const c = agentPageContentFor(locale).tryPrompts;

it('shows the heading, lead and five selectable prompts with payment notes only on paid items', () => {
render(<AgentTryPrompts locale={locale} c={c} />);
expect(screen.getByRole('heading', { level: 2, name: c.title })).toBeVisible();
expect(screen.getByText(c.lead)).toBeVisible();
const items = within(screen.getByRole('list')).getAllByRole('listitem');
expect(items).toHaveLength(5);
for (const [index, item] of c.items.entries()) {
const row = within(items[index]);
expect(row.getByText(item.tag)).toBeVisible();
const prompt = row.getByText(item.prompt);
expect(prompt.tagName).toBe('P');
expect(prompt.textContent).toBe(item.prompt);
if (item.kind === 'paid') expect(row.getByText(c.paidNote)).toBeVisible();
else expect(row.queryByText(c.paidNote)).toBeNull();
}
expect(screen.getAllByText(c.paidNote)).toHaveLength(c.items.filter((item) => item.kind === 'paid').length);
});

it('copies each exact prompt, changes only its button and tracks only its ID after success', async () => {
const user = userEvent.setup();
const write = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
render(<AgentTryPrompts locale={locale} c={c} />);
const buttons = screen.getAllByRole('button', { name: c.copy });
expect(buttons).toHaveLength(5);

for (const [index, item] of c.items.entries()) {
await user.click(buttons[index]);
expect(write).toHaveBeenNthCalledWith(index + 1, item.prompt);
expect(track).toHaveBeenNthCalledWith(index + 1, 'agent_try_prompt_copy', { locale, id: item.id });
expect(screen.getAllByRole('button', { name: c.copied })).toEqual([buttons[index]]);
expect(screen.getAllByRole('button', { name: c.copy })).toHaveLength(4);
}
expect(write).toHaveBeenCalledTimes(5);
expect(track).toHaveBeenCalledTimes(5);
});

it('uses visible button names and distinct prompt descriptions without aria-label overrides', () => {
userEvent.setup();
const { container } = render(<AgentTryPrompts locale={locale} c={c} />);
expect(container.querySelector('[aria-label]')).toBeNull();
const buttons = screen.getAllByRole('button', { name: c.copy });
expect(buttons).toHaveLength(5);
for (const [index, item] of c.items.entries()) {
expect(buttons[index]).toHaveAccessibleName(c.copy);
expect(buttons[index]).toHaveAccessibleDescription(item.prompt);
expect(document.getElementById(buttons[index].getAttribute('aria-describedby')!)?.textContent).toBe(item.prompt);
}
expect(new Set(buttons.map((button) => button.getAttribute('aria-describedby'))).size).toBe(5);
});

it.each([false, true])('matches SSR to the initial client render and then respects clipboard availability (%s)', async (hasClipboard) => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
const container = document.createElement('div');
document.body.appendChild(container);
let root: Root | undefined;
try {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
const html = renderToString(<AgentTryPrompts locale={locale} c={c} />);
container.innerHTML = html;
expect(within(container).getAllByRole('button', { name: c.copy })).toHaveLength(5);
if (hasClipboard) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } });

let initialClientHtml = '';
function HydrationProbe() {
// passive effect が clipboard の有無を反映する前の、client 初回 commit を観測する。
useLayoutEffect(() => { initialClientHtml = container.innerHTML; }, []);
return <AgentTryPrompts locale={locale} c={c} />;
}
const onRecoverableError = vi.fn();
await act(async () => {
root = hydrateRoot(container, <HydrationProbe />, { onRecoverableError });
});
expect(initialClientHtml).toBe(html);
expect(onRecoverableError).not.toHaveBeenCalled();
expect(within(container).queryAllByRole('button')).toHaveLength(hasClipboard ? 5 : 0);
for (const item of c.items) expect(within(container).getByText(item.prompt).textContent).toBe(item.prompt);
} finally {
if (root) await act(async () => root?.unmount());
container.remove();
if (descriptor) Object.defineProperty(navigator, 'clipboard', descriptor);
else Reflect.deleteProperty(navigator, 'clipboard');
}
});

it('does not show success or track while a copy is pending or after it fails', async () => {
const user = userEvent.setup();
let rejectCopy!: (reason: Error) => void;
vi.spyOn(navigator.clipboard, 'writeText').mockReturnValue(new Promise<void>((_, reject) => { rejectCopy = reject; }));
render(<AgentTryPrompts locale={locale} c={c} />);
await user.click(screen.getAllByRole('button', { name: c.copy })[0]);
expect(track).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: c.copied })).toBeNull();
await act(async () => { rejectCopy(new Error('clipboard denied')); });
expect(track).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: c.copied })).toBeNull();
expect(screen.getAllByRole('button', { name: c.copy })).toHaveLength(5);
});

it('keeps successful copy feedback when analytics throws', async () => {
const user = userEvent.setup();
const write = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
vi.mocked(track).mockImplementationOnce(() => { throw new Error('analytics unavailable'); });
render(<AgentTryPrompts locale={locale} c={c} />);
const button = screen.getAllByRole('button', { name: c.copy })[1];
await user.click(button);
expect(write).toHaveBeenCalledWith(c.items[1].prompt);
expect(button).toHaveAccessibleName(c.copied);
expect(button).toHaveAccessibleDescription(c.items[1].prompt);
});

it('clears copied feedback after the shared hook timeout', async () => {
userEvent.setup();
vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
vi.useFakeTimers();
render(<AgentTryPrompts locale={locale} c={c} />);
await act(async () => { fireEvent.click(screen.getAllByRole('button', { name: c.copy })[0]); });
expect(screen.getAllByRole('button', { name: c.copied })).toHaveLength(1);
act(() => { vi.advanceTimersByTime(COPIED_FEEDBACK_MS); });
expect(screen.queryByRole('button', { name: c.copied })).toBeNull();
expect(screen.getAllByRole('button', { name: c.copy })).toHaveLength(5);
});
});
Loading
Loading