From 853309cef1a45435f79b5f9023f49a06d82fb14b Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:39:01 +0000 Subject: [PATCH 1/2] feat(web): humanize integration tool approvals --- ...ngIntegrationToolApprovals.client.test.tsx | 58 +++++- .../PendingIntegrationToolApprovals.tsx | 171 ++++++++++++++---- 2 files changed, 188 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx index 15de7d6a2f..714dee10dc 100644 --- a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx +++ b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx @@ -42,8 +42,8 @@ describe('PendingIntegrationToolApprovals', () => { it.each([ ['Allow once', 'approved'], - ["Don't ask again this session", 'approved_for_session'], - ['Reject', 'rejected'], + ['Allow for this session', 'approved_for_session'], + ['Deny', 'rejected'], ])('submits "%s" as the %s decision', async (label, decision) => { renderCard(); fireEvent.click(screen.getByRole('button', { name: label })); @@ -59,4 +59,58 @@ describe('PendingIntegrationToolApprovals', () => { decision, }); }); + + it('keeps technical request details collapsed until requested', () => { + renderCard(); + + expect( + screen.getByText('Let Mock Slack use this tool?'), + ).toBeInTheDocument(); + expect(screen.queryByText('post_message')).not.toBeInTheDocument(); + expect(screen.queryByText('mock-slack')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Details' })); + + expect(screen.getByText('post_message')).toBeInTheDocument(); + expect(screen.getByText('mock-slack')).toBeInTheDocument(); + expect(screen.getByText(/"channel": "C1"/)).toBeInTheDocument(); + }); + + it('uses request context when a read tool clearly targets a repository', () => { + render( + + + , + ); + + expect( + screen.getByText('Let Deepwiki inspect this repository?'), + ).toBeInTheDocument(); + }); + + it('describes empty arguments without exposing implementation wording', () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Details' })); + + expect(screen.getByText('No additional details.')).toBeInTheDocument(); + expect(screen.queryByText('No arguments')).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx index 0b6f38e7c8..5f3348afd0 100644 --- a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx +++ b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx @@ -3,10 +3,68 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { Button, ShieldQuestion } from '@/components/system'; -import type { IntegrationToolApprovalMetadata } from '@roomote/types'; +import { + Button, + ChevronDown, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + ShieldQuestion, +} from '@/components/system'; +import { + MCP_INTEGRATIONS, + type IntegrationToolApprovalMetadata, +} from '@roomote/types'; -function summarizeArgs(argsSummary: unknown): string { +const integrationNames = new Map( + MCP_INTEGRATIONS.map((integration) => [integration.id, integration.name]), +); + +function formatIdentifier(value: string): string { + return value + .replace(/[.\-_]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (character) => character.toUpperCase()) + .trim(); +} + +function integrationDisplayName(integrationId: string): string { + return integrationNames.get(integrationId) ?? formatIdentifier(integrationId); +} + +function approvalPrompt(item: IntegrationToolApprovalMetadata): string { + const name = integrationDisplayName(item.integrationId) || 'this integration'; + const normalizedToolName = item.toolName.toLowerCase(); + const argumentKeys = + item.argsSummary && + typeof item.argsSummary === 'object' && + !Array.isArray(item.argsSummary) + ? Object.keys(item.argsSummary as Record).map((key) => + key.toLowerCase(), + ) + : []; + const referencesRepository = argumentKeys.some((key) => + [ + 'repo', + 'repository', + 'reponame', + 'repositoryname', + 'repositoryfullname', + ].includes(key), + ); + const isReadAction = + /^(ask|fetch|find|get|inspect|list|lookup|query|read|search)[._-]/.test( + normalizedToolName, + ); + + if (referencesRepository && isReadAction) { + return `Let ${name} inspect this repository?`; + } + + return `Let ${name} use this tool?`; +} + +function summarizeArgs(argsSummary: unknown): string | null { if ( argsSummary === null || argsSummary === undefined || @@ -14,17 +72,16 @@ function summarizeArgs(argsSummary: unknown): string { !Array.isArray(argsSummary) && Object.keys(argsSummary as Record).length === 0) ) { - return 'No arguments'; + return null; } - const rendered = JSON.stringify(argsSummary); - return rendered.length > 160 ? `${rendered.slice(0, 160)}…` : rendered; + return JSON.stringify(argsSummary, null, 2) ?? null; } /** * The experiment-gated (`integrationToolApprovals`) card asking the Session * requester to allow one gated integration tool call or reject it. Allowing * resumes that exact paused call once through OpenCode's native permission - * reply. "Don't ask again this session" also records a requester-owned + * reply. "Allow for this session" also records a requester-owned * override so later calls to that tool in this Session run without a card; * it never changes the deployment policy or any other Session. The card * disappears once the call is decided or the approval expires unanswered. @@ -65,27 +122,59 @@ export function PendingIntegrationToolApprovals({ return (
- {pending.map((item) => ( -
-
-
+ ); + })}
); } From 133ae7551d20f83b788e29f5072cd931abe95d4e Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:45:51 +0000 Subject: [PATCH 2/2] fix(web): quiet the approval denial action --- ...ngIntegrationToolApprovals.client.test.tsx | 26 +++++++++++++++++++ .../PendingIntegrationToolApprovals.tsx | 22 +++++++--------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx index 714dee10dc..b173c86740 100644 --- a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx +++ b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx @@ -98,6 +98,32 @@ describe('PendingIntegrationToolApprovals', () => { ).toBeInTheDocument(); }); + it('disables every decision while the request is being submitted', async () => { + let resolveFetch: ((response: Response) => void) | undefined; + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + renderCard(); + + fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Allow once' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Allow for this session' }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Deny' })).toBeDisabled(); + }); + + resolveFetch?.(new Response('{}', { status: 200 })); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Allow once' })).toBeEnabled(); + }); + }); + it('describes empty arguments without exposing implementation wording', () => { render( diff --git a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx index 5f3348afd0..b8e6b247d5 100644 --- a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx +++ b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx @@ -194,18 +194,16 @@ export function PendingIntegrationToolApprovals({ > Allow for this session -
- -
+ );