qwen-code/packages/web-shell/client/components/messages/ToolApproval.tsx
ermin.zem 5c82857fea
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
Add harness infrastructure for web-shell package (#6517)
* test(web-shell): add browser and lint harness

* test(web-shell): harden browser smoke harness

* fix(web-shell): guard mock daemon model state

* test(web-shell): remove unused scenario harness

* fix(web-shell): remove stale lint disables

* test(web-shell): make matchMedia stub writable

* fix(web-shell): exclude tests from package typecheck

* test(web-shell): tighten mock daemon route contract

* Update packages/web-shell/client/e2e/utils/mockDaemon.ts

Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>

* test(web-shell): clear stale SSE connections

* ci(web-shell): gate smoke on full CI profile

---------

Co-authored-by: ermin.zem <ermin.zem@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
2026-07-09 08:11:58 +00:00

334 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { isAgentTool } from '@qwen-code/webui/daemon-react-sdk';
import type { PermissionRequest } from '../../adapters/types';
import { useI18n } from '../../i18n';
import { isEditableTarget } from '../../utils/dom';
import { localizeToolDisplayName } from './toolFormatting';
import styles from './ToolApproval.module.css';
interface ToolApprovalProps {
request: PermissionRequest;
onConfirm: (id: string, selectedOption: string) => void;
variant?: 'inline' | 'floating';
/**
* Whether this instance owns the global keyboard shortcuts (Enter/Escape/j/k/
* digits). Defaults to true. Set false when several approvals can be mounted
* at once (e.g. split-view panes) so a keypress can't confirm the wrong
* session's request from behind or beside the focused one.
*/
keyboardActive?: boolean;
}
export function parseTitle(title?: string): {
toolName: string;
description: string;
} {
if (!title) return { toolName: '', description: '' };
const colonIdx = title.indexOf(': ');
if (colonIdx > 0) {
const prefix = title.slice(0, colonIdx);
// Only split CLI-style titles such as "Bash: npm test". Descriptive
// permission titles may contain ordinary prose like "(format: auto)";
// treating those colons as separators corrupts the header into name/desc.
if (!/^[A-Za-z][\w.-]{0,40}$/.test(prefix)) {
return { toolName: title, description: '' };
}
return {
toolName: prefix,
description: title.slice(colonIdx + 2),
};
}
return { toolName: title, description: '' };
}
function extractContentText(request: PermissionRequest): string {
const parts: string[] = [];
for (const block of request.content) {
if (block.type === 'text' && block.text) {
parts.push(block.text);
}
}
return parts.join('\n');
}
function isExecKind(request: PermissionRequest): boolean {
const toolName = request.toolName?.toLowerCase();
return (
request.kind === 'bash' ||
request.kind === 'exec' ||
request.kind === 'execute' ||
request.kind === 'shell' ||
toolName === 'run_shell_command'
);
}
function getCommandFromRawInput(request: PermissionRequest): string | null {
if (!request.rawInput) return null;
const raw = request.rawInput;
if (typeof raw.command === 'string') return raw.command;
if (typeof raw.input === 'string') return raw.input;
return null;
}
function getDescriptionText(request: PermissionRequest): string | undefined {
const description = request.rawInput?.description;
if (typeof description === 'string' && description.trim()) {
return description.trim();
}
return request.title;
}
function getSafeDefaultIndex(options: PermissionRequest['options']): number {
if (
options.length > 1 &&
(options[0].kind === 'allow_always' || options[0].kind === 'reject_always')
) {
const saferIdx = options.findIndex(
(o) => o.kind === 'allow_once' || o.kind === 'reject_once',
);
return saferIdx >= 0 ? saferIdx : 1;
}
return 0;
}
function getOptionRank(option: PermissionRequest['options'][number]): number {
if (option.kind === 'reject_once' || option.kind === 'reject_always') {
return 0;
}
if (option.kind === 'allow_always' && option.id === 'proceed_always_user') {
return 1;
}
if (
option.kind === 'allow_always' &&
option.id === 'proceed_always_project'
) {
return 2;
}
if (
option.kind === 'allow_always' &&
(option.id === 'proceed_always_server' ||
option.id === 'proceed_always_tool')
) {
return 3;
}
if (option.kind === 'allow_always') return 3;
if (option.kind === 'allow_once') return 4;
return 5;
}
function orderPermissionOptions(
options: PermissionRequest['options'],
): PermissionRequest['options'] {
return options
.map((option, index) => ({ option, index }))
.sort((a, b) => {
const rankDelta = getOptionRank(a.option) - getOptionRank(b.option);
return rankDelta === 0 ? a.index - b.index : rankDelta;
})
.map(({ option }) => option);
}
function getOptionI18nKey(
option: PermissionRequest['options'][number],
): string | undefined {
if (option.kind === 'allow_once') return 'approval.option.allowOnce';
if (option.kind === 'reject_once') return 'approval.option.rejectOnce';
if (option.kind === 'allow_always') {
if (option.id === 'proceed_always_project')
return 'approval.option.allowAlwaysProject';
if (option.id === 'proceed_always_user')
return 'approval.option.allowAlwaysUser';
if (option.id === 'proceed_always_server')
return 'approval.option.allowAlwaysServer';
if (option.id === 'proceed_always_tool')
return 'approval.option.allowAlwaysTool';
if (option.id === 'proceed_always') return 'approval.option.allowAllEdits';
}
return undefined;
}
function getOptionClassName(
option: PermissionRequest['options'][number],
): string {
if (option.kind === 'allow_once') return styles.optionPrimary;
if (option.kind === 'allow_always') return styles.optionSecondary;
if (option.kind === 'reject_once' || option.kind === 'reject_always') {
return styles.optionPlain;
}
return styles.optionSecondary;
}
export function ToolApproval({
request,
onConfirm,
variant = 'inline',
keyboardActive = true,
}: ToolApprovalProps) {
const { t } = useI18n();
const displayOptions = useMemo(
() => orderPermissionOptions(request.options),
[request.options],
);
const [selected, setSelected] = useState(() =>
getSafeDefaultIndex(orderPermissionOptions(request.options)),
);
const requestRef = useRef(request);
requestRef.current = request;
const selectedRef = useRef(selected);
const submittedRef = useRef(false);
const interactedRef = useRef(false);
useEffect(() => {
const safeDefaultIndex = getSafeDefaultIndex(
orderPermissionOptions(requestRef.current.options),
);
submittedRef.current = false;
interactedRef.current = false;
selectedRef.current = safeDefaultIndex;
setSelected(safeDefaultIndex);
}, [request.id]);
const parsedTitle = parseTitle(request.title);
const rawToolName =
request.toolName || parsedTitle.toolName || request.kind || 'Tool';
const toolName = localizeToolDisplayName(rawToolName, t);
const descriptionText = getDescriptionText(request);
const contentText = extractContentText(request);
const confirm = useCallback(
(optionId: string) => {
if (submittedRef.current) return;
submittedRef.current = true;
onConfirm(requestRef.current.id, optionId);
},
[onConfirm],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.defaultPrevented || isEditableTarget(e.target)) return;
const currentRequest = requestRef.current;
const currentOptions = orderPermissionOptions(currentRequest.options);
const optCount = currentOptions.length;
if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault();
interactedRef.current = true;
setSelected((s) => {
const next = (s - 1 + optCount) % optCount;
selectedRef.current = next;
return next;
});
} else if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault();
interactedRef.current = true;
setSelected((s) => {
const next = (s + 1) % optCount;
selectedRef.current = next;
return next;
});
} else if (e.key === 'Enter') {
e.preventDefault();
if (!interactedRef.current) {
interactedRef.current = true;
return;
}
const option = currentOptions[selectedRef.current];
if (option) confirm(option.id);
} else if (e.key === 'Escape') {
e.preventDefault();
const reject = currentRequest.options.find(
(o) => o.kind === 'reject_once' || o.kind === 'reject_always',
);
if (reject) confirm(reject.id);
} else if (e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key) - 1;
if (idx < optCount) {
e.preventDefault();
interactedRef.current = true;
confirm(currentOptions[idx].id);
}
}
},
[confirm],
);
useEffect(() => {
if (!keyboardActive) return;
const timer = setTimeout(() => {
window.addEventListener('keydown', handleKeyDown);
}, 250);
return () => {
clearTimeout(timer);
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown, keyboardActive]);
const isExec = isExecKind(request);
const isAgent = isAgentTool(request.toolName);
const command = getCommandFromRawInput(request);
return (
<div
className={
variant === 'floating'
? `${styles.approval} ${styles.floating}`
: styles.approval
}
data-web-shell-permission-panel
>
<div className={styles.header}>
<span className={styles.icon}>?</span>
<span className={styles.name}>{toolName}</span>
</div>
{descriptionText && (
<div className={styles.desc} title={descriptionText}>
{descriptionText}
</div>
)}
{isExec && command ? (
<div className={styles.code}>
<pre className={styles.codeBlock} title={command}>
{command}
</pre>
</div>
) : contentText && contentText !== request.title ? (
<pre className={styles.content} title={contentText}>
{contentText}
</pre>
) : null}
<div className={styles.question}>
{isAgent
? t('approval.launchAgentQuestion')
: isExec
? t('approval.execQuestion', { tool: toolName })
: t('approval.changeQuestion')}
</div>
<div className={styles.options}>
{displayOptions.map((option, i) => {
const isSelected = i === selected;
const i18nKey = getOptionI18nKey(option);
const label = i18nKey ? t(i18nKey) : option.label;
return (
<div
key={option.id}
className={`${styles.option} ${getOptionClassName(option)} ${
isSelected ? styles.optionActive : ''
}`}
data-web-shell-permission-option
data-option-id={option.id}
onClick={() => confirm(option.id)}
>
<span className={styles.pointer}>{isSelected ? '' : ' '}</span>
<span className={styles.num}>{i + 1}.</span>
<span className={styles.label}>{label}</span>
</div>
);
})}
</div>
</div>
);
}