= ({ 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..6270ef6e15
--- /dev/null
+++ b/frontend/src/v2/components/toolCallView.ts
@@ -0,0 +1,149 @@
+// 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;
+
+ // GitHub-shaped rows (repository, PR, issue, head) are GitHub's vocabulary.
+ // On any other tool `owner` may be a person, so those keys stay plain rows
+ // under their own names.
+ if (isGithub && (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 (!isGithub || !(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 (isGithub && '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),
+ // A multi-line string keeps its line breaks, whatever its key.
+ kind: typeof raw === 'string' && (BLOCK_KEYS.has(key) || raw.includes('\n'))
+ ? '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 };
+};