diff --git a/docs/audits/pr-2381/README.md b/docs/audits/pr-2381/README.md
new file mode 100644
index 0000000000..8c571fa791
--- /dev/null
+++ b/docs/audits/pr-2381/README.md
@@ -0,0 +1,34 @@
+# Approval argument labels: PR #2381 repair
+
+The branch was rebased on main before repairing Rhea's HOLD (review 5598440568).
+`LABEL_KEYS` now accepts own entries only. Canonical JSON argument names such as
+`toString`, `constructor`, `hasOwnProperty` and `__proto__` use their raw names,
+just like the ordinary unknown-key control `extra`. The same own-key rule
+protects the tool-name verb table.
+
+The new helper cases failed for all four inherited argument names before the
+fix. Afterward, helper and real approval-card suites pass 40 tests. Card tests
+use the configured i18next resources, fetch the pinned envelope through the
+API seam, preserve the exact JSON disclosure, and verify Approve and Decline
+remain enabled. Frontend typecheck and changed-file lint pass.
+
+## Local browser evidence
+
+`frontend/design-system/preview/approval-readable.html` renders the actual
+rebased approval component, shell CSS and self-hosted fonts. Isolated headless
+Chrome intercepts the pending-approval GET with controlled merge/create-issue
+envelopes; no vendor execution, live account, approval or production API was
+used. The ego command was refused by the seat wrapper, so the check uses a
+fresh local Chrome profile.
+
+The en/zh-CN captures at 1440 and 390 show all inherited-key rows. Opening the
+second card's disclosure preserves every JSON own key. Both consent controls
+remain usable, with zero page errors, zero POSTs and no horizontal overflow.
+`browser-proof.json` records the measurements. Existing base button heights
+remain 33px in en and 34px in zh-CN, including at 390; this repair does not
+claim a new mobile target-size gate.
+
+These are local component fixtures, not a deployed consent flow or a vendor
+execution. Current-head code/security and UX re-gates remain required.
+
+The refreshed proof verifies IBM Plex Sans/Mono loaded and records zero HTTP error responses. The local server explicitly allowed the real dependency directory behind the worktree symlink; earlier captures without loaded fonts are superseded.
diff --git a/docs/audits/pr-2381/browser-proof.json b/docs/audits/pr-2381/browser-proof.json
new file mode 100644
index 0000000000..19fb220924
--- /dev/null
+++ b/docs/audits/pr-2381/browser-proof.json
@@ -0,0 +1,72 @@
+{
+ "source": "actual rebased components; local controlled API fixtures, not deployed or vendor calls",
+ "proof": [
+ {
+ "lang": "en",
+ "width": 1440,
+ "overflow": false,
+ "approveHeights": [
+ 33,
+ 33
+ ],
+ "allOwnKeysVisible": true,
+ "exactJsonPreserved": true,
+ "loadedFonts": [
+ "IBM Plex Sans",
+ "IBM Plex Sans",
+ "IBM Plex Mono"
+ ]
+ },
+ {
+ "lang": "en",
+ "width": 390,
+ "overflow": false,
+ "approveHeights": [
+ 33,
+ 33
+ ],
+ "allOwnKeysVisible": true,
+ "exactJsonPreserved": true,
+ "loadedFonts": [
+ "IBM Plex Sans",
+ "IBM Plex Sans",
+ "IBM Plex Mono"
+ ]
+ },
+ {
+ "lang": "zh-CN",
+ "width": 1440,
+ "overflow": false,
+ "approveHeights": [
+ 34,
+ 34
+ ],
+ "allOwnKeysVisible": true,
+ "exactJsonPreserved": true,
+ "loadedFonts": [
+ "IBM Plex Sans",
+ "IBM Plex Sans",
+ "IBM Plex Mono"
+ ]
+ },
+ {
+ "lang": "zh-CN",
+ "width": 390,
+ "overflow": false,
+ "approveHeights": [
+ 34,
+ 34
+ ],
+ "allOwnKeysVisible": true,
+ "exactJsonPreserved": true,
+ "loadedFonts": [
+ "IBM Plex Sans",
+ "IBM Plex Sans",
+ "IBM Plex Mono"
+ ]
+ }
+ ],
+ "errors": [],
+ "posts": 0,
+ "failedResponses": []
+}
diff --git a/docs/audits/pr-2381/en-1440.png b/docs/audits/pr-2381/en-1440.png
new file mode 100644
index 0000000000..5a71d33459
Binary files /dev/null and b/docs/audits/pr-2381/en-1440.png differ
diff --git a/docs/audits/pr-2381/en-390.png b/docs/audits/pr-2381/en-390.png
new file mode 100644
index 0000000000..8a56048989
Binary files /dev/null and b/docs/audits/pr-2381/en-390.png differ
diff --git a/docs/audits/pr-2381/zh-CN-1440.png b/docs/audits/pr-2381/zh-CN-1440.png
new file mode 100644
index 0000000000..1713c97ac1
Binary files /dev/null and b/docs/audits/pr-2381/zh-CN-1440.png differ
diff --git a/docs/audits/pr-2381/zh-CN-390.png b/docs/audits/pr-2381/zh-CN-390.png
new file mode 100644
index 0000000000..77e0f73d1f
Binary files /dev/null and b/docs/audits/pr-2381/zh-CN-390.png differ
diff --git a/frontend/design-system/preview/approval-readable.html b/frontend/design-system/preview/approval-readable.html
new file mode 100644
index 0000000000..ed6b357ef8
--- /dev/null
+++ b/frontend/design-system/preview/approval-readable.html
@@ -0,0 +1 @@
+
Approval arguments specimen
diff --git a/frontend/design-system/preview/approval-readable.tsx b/frontend/design-system/preview/approval-readable.tsx
new file mode 100644
index 0000000000..3a7ed6f224
--- /dev/null
+++ b/frontend/design-system/preview/approval-readable.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { MemoryRouter } from 'react-router-dom';
+import { AuthContext } from '../../src/context/AuthContext';
+import i18n, { i18nReady } from '../../src/i18n';
+import V2ApprovalCard from '../../src/v2/components/V2ApprovalCard';
+import '../../src/v2/v2.css';
+import '@fontsource/ibm-plex-sans/400.css';
+import '@fontsource/ibm-plex-sans/600.css';
+import '@fontsource/ibm-plex-mono/500.css';
+
+async function show() {
+ await i18nReady;
+ await i18n.changeLanguage(new URLSearchParams(location.search).get('lang') || 'en');
+ createRoot(document.getElementById('root')!).render(
+
+
+
+ {['merge','inherited'].map(id => )}
+
+
+ ,
+ );
+}
+void show();
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 1e675e9d3b..5e133e2aae 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1380,7 +1380,26 @@
"failed": "Approved — tool call failed ({{code}}).",
"refused": "Approved — tool call refused ({{code}}).",
"pinChanged": "Approved, but the tool changed since this request. Ask again before running it.",
- "expiredPending": "This tool-call approval has expired."
+ "expiredPending": "This tool-call approval has expired.",
+ "verb": {
+ "mergePullRequest": "Merge pull request #{{number}}",
+ "createIssue": "Open an issue",
+ "commentOnIssue": "Comment on issue #{{number}}",
+ "closeIssue": "Close issue #{{number}}"
+ },
+ "label": {
+ "repository": "Repository",
+ "pullRequest": "Pull request",
+ "issue": "Issue",
+ "head": "Head commit",
+ "mergeMethod": "Merge method",
+ "commitTitle": "Commit title",
+ "commitMessage": "Commit message",
+ "title": "Title",
+ "body": "Body",
+ "labels": "Labels"
+ },
+ "exactArgs": "Exact arguments"
},
"approve": "Approve",
"decline": "Decline",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 39236f71ef..ee2327f605 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1372,12 +1372,31 @@
"failed": "已批准 — 工具调用失败({{code}})。",
"refused": "已批准 — 工具调用被拒绝({{code}})。",
"pinChanged": "已批准,但工具在请求后已变更。请重新发起请求后再执行。",
- "expiredPending": "该工具调用批准已过期。"
+ "expiredPending": "该工具调用批准已过期。",
+ "verb": {
+ "mergePullRequest": "合并 PR #{{number}}",
+ "createIssue": "创建 Issue",
+ "commentOnIssue": "评论 Issue #{{number}}",
+ "closeIssue": "关闭 Issue #{{number}}"
+ },
+ "label": {
+ "repository": "仓库",
+ "pullRequest": "PR",
+ "issue": "Issue",
+ "head": "头提交",
+ "mergeMethod": "合并方式",
+ "commitTitle": "提交标题",
+ "commitMessage": "提交信息",
+ "title": "标题",
+ "body": "正文",
+ "labels": "标签"
+ },
+ "exactArgs": "原始参数"
},
"approve": "批准",
"decline": "拒绝",
"deciding": "处理中…",
- "waitingForOwner": "等待工作区所有者决定。",
+ "waitingForOwner": "等待工作空间所有者决定。",
"aged": "该提议已等待较久 — 请确认它仍然适用。",
"approved": "✓ 已批准 — 已完成。",
"approvedButFailed": "已批准,但执行失败:{{error}}",
diff --git a/frontend/src/v2/__tests__/V2ApprovalCardAction.test.tsx b/frontend/src/v2/__tests__/V2ApprovalCardAction.test.tsx
index ff559e0ed1..d96844ac41 100644
--- a/frontend/src/v2/__tests__/V2ApprovalCardAction.test.tsx
+++ b/frontend/src/v2/__tests__/V2ApprovalCardAction.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import V2ApprovalCard from '../components/V2ApprovalCard';
import en from '../../i18n/locales/en.json';
@@ -279,6 +279,50 @@ describe('tool-call approval details stay owner-scoped', () => {
expect(mockGet).toHaveBeenCalledWith('/api/approvals/pending?podId=pod-1');
});
+ test('the owner reads the call as a verb and rows, with the exact arguments one click away', async () => {
+ const headSha = 'abc1234def5678abc1234def5678abc1234def56';
+ mockGet.mockResolvedValue({
+ approvals: [{
+ approvalId: 'tool-2',
+ messageId: 'm1',
+ toolCall: {
+ tool: 'github.merge_pull_request',
+ canonicalArgs: { pullNumber: 4, mergeMethod: 'squash', headSha, owner: 'Team-Commonly', repo: 'demo-day' },
+ },
+ }],
+ });
+
+ renderCard({
+ kind: 'approval-card',
+ approvalId: 'tool-2',
+ status: 'flagged',
+ ownerUserId: 'viewer-1',
+ actionType: 'tool_call',
+ summary: 'Merge the pull request',
+ });
+
+ await waitFor(() => expect(screen.getByTestId('approval-args')).toBeInTheDocument());
+ const action = screen.getByTestId('approval-action');
+ expect(action).toHaveTextContent('Merge pull request #4');
+ expect(action).toHaveTextContent('github.merge_pull_request');
+
+ const repoLink = within(screen.getByTestId('approval-arg-owner-repo')).getByRole('link', { name: 'Team-Commonly/demo-day' });
+ expect(repoLink).toHaveAttribute('href', 'https://github.com/Team-Commonly/demo-day');
+ expect(repoLink).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(within(screen.getByTestId('approval-arg-pullNumber')).getByRole('link', { name: '#4' }))
+ .toHaveAttribute('href', 'https://github.com/Team-Commonly/demo-day/pull/4');
+ const head = screen.getByTestId('approval-arg-headSha');
+ expect(head).toHaveTextContent(en.approvalCard.toolCall.label.head);
+ expect(head).toHaveTextContent('abc1234');
+ expect(head.querySelector('dd')).toHaveAttribute('title', headSha);
+ expect(screen.getByTestId('approval-arg-mergeMethod')).toHaveTextContent('squash');
+
+ // The exact JSON is still on the card, behind a disclosure, never dropped.
+ expect(screen.getByText(en.approvalCard.toolCall.exactArgs).closest('details')).not.toBeNull();
+ expect(screen.getByTestId('approval-tool-call')).toHaveTextContent(`"headSha": "${headSha}"`);
+ expect(screen.getByText(en.approvalCard.approve)).toBeEnabled();
+ });
+
test('a non-owner never fetches or renders canonical args', () => {
renderCard({
kind: 'approval-card',
@@ -330,3 +374,38 @@ describe('tool-call approval details stay owner-scoped', () => {
expect(screen.getByText(en.approvalCard.decline)).toBeEnabled();
});
});
+
+test.each(['toString', 'constructor', 'hasOwnProperty', '__proto__', 'extra'])('canonical argument %s keeps the real consent card usable', async key => {
+ const canonicalArgs = JSON.parse(JSON.stringify({ title:'Pinned issue', [key]:'pinned-not-prose' }));
+ mockGet.mockResolvedValue({approvals:[{approvalId:'inherited-key',messageId:'m1',toolCall:{tool:'github.create_issue',canonicalArgs}}]});
+ renderCard({kind:'approval-card',approvalId:'inherited-key',status:'flagged',ownerUserId:'viewer-1',actionType:'tool_call'});
+ const row = await screen.findByTestId(`approval-arg-${key}`);
+ expect(within(row).getByText(key)).toBeInTheDocument();
+ expect(within(row).getByText('pinned-not-prose')).toBeInTheDocument();
+ expect(screen.getByTestId('approval-tool-call')).toHaveTextContent(`"${key}": "pinned-not-prose"`);
+ expect(screen.getByText(en.approvalCard.approve)).toBeEnabled();
+ expect(screen.getByText(en.approvalCard.decline)).toBeEnabled();
+});
+
+
+test.each([
+ ['github.merge_pull_request', { pullNumber: 42, issueNumber: 7 }, 'Merge pull request #42'],
+ ['github.comment_on_issue', { pullNumber: 7, issueNumber: 42 }, 'Comment on issue #42'],
+ ['github.close_issue', { pullNumber: 7, issueNumber: 42 }, 'Close issue #42'],
+ ['github.merge_pull_request', { pullNumber: 0, issueNumber: 7 }, null],
+ ['github.comment_on_issue', { pullNumber: 7, issueNumber: 0 }, null],
+ ['github.close_issue', { pullNumber: 7, issueNumber: 0 }, null],
+])('consent for %s uses only its required number (%j)', async (tool, canonicalArgs, expected) => {
+ mockGet.mockResolvedValue({ approvals: [{
+ approvalId: 'number-selection', messageId: 'm1', toolCall: { tool, canonicalArgs },
+ }] });
+ renderCard({ kind: 'approval-card', approvalId: 'number-selection', status: 'flagged',
+ ownerUserId: 'viewer-1', actionType: 'tool_call' });
+ await waitFor(() => expect(screen.getByTestId('approval-args')).toBeInTheDocument());
+ const action = screen.getByTestId('approval-action');
+ expect(action).toHaveTextContent(tool);
+ if (expected) expect(action).toHaveTextContent(expected);
+ else expect(action).not.toHaveTextContent(/#7/);
+ expect(screen.getByTestId('approval-arg-pullNumber')).toBeInTheDocument();
+ expect(screen.getByTestId('approval-arg-issueNumber')).toBeInTheDocument();
+});
diff --git a/frontend/src/v2/__tests__/toolCallView.test.ts b/frontend/src/v2/__tests__/toolCallView.test.ts
new file mode 100644
index 0000000000..7b97fef11b
--- /dev/null
+++ b/frontend/src/v2/__tests__/toolCallView.test.ts
@@ -0,0 +1,124 @@
+import { toolCallView } from '../components/toolCallView';
+
+// The card is a consent surface: the readable view may relabel, but it must
+// show every pinned argument, and it may only link to github.com paths built
+// from validated values.
+const merge = {
+ pullNumber: 42,
+ mergeMethod: 'squash',
+ headSha: 'abc1234def5678abc1234def5678abc1234def56',
+ owner: 'Team-Commonly',
+ repo: 'demo-day',
+};
+
+const keysShown = (args: Record, tool: string) => (
+ toolCallView(tool, args).rows.flatMap((row) => row.keys).sort()
+);
+
+describe('toolCallView', () => {
+ test('a merge reads as a verb with the repo, the PR, the pinned head and the method', () => {
+ const view = toolCallView('github.merge_pull_request', merge);
+ expect(view.verb).toEqual({ key: 'approvalCard.toolCall.verb.mergePullRequest', vars: { number: '42' } });
+ const byKey = Object.fromEntries(view.rows.map((row) => [row.keys.join('+'), row]));
+ expect(byKey['owner+repo']).toMatchObject({ value: 'Team-Commonly/demo-day', href: 'https://github.com/Team-Commonly/demo-day' });
+ expect(byKey.pullNumber).toMatchObject({ value: '#42', href: 'https://github.com/Team-Commonly/demo-day/pull/42' });
+ expect(byKey.headSha).toMatchObject({
+ value: 'abc1234',
+ title: merge.headSha,
+ kind: 'mono',
+ href: `https://github.com/Team-Commonly/demo-day/commit/${merge.headSha}`,
+ });
+ expect(byKey.mergeMethod).toMatchObject({ value: 'squash', labelKey: 'approvalCard.toolCall.label.mergeMethod' });
+ });
+
+ test('every pinned argument is shown, including ones the view has no name for', () => {
+ const args = { ...merge, commitTitle: 'Fix #3', commitMessage: 'line one\nline two', extra: { nested: true } };
+ expect(keysShown(args, 'github.merge_pull_request')).toEqual(Object.keys(args).sort());
+ const extra = toolCallView('github.merge_pull_request', args).rows.find((row) => row.keys[0] === 'extra');
+ expect(extra).toMatchObject({ label: 'extra', value: '{"nested":true}', kind: 'mono' });
+ expect(extra?.labelKey).toBeUndefined();
+ });
+
+ test('a hosted-MCP call keeps its tool name and shows its arguments as plain rows', () => {
+ const args = { parent: 'page-1', title: 'Weekly notes', properties: { status: 'draft' } };
+ const view = toolCallView('notion.create_page', args);
+ expect(view.verb).toBeNull();
+ expect(keysShown(args, 'notion.create_page')).toEqual(Object.keys(args).sort());
+ expect(view.rows.some((row) => row.href)).toBe(false);
+ });
+
+ test('a body stays one literal block, and labels read as a list', () => {
+ const view = toolCallView('github.create_issue', {
+ title: 'Bug', body: 'Steps:\n1. open\n2. ', labels: ['bug', 'p1'], owner: 'o', repo: 'r',
+ });
+ expect(view.verb).toEqual({ key: 'approvalCard.toolCall.verb.createIssue', vars: {} });
+ const body = view.rows.find((row) => row.keys[0] === 'body');
+ expect(body).toMatchObject({ kind: 'block', value: 'Steps:\n1. open\n2. ' });
+ expect(view.rows.find((row) => row.keys[0] === 'labels')).toMatchObject({ value: '["bug","p1"]', kind: 'text' });
+ });
+
+ test('no link is built from an owner, repo, number or SHA that fails validation', () => {
+ const view = toolCallView('github.merge_pull_request', {
+ owner: 'evil.example/../x', repo: 'demo-day', pullNumber: 4, headSha: 'not-a-sha',
+ });
+ expect(view.rows.every((row) => row.href === undefined)).toBe(true);
+ expect(view.rows.find((row) => row.keys[0] === 'headSha')).toMatchObject({ value: 'not-a-sha' });
+ const bad = toolCallView('github.close_issue', { owner: 'o', repo: 'r', issueNumber: '3; drop' });
+ expect(bad.rows.find((row) => row.keys[0] === 'issueNumber')).toMatchObject({ value: '3; drop' });
+ expect(bad.rows.find((row) => row.keys[0] === 'issueNumber')?.href).toBeUndefined();
+ // A numbered verb with no valid number falls back to the tool name rather than "#undefined".
+ expect(bad.verb).toBeNull();
+ });
+
+ test('only github tools get github links', () => {
+ const view = toolCallView('linear.create_issue', { owner: 'Team-Commonly', repo: 'demo-day', issueNumber: 3 });
+ expect(view.rows.every((row) => row.href === undefined)).toBe(true);
+ });
+});
+
+test.each(['toString', 'constructor', 'hasOwnProperty', '__proto__', 'extra'])('unknown argument %s retains its raw label', key => {
+ const args = JSON.parse(JSON.stringify({ title:'Pinned title', [key]:'pinned-not-prose' }));
+ const view = toolCallView('github.create_issue', args);
+ const row = view.rows.find(candidate => candidate.keys.includes(key));
+ expect(row).toMatchObject({ label:key, value:'pinned-not-prose' });
+ expect(row?.labelKey).toBeUndefined();
+ expect(keysShown(args,'github.create_issue')).toEqual(Object.keys(args).sort());
+});
+
+test.each(['toString','constructor','__proto__'])('unknown tool %s has no inherited verb', tool => {
+ expect(toolCallView(tool,{issueNumber:1,extra:'kept'}).verb).toBeNull();
+});
+
+
+describe.each([
+ ['github.merge_pull_request', 'mergePullRequest', 'pullNumber', 'issueNumber'],
+ ['github.comment_on_issue', 'commentOnIssue', 'issueNumber', 'pullNumber'],
+ ['github.close_issue', 'closeIssue', 'issueNumber', 'pullNumber'],
+])('%s number selection', (tool, verb, required, irrelevant) => {
+ test('uses the required field when both numbers differ', () => {
+ expect(toolCallView(tool, { [required]: 42, [irrelevant]: 7 }).verb).toEqual({
+ key: `approvalCard.toolCall.verb.${verb}`, vars: { number: '42' },
+ });
+ });
+
+ test.each([undefined, 0, -1, 1.5, '42'])('invalid required number %s never uses the other field', invalid => {
+ expect(toolCallView(tool, { [required]: invalid, [irrelevant]: 7 }).verb).toBeNull();
+ });
+});
+
+
+test.each(['.', '..'])('dot segment %s never creates a GitHub link', segment => {
+ for (const args of [{ owner: segment, repo: 'valid-repo' }, { owner: 'valid-owner', repo: segment }]) {
+ const view = toolCallView('github.merge_pull_request', { ...args, pullNumber: 42, headSha: 'abc1234' });
+ expect(view.rows.every(row => row.href === undefined)).toBe(true);
+ }
+});
+
+test('label boundaries remain distinct when a label contains a comma', () => {
+ const labelsRow = (labels: string[]) => toolCallView('github.create_issue', { labels }).rows[0];
+ const comma = labelsRow(['security, urgent', 'p1']);
+ const separate = labelsRow(['security', 'urgent', 'p1']);
+ expect(comma.value).not.toBe(separate.value);
+ expect(JSON.parse(comma.value)).toEqual(['security, urgent', 'p1']);
+ expect(JSON.parse(separate.value)).toEqual(['security', 'urgent', 'p1']);
+});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 975c0bbe0a..a6bff34c00 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -1799,6 +1799,12 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(pitch).toMatch(/font-size: 13px/);
});
+ test('approval argument blocks scroll within a bounded height', () => {
+ const block = ruleBody(v2, '.v2-approval__arg-value--block');
+ expect(block).toContain('max-height: 12em');
+ expect(block).toContain('overflow-y: auto');
+ });
+
test('the hosted-to-BYO handoff preserves the server command and has a copy action', () => {
// The payload is the source of truth: the renderer must branch on the
// structured handoff kind, and the pre block must preserve line breaks so
diff --git a/frontend/src/v2/components/V2ApprovalCard.tsx b/frontend/src/v2/components/V2ApprovalCard.tsx
index 1967bc8c06..5e89c723b6 100644
--- a/frontend/src/v2/components/V2ApprovalCard.tsx
+++ b/frontend/src/v2/components/V2ApprovalCard.tsx
@@ -14,6 +14,7 @@ import { useAuth } from '../../context/AuthContext';
import { useV2Api } from '../hooks/useV2Api';
import { notifyAttentionChanged } from '../hooks/useV2PodAttention';
import type { V2Message } from '../hooks/useV2PodDetail';
+import { toolCallView } from './toolCallView';
interface V2ApprovalCardProps {
message: V2Message;
@@ -184,6 +185,9 @@ const V2ApprovalCard: React.FC = ({ message, authorLabel, t
const action = describeAction(payload.actionType, payload.params);
const prose = payload.summary || message.content;
const toolCallReady = !needsToolCall || !!pendingToolCall;
+ const readable = pendingToolCall?.tool && pendingToolCall.canonicalArgs
+ ? toolCallView(pendingToolCall.tool, pendingToolCall.canonicalArgs)
+ : null;
const decide = async (decision: 'approved' | 'declined') => {
if (
@@ -229,12 +233,35 @@ const V2ApprovalCard: React.FC = ({ message, authorLabel, t
{pendingToolCall ? (
<>
-
- {pendingToolCall.tool}
-
-
- {JSON.stringify(pendingToolCall.canonicalArgs, null, 2)}
-
+ {readable?.verb ? (
+
+ {t(readable.verb.key, readable.verb.vars)}
+ {pendingToolCall.tool}
+
+ ) : (
+ {pendingToolCall.tool}
+ )}
+ {/* Every pinned argument, relabelled but never dropped (toolCallView). */}
+ {readable && readable.rows.length > 0 && (
+
+ {readable.rows.map((row) => (
+
+
- {row.labelKey ? t(row.labelKey) : row.label}
+
-
+ {row.href
+ ? {row.value}
+ : row.value}
+
+
+ ))}
+
+ )}
+
+ {t('approvalCard.toolCall.exactArgs')}
+
+ {JSON.stringify(pendingToolCall.canonicalArgs, null, 2)}
+
+
>
) : isToolCall ? (
<>
diff --git a/frontend/src/v2/components/toolCallView.ts b/frontend/src/v2/components/toolCallView.ts
new file mode 100644
index 0000000000..b1f018d5c5
--- /dev/null
+++ b/frontend/src/v2/components/toolCallView.ts
@@ -0,0 +1,145 @@
+// A parked connector call, readable: the verb on the action line and every
+// pinned argument as a labelled row (demo plan must-have 6, Sam's "small
+// sub-UI for external connectors").
+//
+// The card is a consent surface, so this view may relabel and format, but it
+// never drops an argument: every key of `canonicalArgs` lands in a row (owner
+// and repo share one), and the exact JSON stays one click away beside it.
+// Rows are built from the server-pinned arguments only, never from the
+// agent's prose.
+
+export type ToolCallRowKind = 'text' | 'mono' | 'block';
+
+export interface ToolCallRow {
+ /** The canonical argument key(s) this row shows. */
+ keys: string[];
+ /** i18n key for the row label; absent for an argument with no known name. */
+ labelKey?: string;
+ /** The raw argument name, shown when there is no labelKey. */
+ label: string;
+ value: string;
+ kind: ToolCallRowKind;
+ /** A github.com link, built only from validated pinned values. */
+ href?: string;
+ /** The full value when `value` is shortened (a commit SHA). */
+ title?: string;
+}
+
+export interface ToolCallView {
+ verb: { key: string; vars: Record } | null;
+ rows: ToolCallRow[];
+}
+
+const VERB_KEYS: Record = {
+ 'github.merge_pull_request': 'approvalCard.toolCall.verb.mergePullRequest',
+ 'github.create_issue': 'approvalCard.toolCall.verb.createIssue',
+ 'github.comment_on_issue': 'approvalCard.toolCall.verb.commentOnIssue',
+ 'github.close_issue': 'approvalCard.toolCall.verb.closeIssue',
+};
+
+const LABEL_KEYS: Record = {
+ mergeMethod: 'approvalCard.toolCall.label.mergeMethod',
+ commitTitle: 'approvalCard.toolCall.label.commitTitle',
+ commitMessage: 'approvalCard.toolCall.label.commitMessage',
+ title: 'approvalCard.toolCall.label.title',
+ body: 'approvalCard.toolCall.label.body',
+ labels: 'approvalCard.toolCall.label.labels',
+};
+
+const BLOCK_KEYS = new Set(['body', 'commitMessage']);
+
+// A GitHub owner or repository name, as GitHub allows them. Anything else is
+// shown as text and never put in a URL.
+const GITHUB_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9_.-]{1,100}$/;
+const COMMIT_SHA = /^[0-9a-f]{7,64}$/i;
+
+const asText = (value: unknown): string => {
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+ return JSON.stringify(value ?? null);
+};
+
+const isPositiveInteger = (value: unknown): value is number => (
+ typeof value === 'number' && Number.isInteger(value) && value > 0
+);
+
+export const toolCallView = (tool: string, args: Record): ToolCallView => {
+ const rows: ToolCallRow[] = [];
+ const shown = new Set();
+ const isGithub = tool.startsWith('github.');
+ const { owner, repo } = args;
+ const repoBase = isGithub && typeof owner === 'string' && typeof repo === 'string'
+ && GITHUB_SEGMENT.test(owner) && GITHUB_SEGMENT.test(repo)
+ ? `https://github.com/${owner}/${repo}`
+ : undefined;
+
+ if (owner !== undefined || repo !== undefined) {
+ const keys = ['owner', 'repo'].filter((key) => key in args);
+ rows.push({
+ keys,
+ labelKey: 'approvalCard.toolCall.label.repository',
+ label: keys.join('/'),
+ value: [owner, repo].filter((part) => part !== undefined).map(asText).join('/'),
+ kind: 'text',
+ ...(repoBase ? { href: repoBase } : {}),
+ });
+ keys.forEach((key) => shown.add(key));
+ }
+
+ const numbered = (key: 'pullNumber' | 'issueNumber', path: 'pull' | 'issues', labelKey: string) => {
+ if (!(key in args)) return;
+ const raw = args[key];
+ rows.push({
+ keys: [key],
+ labelKey,
+ label: key,
+ value: isPositiveInteger(raw) ? `#${raw}` : asText(raw),
+ kind: 'text',
+ ...(repoBase && isPositiveInteger(raw) ? { href: `${repoBase}/${path}/${raw}` } : {}),
+ });
+ shown.add(key);
+ };
+ numbered('pullNumber', 'pull', 'approvalCard.toolCall.label.pullRequest');
+ numbered('issueNumber', 'issues', 'approvalCard.toolCall.label.issue');
+
+ if ('headSha' in args) {
+ const raw = args.headSha;
+ const sha = typeof raw === 'string' && COMMIT_SHA.test(raw) ? raw : null;
+ rows.push({
+ keys: ['headSha'],
+ labelKey: 'approvalCard.toolCall.label.head',
+ label: 'headSha',
+ value: sha ? sha.slice(0, 7) : asText(raw),
+ kind: 'mono',
+ ...(sha ? { title: sha } : {}),
+ ...(sha && repoBase ? { href: `${repoBase}/commit/${sha}` } : {}),
+ });
+ shown.add('headSha');
+ }
+
+ for (const [key, raw] of Object.entries(args)) {
+ if (shown.has(key)) continue;
+ const labelKey = Object.prototype.hasOwnProperty.call(LABEL_KEYS, key) ? LABEL_KEYS[key] : undefined;
+ const isLabels = key === 'labels' && Array.isArray(raw) && raw.every((label) => typeof label === 'string');
+ rows.push({
+ keys: [key],
+ ...(labelKey ? { labelKey } : {}),
+ label: key,
+ value: isLabels ? JSON.stringify(raw) : asText(raw),
+ kind: BLOCK_KEYS.has(key) && typeof raw === 'string'
+ ? 'block'
+ : (typeof raw === 'string' || isLabels ? 'text' : 'mono'),
+ });
+ }
+
+ const verbKey = Object.prototype.hasOwnProperty.call(VERB_KEYS, tool) ? VERB_KEYS[tool] : undefined;
+ const requiredNumber = tool === 'github.merge_pull_request' ? args.pullNumber
+ : (tool === 'github.comment_on_issue' || tool === 'github.close_issue' ? args.issueNumber : null);
+ const number = isPositiveInteger(requiredNumber) ? requiredNumber : null;
+ // A numbered verb with no valid number would read "#undefined"; fall back to the tool name.
+ const needsNumber = verbKey && verbKey !== VERB_KEYS['github.create_issue'];
+ const verb = verbKey && (!needsNumber || number !== null)
+ ? { key: verbKey, vars: number !== null ? { number: String(number) } : {} }
+ : null;
+ return { verb, rows };
+};
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 6239cfedb7..75c2252e4f 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -9125,6 +9125,97 @@ body.modern-ui.v2-canvas {
overflow-x: auto;
}
+/* A readable connector call: the verb, then the tool's own name as meta. The
+ gap is a column gap, so a tool name that wraps onto its own line starts
+ flush left rather than indented (Cofounder at 390). */
+.v2-approval__action--tool {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ column-gap: 8px;
+}
+
+/* The tool name wraps as one unit, so a narrow card never splits the verb from its number. */
+.v2-approval__tool-name {
+ white-space: nowrap;
+ font: 500 11.5px/1.4 var(--v2-font-mono);
+ color: var(--v2-text-muted);
+}
+
+/* A parked call's pinned arguments as rows (Signal rows grammar): a bordered
+ list with divider hairlines, the label in mono meta, the value in body. */
+.v2-approval__args {
+ margin: 0;
+ border: 1px solid var(--v2-border-soft);
+ border-radius: var(--v2-radius-sm);
+ display: flex;
+ flex-direction: column;
+}
+
+.v2-approval__arg {
+ display: grid;
+ grid-template-columns: minmax(96px, 28%) minmax(0, 1fr);
+ gap: 12px;
+ padding: 6px 10px;
+ border-top: 1px solid var(--v2-border-soft);
+}
+
+.v2-approval__arg:first-child {
+ border-top: 0;
+}
+
+.v2-approval__arg dt {
+ font: 500 11.5px/1.6 var(--v2-font-mono);
+ color: var(--v2-text-muted);
+ overflow-wrap: anywhere;
+}
+
+.v2-approval__arg dd {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--v2-text-primary);
+ overflow-wrap: anywhere;
+ min-width: 0;
+}
+
+.v2-approval__arg-value--mono {
+ font-family: var(--v2-font-mono);
+ font-size: 12.5px;
+}
+
+.v2-approval__arg-value--block {
+ white-space: pre-wrap;
+ max-height: 12em;
+ overflow-y: auto;
+}
+
+.v2-approval__arg dd a {
+ color: var(--v2-accent-text);
+ text-decoration: none;
+}
+
+.v2-approval__arg dd a:hover {
+ text-decoration: underline;
+}
+
+.v2-approval__exact summary {
+ font-size: 12px;
+ color: var(--v2-text-muted);
+ cursor: pointer;
+}
+
+.v2-approval__exact[open] summary {
+ margin-bottom: 6px;
+}
+
+@media (max-width: 480px) {
+ .v2-approval__arg {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 2px;
+ }
+}
+
/* The agent's reason for asking, demoted below the action it belongs to. */
.v2-approval__pitch {
font-size: 13px;