feat: ship permission/approval UI with mobile support and context usage ring

This commit is contained in:
JOJO 2026-04-11 04:07:55 +08:00
parent e814e89e32
commit 34a6577f3a
22 changed files with 1233 additions and 144 deletions

View File

@ -276,6 +276,10 @@
:block-token-panel="policyUiBlocks.block_token_panel"
:block-compress-conversation="policyUiBlocks.block_compress_conversation"
:block-conversation-review="policyUiBlocks.block_conversation_review"
:current-permission-mode="currentPermissionMode"
:permission-menu-open="permissionMenuOpen"
:permission-options="permissionModeOptions"
:current-context-tokens="currentContextTokens"
@update:input-message="inputSetMessage"
@input-change="handleInputChange"
@input-focus="handleInputFocus"
@ -295,21 +299,33 @@
@toggle-focus-panel="handleFocusPanelToggleClick"
@toggle-token-panel="handleTokenPanelToggleClick"
@compress-conversation="handleCompressConversationClick"
@toggle-approval-panel="handleApprovalPanelToggleClick"
@file-selected="handleFileSelected"
@pick-images="openImagePicker"
@pick-video="openVideoPicker"
@remove-image="handleRemoveImage"
@remove-video="handleRemoveVideo"
@open-review="openReviewDialog"
@toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode"
/>
</div>
</main>
<div
v-if="!isMobileViewport"
class="resize-handle"
@mousedown="startResize('right', $event)"
></div>
<ToolApprovalPanel
v-if="!isMobileViewport"
:collapsed="rightCollapsed"
:width="rightWidth"
:approvals="pendingToolApprovals"
:deciding-approval-ids="decidingApprovalIds"
@approve="approveToolApproval"
@reject="rejectToolApproval"
@close="rightCollapsed = true"
/>
</div>
<PersonalizationDrawer v-if="personalizationStore.visible" />
@ -609,6 +625,27 @@
</div>
</div>
</transition>
<transition name="mobile-panel-overlay">
<div
v-if="isMobileViewport && activeMobileOverlay === 'approval'"
class="mobile-panel-overlay mobile-panel-overlay--right"
@click.self="closeMobileOverlay"
>
<div class="mobile-panel-sheet mobile-panel-sheet--approval">
<ToolApprovalPanel
class="mobile-overlay-content"
:collapsed="false"
:width="Math.min(rightWidth || 420, 460)"
:approvals="pendingToolApprovals"
:deciding-approval-ids="decidingApprovalIds"
@approve="approveToolApproval"
@reject="rejectToolApproval"
@close="closeMobileOverlay"
/>
</div>
</div>
</transition>
</template>
</AppShell>
</template>

View File

@ -2,6 +2,7 @@ import { defineAsyncComponent } from 'vue';
import ChatArea from '../components/chat/ChatArea.vue';
import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue';
import LeftPanel from '../components/panels/LeftPanel.vue';
import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue';
import TokenDrawer from '../components/token/TokenDrawer.vue';
import QuickMenu from '../components/input/QuickMenu.vue';
import InputComposer from '../components/input/InputComposer.vue';
@ -31,6 +32,7 @@ export const appComponents = {
ChatArea,
ConversationSidebar,
LeftPanel,
ToolApprovalPanel,
TokenDrawer,
PersonalizationDrawer,
QuickMenu,

View File

@ -90,6 +90,7 @@ export const conversationMethods = {
this.inputSetToolMenuOpen(false);
this.inputSetQuickMenuOpen(false);
this.modeMenuOpen = false;
this.permissionMenuOpen = false;
this.inputSetLineCount(1);
this.inputSetMultiline(false);
this.inputClearMessage();
@ -100,6 +101,8 @@ export const conversationMethods = {
this.conversationHasImages = false;
this.toolSetSettingsLoading(false);
this.toolSetSettings([]);
this.pendingToolApprovals = [];
this.decidingApprovalIds = [];
debugLog('前端状态重置完成');
this._scrollListenerReady = false;
@ -363,6 +366,8 @@ export const conversationMethods = {
// 4. 立即加载历史和统计,确保列表切换后界面同步更新
await this.fetchAndDisplayHistory();
this.fetchPermissionMode();
this.fetchPendingToolApprovals();
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
traceLog('loadConversation:after-history', {

View File

@ -139,6 +139,9 @@ export const resourceMethods = {
if (status && typeof status.model_key === 'string') {
this.modelSet(status.model_key);
}
if (status && typeof status.permission_mode === 'string') {
this.currentPermissionMode = status.permission_mode;
}
if (status && typeof status.has_images !== 'undefined') {
this.conversationHasImages = !!status.has_images;
}

View File

@ -248,6 +248,12 @@ export const taskPollingMethods = {
case 'update_action':
this.handleToolUpdateAction(eventData, eventIdx);
break;
case 'tool_approval_required':
this.handleToolApprovalRequired(eventData, eventIdx);
break;
case 'tool_approval_resolved':
this.handleToolApprovalResolved(eventData, eventIdx);
break;
case 'append_payload':
this.handleAppendPayload(eventData, eventIdx);
@ -789,6 +795,7 @@ export const taskPollingMethods = {
targetAction.tool.content = data.content;
}
this.$forceUpdate();
this.conditionalScrollToBottom();
@ -797,6 +804,43 @@ export const taskPollingMethods = {
}
},
handleToolApprovalRequired(data: any) {
const approval = data?.approval;
if (!approval || !approval.approval_id) {
return;
}
if (!Array.isArray(this.pendingToolApprovals)) {
this.pendingToolApprovals = [];
}
const idx = this.pendingToolApprovals.findIndex(
(item: any) => item && item.approval_id === approval.approval_id
);
if (idx >= 0) {
this.pendingToolApprovals.splice(idx, 1, approval);
} else {
this.pendingToolApprovals.push(approval);
}
this.rightCollapsed = false;
if (this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
}
this.$forceUpdate();
},
handleToolApprovalResolved(data: any) {
const approvalId = data?.approval_id;
if (!approvalId || !Array.isArray(this.pendingToolApprovals)) {
return;
}
this.pendingToolApprovals = this.pendingToolApprovals.filter(
(item: any) => item && item.approval_id !== approvalId
);
if (!this.pendingToolApprovals.length) {
this.rightCollapsed = true;
}
this.$forceUpdate();
},
handleAppendPayload(data: any) {
debugLog('[TaskPolling] 文件追加:', data.path);
this.chatAddAppendPayloadAction(data);

View File

@ -121,6 +121,9 @@ export const uiMethods = {
if (!this.isMobileViewport) {
return;
}
if (target === 'approval') {
this.fetchPendingToolApprovals();
}
if (this.activeMobileOverlay === target) {
this.closeMobileOverlay();
return;
@ -607,6 +610,150 @@ export const uiMethods = {
}
},
getPermissionModeLabel(mode) {
const options = Array.isArray(this.permissionModeOptions) ? this.permissionModeOptions : [];
const hit = options.find((item) => item.value === mode);
return hit ? hit.label : mode || '未知';
},
togglePermissionMenu() {
if (!this.isConnected) {
return;
}
this.permissionMenuOpen = !this.permissionMenuOpen;
},
closePermissionMenu() {
this.permissionMenuOpen = false;
},
async changePermissionMode(mode) {
const target = String(mode || '').trim().toLowerCase();
if (!target || target === this.currentPermissionMode) {
this.closePermissionMenu();
return;
}
try {
const response = await fetch('/api/permission-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ mode: target })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '切换权限失败');
}
this.currentPermissionMode = payload.mode || target;
this.uiPushToast({
title: '权限已切换',
message: `当前权限:${this.getPermissionModeLabel(this.currentPermissionMode)}`,
type: 'success',
duration: 1800
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '切换权限失败');
this.uiPushToast({
title: '切换权限失败',
message: msg,
type: 'error'
});
} finally {
this.closePermissionMenu();
}
},
async fetchPermissionMode() {
try {
const response = await fetch('/api/permission-mode');
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
if (typeof payload.mode === 'string') {
this.currentPermissionMode = payload.mode;
}
} catch (_error) {
// ignore
}
},
async fetchPendingToolApprovals() {
if (!this.currentConversationId) {
this.pendingToolApprovals = [];
return;
}
try {
const response = await fetch(
`/api/tool-approvals/pending?conversation_id=${encodeURIComponent(this.currentConversationId)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
const items = Array.isArray(payload.items) ? payload.items : [];
this.pendingToolApprovals = items;
if (items.length > 0) {
this.rightCollapsed = false;
if (this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
}
}
} catch (_error) {
// ignore
}
},
async decideToolApproval(approvalId, decision) {
const id = String(approvalId || '').trim();
if (!id) {
return;
}
if (!Array.isArray(this.decidingApprovalIds)) {
this.decidingApprovalIds = [];
}
if (!this.decidingApprovalIds.includes(id)) {
this.decidingApprovalIds.push(id);
}
try {
const response = await fetch(`/api/tool-approvals/${encodeURIComponent(id)}/decision`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ decision })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '提交审批失败');
}
this.pendingToolApprovals = (this.pendingToolApprovals || []).filter(
(item) => item && item.approval_id !== id
);
if (!this.pendingToolApprovals.length) {
this.rightCollapsed = true;
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '审批失败');
this.uiPushToast({
title: '审批失败',
message: msg,
type: 'error'
});
} finally {
this.decidingApprovalIds = (this.decidingApprovalIds || []).filter((item) => item !== id);
}
},
approveToolApproval(approvalId) {
return this.decideToolApproval(approvalId, 'approved');
},
rejectToolApproval(approvalId) {
return this.decideToolApproval(approvalId, 'rejected');
},
handleInputChange() {
this.autoResizeInput();
},
@ -614,6 +761,7 @@ export const uiMethods = {
handleInputFocus() {
this.inputSetFocused(true);
this.closeQuickMenu();
this.closePermissionMenu();
},
handleInputBlur() {
@ -640,6 +788,18 @@ export const uiMethods = {
this.toggleFocusPanel();
},
handleApprovalPanelToggleClick() {
if (!this.currentConversationId) {
return;
}
if (this.isMobileViewport) {
this.fetchPendingToolApprovals();
this.openMobileOverlay('approval');
return;
}
this.toggleApprovalPanel();
},
handleTokenPanelToggleClick() {
if (!this.currentConversationId) {
return;
@ -821,7 +981,7 @@ export const uiMethods = {
},
handleClickOutsideQuickMenu(event) {
if (!this.quickMenuOpen) {
if (!this.quickMenuOpen && !this.permissionMenuOpen) {
return;
}
const shell =
@ -830,6 +990,7 @@ export const uiMethods = {
return;
}
this.closeQuickMenu();
this.closePermissionMenu();
},
handleClickOutsideHeaderMenu(event) {
@ -909,6 +1070,16 @@ export const uiMethods = {
}
},
toggleApprovalPanel() {
this.rightCollapsed = !this.rightCollapsed;
if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
}
if (!this.rightCollapsed) {
this.fetchPendingToolApprovals();
}
},
addSystemMessage(content) {
console.log('[DEBUG_SYSTEM][ui] addSystemMessage 入参', { content });
const systemNoticeLabel = parseSystemNoticeLabel(content);
@ -1341,6 +1512,8 @@ export const uiMethods = {
this.agentVersion = statusData.version || this.agentVersion;
this.thinkingMode = !!statusData.thinking_mode;
this.applyStatusSnapshot(statusData);
this.fetchPermissionMode();
this.fetchPendingToolApprovals();
// 立即更新配额和运行模式,避免等待其他慢接口
this.fetchUsageQuota();
const modelStore = useModelStore();

View File

@ -79,6 +79,27 @@ export function dataState() {
mobileViewportQuery: null,
modeMenuOpen: false,
modelMenuOpen: false,
permissionMenuOpen: false,
currentPermissionMode: 'unrestricted',
permissionModeOptions: [
{
value: 'readonly',
label: '只读',
description: '仅允许读取/搜索类工具,禁止修改工作区'
},
{
value: 'approval',
label: '批准',
description: '对工作区文件进行修改的工具需用户批准后才会执行'
},
{
value: 'unrestricted',
label: '无限制',
description: '保持当前默认行为,不额外拦截'
}
],
pendingToolApprovals: [],
decidingApprovalIds: [],
imageEntries: [],
imageLoading: false,
videoEntries: [],

View File

@ -87,6 +87,20 @@ function formatBytes(bytes: number): string {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string {
const state = String(result?.status || props.action?.tool?.status || '').toLowerCase();
if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') {
return '待审批';
}
if (state === 'rejected') {
return '✗ 已拒绝';
}
if (state === 'cancelled') {
return '✗ 已取消';
}
return result?.success ? successLabel : failedLabel;
}
function renderEnhancedToolResult(): string {
const tool = props.action.tool;
const result = tool.result;
@ -203,7 +217,7 @@ function renderWebSearch(result: any, args: any): string {
function renderExtractWebpage(result: any, args: any): string {
const url = args.url || result.url || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const content = result.content || result.text || '';
let html = '<div class="tool-result-meta">';
@ -223,7 +237,7 @@ function renderExtractWebpage(result: any, args: any): string {
function renderSaveWebpage(result: any, args: any): string {
const url = args.url || result.url || '';
const path = result.path || args.save_path || '';
const status = result.success ? '✓ 已保存' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已保存');
let html = '<div class="tool-result-meta">';
html += `<div><strong>URL</strong>${escapeHtml(url)}</div>`;
@ -237,7 +251,7 @@ function renderSaveWebpage(result: any, args: any): string {
// ===== =====
function renderCreateFile(result: any, args: any): string {
const path = args.path || result.path || '';
const status = result.success ? '✓ 已创建' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已创建');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
@ -249,7 +263,7 @@ function renderCreateFile(result: any, args: any): string {
function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = result.success ? '✓ 已写入' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || '';
const isAppend = args.append || false;
@ -279,7 +293,7 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = result.success ? '✓ 已编辑' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已编辑');
//
// 1. args.replacements
@ -355,7 +369,7 @@ function renderEditFile(result: any, args: any): string {
function renderDeleteFile(result: any, args: any): string {
const path = args.path || result.path || '';
const status = result.success ? '✓ 已删除' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已删除');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
@ -368,7 +382,7 @@ function renderDeleteFile(result: any, args: any): string {
function renderRenameFile(result: any, args: any): string {
const oldPath = args.old_path || args.source || result.old_path || '';
const newPath = args.new_path || args.destination || result.new_path || '';
const status = result.success ? '✓ 已重命名' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已重命名');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(oldPath)}</div>`;
@ -382,7 +396,7 @@ function renderRenameFile(result: any, args: any): string {
// ===== =====
function renderReadFile(result: any, args: any): string {
const path = args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const size = result.size || result.file_size || 0;
const readType = result.type || 'read';
@ -462,7 +476,7 @@ function renderReadFile(result: any, args: any): string {
function renderVlmAnalyze(result: any, args: any): string {
const path = args.image_path || args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const analysis = result.analysis || result.result || '';
let html = '<div class="tool-result-meta">';
@ -482,7 +496,7 @@ function renderVlmAnalyze(result: any, args: any): string {
function renderOcrImage(result: any, args: any): string {
const path = args.image_path || args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const size = result.size || result.file_size || 0;
const text = result.text || result.ocr_text || '';
const imageUrl = result.image_url || result.url || '';
@ -511,7 +525,7 @@ function renderOcrImage(result: any, args: any): string {
function renderViewImage(result: any, args: any): string {
const path = args.image_path || args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const size = result.size || result.file_size || 0;
const imageUrl = result.image_url || result.url || '';
@ -534,7 +548,7 @@ function renderViewImage(result: any, args: any): string {
function renderTerminalSession(result: any, args: any): string {
const operation = args.operation || args.action || 'start';
const sessionName = args.session_name || args.name || result.session_name || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
let html = '<div class="tool-result-meta">';
html += `<div><strong>操作:</strong>${escapeHtml(operation)}</div>`;
@ -567,7 +581,7 @@ function renderTerminalInput(result: any, args: any): string {
function renderTerminalSnapshot(result: any, args: any): string {
const sessionName = args.session_name || args.name || result.session_name || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const startLine = args.start_line || 0;
const endLine = args.end_line || 0;
const content = result.content || result.output || '';
@ -592,7 +606,7 @@ function renderTerminalSnapshot(result: any, args: any): string {
function renderSleep(result: any, args: any): string {
const seconds = args.seconds || args.duration || 0;
const status = result.success ? '✓ 完成' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 完成');
let html = '<div class="tool-result-meta">';
html += `<div><strong>等待时间:</strong>${seconds}秒</div>`;
@ -690,7 +704,7 @@ function renderUpdateMemory(result: any, args: any): string {
// ===== =====
function renderTodoCreate(result: any, args: any): string {
const status = result.success ? '✓ 已创建' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已创建');
const title = args.title || result.title || '';
const tasks = args.tasks || result.tasks || [];
@ -714,7 +728,7 @@ function renderTodoCreate(result: any, args: any): string {
}
function renderTodoUpdate(result: any, args: any): string {
const status = result.success ? '✓ 已更新' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已更新');
const title = args.title || result.title || '';
const tasks = result.tasks || args.tasks || [];
@ -740,7 +754,7 @@ function renderTodoUpdate(result: any, args: any): string {
// ===== =====
function renderEasterEgg(result: any, args: any): string {
const status = result.success ? '✓ 已触发' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已触发');
const type = result.type || args.type || '';
const content = result.content || result.message || '';

View File

@ -14,6 +14,20 @@ export function formatBytes(bytes: number): string {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string {
const state = String(result?.status || '').toLowerCase();
if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') {
return '待审批';
}
if (state === 'rejected') {
return '✗ 已拒绝';
}
if (state === 'cancelled') {
return '✗ 已取消';
}
return result?.success ? successLabel : failedLabel;
}
export function renderEnhancedToolResult(
action: any,
formatSearchTopic: (filters: Record<string, any>) => string,
@ -144,7 +158,7 @@ function renderWebSearch(
function renderExtractWebpage(result: any, args: any): string {
const url = args.url || result.url || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const content = result.content || result.text || '';
let html = '<div class="tool-result-meta">';
@ -164,7 +178,7 @@ function renderExtractWebpage(result: any, args: any): string {
function renderSaveWebpage(result: any, args: any): string {
const url = args.url || result.url || '';
const path = result.path || args.save_path || '';
const status = result.success ? '✓ 已保存' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已保存');
let html = '<div class="tool-result-meta">';
html += `<div><strong>URL</strong>${escapeHtml(url)}</div>`;
@ -178,7 +192,7 @@ function renderSaveWebpage(result: any, args: any): string {
// 文件编辑类渲染函数
function renderCreateFile(result: any, args: any): string {
const path = args.path || result.path || '';
const status = result.success ? '✓ 已创建' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已创建');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
@ -190,7 +204,7 @@ function renderCreateFile(result: any, args: any): string {
function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = result.success ? '✓ 已写入' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || '';
const isAppend = args.append || false;
@ -220,7 +234,7 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = result.success ? '✓ 已编辑' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已编辑');
// 兼容两种数据格式:
// 1. 新格式args.replacements 数组
@ -296,7 +310,7 @@ function renderEditFile(result: any, args: any): string {
function renderDeleteFile(result: any, args: any): string {
const path = args.path || result.path || '';
const status = result.success ? '✓ 已删除' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已删除');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
@ -309,7 +323,7 @@ function renderDeleteFile(result: any, args: any): string {
function renderRenameFile(result: any, args: any): string {
const oldPath = args.old_path || args.source || result.old_path || '';
const newPath = args.new_path || args.destination || result.new_path || '';
const status = result.success ? '✓ 已重命名' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已重命名');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(oldPath)}</div>`;
@ -323,7 +337,7 @@ function renderRenameFile(result: any, args: any): string {
// 阅读聚焦类渲染函数
function renderReadFile(result: any, args: any): string {
const path = args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const size = result.size || result.file_size || 0;
const readType = result.type || 'read';
@ -403,7 +417,7 @@ function renderReadFile(result: any, args: any): string {
function renderVlmAnalyze(result: any, args: any): string {
const path = args.image_path || args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const analysis = result.analysis || result.result || '';
let html = '<div class="tool-result-meta">';
@ -423,7 +437,7 @@ function renderVlmAnalyze(result: any, args: any): string {
function renderOcrImage(result: any, args: any): string {
const path = args.image_path || args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const size = result.size || result.file_size || 0;
const text = result.text || result.ocr_text || '';
const imageUrl = result.image_url || result.url || '';
@ -452,7 +466,7 @@ function renderOcrImage(result: any, args: any): string {
function renderViewImage(result: any, args: any): string {
const path = args.image_path || args.path || result.path || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const size = result.size || result.file_size || 0;
const imageUrl = result.image_url || result.url || '';
@ -475,7 +489,7 @@ function renderViewImage(result: any, args: any): string {
function renderTerminalSession(result: any, args: any): string {
const operation = args.operation || args.action || 'start';
const sessionName = args.session_name || args.name || result.session_name || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
let html = '<div class="tool-result-meta">';
html += `<div><strong>操作:</strong>${escapeHtml(operation)}</div>`;
@ -508,7 +522,7 @@ function renderTerminalInput(result: any, args: any): string {
function renderTerminalSnapshot(result: any, args: any): string {
const sessionName = args.session_name || args.name || result.session_name || '';
const status = result.success ? '✓ 成功' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 成功');
const startLine = args.start_line || 0;
const endLine = args.end_line || 0;
const content = result.content || result.output || '';
@ -533,7 +547,7 @@ function renderTerminalSnapshot(result: any, args: any): string {
function renderSleep(result: any, args: any): string {
const seconds = args.seconds || args.duration || 0;
const status = result.success ? '✓ 完成' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 完成');
let html = '<div class="tool-result-meta">';
html += `<div><strong>等待时间:</strong>${seconds}秒</div>`;
@ -614,7 +628,7 @@ function renderUpdateMemory(result: any, args: any): string {
// 待办事项类渲染函数
function renderTodoCreate(result: any, args: any): string {
const status = result.success ? '✓ 已创建' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已创建');
const todoList = result.todo_list || {};
const title = todoList.title || args.title || '';
const tasks = todoList.tasks || args.tasks || [];
@ -640,7 +654,7 @@ function renderTodoCreate(result: any, args: any): string {
}
function renderTodoUpdate(result: any): string {
const status = result.success ? '✓ 已更新' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已更新');
const todoList = result.todo_list || {};
const title = todoList.title || '';
const tasks = todoList.tasks || [];
@ -670,7 +684,7 @@ function renderTodoUpdate(result: any): string {
// 彩蛋类渲染函数
function renderEasterEgg(result: any, args: any): string {
const status = result.success ? '✓ 已触发' : '✗ 失败';
const status = formatToolStatusLabel(result, '✓ 已触发');
const type = result.type || args.type || '';
const content = result.content || result.message || '';

View File

@ -131,16 +131,65 @@
@toggle-focus-panel="$emit('toggle-focus-panel')"
@toggle-token-panel="$emit('toggle-token-panel')"
@compress-conversation="$emit('compress-conversation')"
@toggle-approval-panel="$emit('toggle-approval-panel')"
@open-review="$emit('open-review')"
/>
<div class="permission-switcher" @click.stop>
<button
type="button"
class="permission-switcher__btn"
:disabled="!isConnected || streamingMessage"
@click="$emit('toggle-permission-menu')"
>
<span>权限{{ currentPermissionLabel }}</span>
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }"></span>
</button>
<div v-if="permissionMenuOpen" class="permission-switcher__menu">
<button
v-for="option in permissionOptions"
:key="option.value"
type="button"
class="permission-switcher__item"
:class="{ active: option.value === currentPermissionMode }"
@click="$emit('change-permission-mode', option.value)"
>
<span class="permission-switcher__item-label">{{ option.label }}</span>
<span class="permission-switcher__item-desc">{{ option.description }}</span>
</button>
</div>
</div>
<div class="context-usage-switcher" @click.stop>
<button
type="button"
class="context-usage-switcher__btn"
:disabled="!isConnected"
@click="$emit('toggle-token-panel')"
>
<span
class="context-usage-ring"
:style="{
'--context-progress': `${contextUsagePercent}%`,
'--context-ring-color': contextUsageColor
}"
aria-hidden="true"
>
<span class="context-usage-ring__inner"></span>
</span>
</button>
<div class="context-usage-switcher__tooltip">
<div>{{ contextUsagePercentLabel }}已用</div>
<div>{{ contextUsageCompactText }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch, nextTick } from 'vue';
import { onMounted, ref, watch, nextTick, computed } from 'vue';
import QuickMenu from '@/components/input/QuickMenu.vue';
import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization';
defineOptions({ name: 'InputComposer' });
@ -166,10 +215,13 @@ const emit = defineEmits([
'toggle-focus-panel',
'toggle-token-panel',
'compress-conversation',
'toggle-approval-panel',
'file-selected',
'remove-image',
'remove-video',
'open-review'
'open-review',
'toggle-permission-menu',
'change-permission-mode'
]);
const props = defineProps<{
@ -200,6 +252,7 @@ const props = defineProps<{
disabled?: boolean;
supportsImage?: boolean;
supportsVideo?: boolean;
contextWindow?: number | null;
}>;
currentModelKey: string;
selectedImages?: string[];
@ -212,9 +265,14 @@ const props = defineProps<{
blockTokenPanel?: boolean;
blockCompressConversation?: boolean;
blockConversationReview?: boolean;
currentPermissionMode: 'readonly' | 'approval' | 'unrestricted';
permissionMenuOpen: boolean;
permissionOptions: Array<{ value: string; label: string; description: string }>;
currentContextTokens: number;
}>();
const inputStore = useInputStore();
const personalizationStore = usePersonalizationStore();
const stadiumShellOuter = ref<HTMLElement | null>(null);
const compactInputShell = ref<HTMLElement | null>(null);
const stadiumInput = ref<HTMLTextAreaElement | null>(null);
@ -284,6 +342,72 @@ const triggerQuickUpload = () => {
emit('quick-upload');
};
const currentPermissionLabel = computed(() => {
const matched = (props.permissionOptions || []).find(
(item) => item.value === props.currentPermissionMode
);
return matched ? matched.label : props.currentPermissionMode;
});
const modelContextWindow = computed(() => {
const list = Array.isArray(props.modelOptions) ? props.modelOptions : [];
const current = list.find((item) => item.key === props.currentModelKey);
return Number(current?.contextWindow || 0);
});
const autoDeepCompressEnabled = computed(() => {
return !!personalizationStore?.form?.auto_deep_compress_enabled;
});
const deepCompressLimit = computed(() => {
const custom = Number(personalizationStore?.form?.deep_compress_trigger_tokens || 0);
if (custom > 0) return custom;
return 150000;
});
const contextUsageLimit = computed(() => {
if (autoDeepCompressEnabled.value) {
return deepCompressLimit.value;
}
if (modelContextWindow.value > 0) {
return modelContextWindow.value;
}
return 0;
});
const contextUsagePercent = computed(() => {
const limit = Number(contextUsageLimit.value || 0);
if (limit <= 0) return 0;
const current = Math.max(0, Number(props.currentContextTokens || 0));
return Math.max(0, Math.min(100, (current / limit) * 100));
});
const contextUsagePercentLabel = computed(() => `${Math.round(contextUsagePercent.value)}%`);
const contextUsageColor = computed(() => {
const percent = contextUsagePercent.value;
if (percent < 40) return '#16a34a';
if (percent <= 80) return '#f59e0b';
return '#ef4444';
});
const formatCompactTokens = (value: number) => {
const num = Math.max(0, Number(value || 0));
if (num >= 1000) {
const raw = num / 1000;
const text = raw >= 100 ? Math.round(raw).toString() : raw.toFixed(1).replace(/\.0$/, '');
return `${text}k`;
}
return `${Math.round(num)}`;
};
const contextUsageCompactText = computed(() => {
const current = formatCompactTokens(props.currentContextTokens || 0);
const limit = Number(contextUsageLimit.value || 0);
if (limit <= 0) return `${current}/--`;
return `${current}/${formatCompactTokens(limit)}`;
});
defineExpose({
stadiumShellOuter,
compactInputShell,

View File

@ -121,6 +121,14 @@
>
{{ compressing ? '压缩中...' : '压缩对话' }}
</button>
<button
type="button"
class="menu-entry submenu-entry"
@click="$emit('toggle-approval-panel')"
:disabled="!currentConversationId"
>
审批面板
</button>
</div>
</div>
</transition>
@ -176,6 +184,7 @@ defineEmits<{
(event: 'realtime-terminal'): void;
(event: 'toggle-token-panel'): void;
(event: 'compress-conversation'): void;
(event: 'toggle-approval-panel'): void;
(event: 'toggle-mode-menu'): void;
(event: 'select-run-mode', mode: 'fast' | 'thinking' | 'deep'): void;
(event: 'toggle-model-menu'): void;

View File

@ -0,0 +1,171 @@
<template>
<aside
class="sidebar right-sidebar tool-approval-panel"
:class="{ collapsed }"
:style="{ width: collapsed ? '0px' : width + 'px' }"
>
<div class="sidebar-header">
<h3 class="icon-label">工具审批 ({{ approvals.length }})</h3>
<button type="button" class="approval-close-btn" aria-label="关闭审批面板" @click="$emit('close')">
×
</button>
</div>
<div class="approval-panel-body" v-if="!collapsed">
<div v-if="!approvals.length" class="no-files">暂无待审批操作</div>
<div v-else class="approval-list">
<div v-for="item in approvals" :key="item.approval_id" class="approval-card">
<div class="approval-card__title">{{ item.tool_name }}</div>
<div class="approval-card__summary">{{ getToolLabel(item.tool_name) }}</div>
<div v-if="isEditPreview(item)" class="approval-edit-preview">
<div class="approval-path">{{ item.preview?.file_path || item.preview?.resolved_path }}</div>
<div class="tool-result-diff scrollable approval-diff">
<div
class="diff-line diff-remove"
v-for="(line, idx) in getEditOldLines(item)"
:key="`o-${item.approval_id}-${idx}`"
>
- {{ line }}
</div>
<div
class="diff-line diff-add"
v-for="(line, idx) in getEditNewLines(item)"
:key="`n-${item.approval_id}-${idx}`"
>
+ {{ line }}
</div>
</div>
</div>
<div v-else-if="isWritePreview(item)" class="approval-edit-preview">
<div class="approval-path">{{ item.preview?.file_path }}</div>
<div class="tool-result-diff scrollable approval-diff">
<div
class="diff-line diff-add"
v-for="(line, idx) in getWriteLines(item)"
:key="`w-${item.approval_id}-${idx}`"
>
+ {{ line }}
</div>
<div class="approval-note" v-if="isWriteTruncated(item)">
内容过长已截断展示
</div>
</div>
</div>
<div v-else-if="isPathOnlyPreview(item)" class="approval-kv">
<div>
<strong>路径</strong
><span class="approval-value approval-value--path">{{ resolvePath(item) }}</span>
</div>
</div>
<div v-else-if="isRenamePreview(item)" class="approval-kv">
<div>
<strong>重命名</strong
><span class="approval-value approval-value--path"
>{{ item.preview?.old_path }} {{ item.preview?.new_path }}</span
>
</div>
</div>
<pre
v-else-if="isCommandPreview(item)"
class="approval-lines approval-lines--cmd"
>{{ item.preview?.command || '' }}</pre>
<div v-else class="approval-kv">
<div><strong>工具</strong>{{ item.tool_name }}</div>
<div v-if="item.preview?.summary"><strong>说明</strong>{{ item.preview.summary }}</div>
</div>
<div class="approval-actions">
<button
type="button"
class="approval-btn approval-btn--approve"
:disabled="isDeciding(item.approval_id)"
@click="$emit('approve', item.approval_id)"
>
运行
</button>
<button
type="button"
class="approval-btn approval-btn--reject"
:disabled="isDeciding(item.approval_id)"
@click="$emit('reject', item.approval_id)"
>
拒绝
</button>
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
defineOptions({ name: 'ToolApprovalPanel' });
const props = defineProps<{
collapsed: boolean;
width: number;
approvals: Array<any>;
decidingApprovalIds?: string[];
}>();
defineEmits<{
(event: 'approve', approvalId: string): void;
(event: 'reject', approvalId: string): void;
(event: 'close'): void;
}>();
const isEditPreview = (item: any) => item?.tool_name === 'edit_file' && item?.preview?.edit_context;
const isCommandPreview = (item: any) => ['run_command', 'terminal_input'].includes(item?.tool_name);
const isWritePreview = (item: any) => item?.tool_name === 'write_file';
const isRenamePreview = (item: any) => item?.tool_name === 'rename_file';
const isPathOnlyPreview = (item: any) =>
['create_file', 'create_folder', 'delete_file'].includes(item?.tool_name);
const isDeciding = (approvalId: string) =>
Array.isArray(props.decidingApprovalIds) && props.decidingApprovalIds.includes(approvalId);
const resolvePath = (item: any) => {
return item?.preview?.path || item?.preview?.file_path || item?.arguments?.path || item?.arguments?.file_path || '';
};
const getToolLabel = (toolName: string) => {
const name = String(toolName || '');
const map: Record<string, string> = {
run_command: '执行命令',
terminal_input: '终端输入',
create_file: '创建文件',
create_folder: '创建文件夹',
delete_file: '删除文件',
rename_file: '重命名文件',
write_file: '写入文件',
edit_file: '编辑文件'
};
return map[name] || name || '待审批操作';
};
const getWriteLines = (item: any): string[] => {
const text = String(item?.preview?.content_preview || '');
return text.split('\n');
};
const getEditOldLines = (item: any): string[] => {
const rows = Array.isArray(item?.preview?.edit_context?.old) ? item.preview.edit_context.old : [];
return rows.map((row: any) => String(row?.content ?? ''));
};
const getEditNewLines = (item: any): string[] => {
const rows = Array.isArray(item?.preview?.edit_context?.new) ? item.preview.edit_context.new : [];
return rows.map((row: any) => String(row?.content ?? ''));
};
const isWriteTruncated = (item: any): boolean => {
const text = String(item?.preview?.content_preview || '');
const length = Number(item?.preview?.content_length || 0);
return length > text.length;
};
</script>

View File

@ -424,6 +424,55 @@
</div>
</div>
</section>
<section
v-else-if="activeTab === 'workspace'"
key="workspace"
class="personal-page behavior-page"
data-tutorial="personal-page-workspace"
>
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">默认权限</span>
<p class="field-desc">
新建对话时的默认权限模式权限会与对话绑定切换对话会自动切换对应权限
</p>
</div>
<div class="run-mode-options">
<button
v-for="option in permissionModeOptions"
:key="option.id"
type="button"
class="run-mode-card"
:class="{ active: form.default_permission_mode === option.id }"
:aria-pressed="form.default_permission_mode === option.id"
@click.prevent="setDefaultPermissionMode(option.id)"
>
<div class="run-mode-card-header">
<span class="run-mode-title">{{ option.label }}</span>
</div>
<p class="run-mode-desc">{{ option.desc }}</p>
</button>
</div>
</div>
<div class="personal-actions-row">
<div class="personal-form-actions card-aligned">
<div class="personal-status-group">
<transition name="personal-status-fade">
<span class="status success" v-if="status">{{ status }}</span>
</transition>
<transition name="personal-status-fade">
<span class="status error" v-if="error">{{ error }}</span>
</transition>
</div>
<button type="submit" class="primary" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</div>
</section>
<section
v-else-if="activeTab === 'usage'"
key="usage"
@ -1328,6 +1377,7 @@ const {
const baseTabs = [
{ id: 'preferences', label: '个性化设置', description: '称呼、语气与注意事项' },
{ id: 'model', label: '模型偏好', description: '默认模型选择' },
{ id: 'workspace', label: '工作区设置', description: '默认权限模式' },
{ id: 'usage', label: '用量统计', description: '对话累计数据' },
{ id: 'app-update', label: '软件更新', description: '检查并下载新版 APK' },
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
@ -1340,6 +1390,7 @@ const baseTabs = [
type PersonalTab =
| 'preferences'
| 'model'
| 'workspace'
| 'usage'
| 'app-update'
| 'behavior'
@ -1369,6 +1420,7 @@ const activeTab = ref<PersonalTab>('preferences');
const swipeState = ref<{ startY: number; active: boolean }>({ startY: 0, active: false });
type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
type PermissionModeValue = 'readonly' | 'approval' | 'unrestricted';
type CompressionField =
| 'shallow_compress_trigger_tokens'
| 'shallow_compress_keep_recent_tools'
@ -1388,6 +1440,16 @@ const runModeOptions: Array<{
{ id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' }
];
const permissionModeOptions: Array<{
id: PermissionModeValue;
label: string;
desc: string;
}> = [
{ id: 'readonly', label: '只读', desc: '仅允许读取/检索类工具,修改操作将被拒绝。' },
{ id: 'approval', label: '批准', desc: '对工作区文件进行修改的工具需人工批准后执行。' },
{ id: 'unrestricted', label: '无限制', desc: '工具按常规流程直接执行。' }
];
const policyStore = usePolicyStore();
const modelStore = useModelStore();
@ -1733,6 +1795,10 @@ const setDefaultModel = (value: string) => {
personalization.setDefaultModel(value);
};
const setDefaultPermissionMode = (value: PermissionModeValue) => {
personalization.setDefaultPermissionMode(value);
};
const checkModeModelConflict = (mode: RunModeValue, model: string | null): boolean => {
const found = (filteredModelOptions.value || []).find((item: any) => item.value === model);
const warnings: string[] = [];

View File

@ -3,6 +3,7 @@ import { useModelStore } from './model';
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
type RunMode = 'fast' | 'thinking' | 'deep';
type PermissionMode = 'readonly' | 'approval' | 'unrestricted';
interface PersonalForm {
enabled: boolean;
@ -25,6 +26,7 @@ interface PersonalForm {
thinking_interval: number | null;
disabled_tool_categories: string[];
default_run_mode: RunMode | null;
default_permission_mode: PermissionMode;
default_model: string | null;
image_compression: string;
auto_shallow_compress_enabled: boolean;
@ -89,6 +91,7 @@ const defaultForm = (): PersonalForm => ({
thinking_interval: null,
disabled_tool_categories: [],
default_run_mode: null,
default_permission_mode: 'unrestricted',
default_model: 'kimi-k2.5',
image_compression: 'original',
auto_shallow_compress_enabled: false,
@ -244,6 +247,12 @@ export const usePersonalizationStore = defineStore('personalization', {
RUN_MODE_OPTIONS.includes(data.default_run_mode as RunMode)
? (data.default_run_mode as RunMode)
: null,
default_permission_mode:
data.default_permission_mode === 'readonly' ||
data.default_permission_mode === 'approval' ||
data.default_permission_mode === 'unrestricted'
? data.default_permission_mode
: 'unrestricted',
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
image_compression:
typeof data.image_compression === 'string' ? data.image_compression : 'original',
@ -478,6 +487,15 @@ export const usePersonalizationStore = defineStore('personalization', {
};
this.clearFeedback();
},
setDefaultPermissionMode(mode: PermissionMode) {
const allowed: PermissionMode[] = ['readonly', 'approval', 'unrestricted'];
const target = allowed.includes(mode) ? mode : 'unrestricted';
this.form = {
...this.form,
default_permission_mode: target
};
this.clearFeedback();
},
setDefaultModel(model: string | null) {
const modelStore = useModelStore();
const allowed = new Set((modelStore.models || []).map((m) => m.key));

View File

@ -2,7 +2,7 @@ import { defineStore } from 'pinia';
type PanelMode = 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
type ResizingPanel = 'left' | 'right' | null;
type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | null;
type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | 'approval' | null;
interface QuotaToast {
message: string;

View File

@ -14,6 +14,7 @@
.compact-input-area {
display: flex;
justify-content: center;
padding-bottom: 0;
pointer-events: none;
}
@ -23,6 +24,186 @@
pointer-events: auto;
}
.permission-switcher {
position: absolute;
left: 20px;
bottom: -28px;
z-index: 35;
}
.context-usage-switcher {
position: absolute;
right: 35px;
bottom: -28px;
z-index: 35;
}
.context-usage-switcher__btn {
border: none;
background: transparent;
padding: 0;
width: 28px;
height: 28px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: none;
}
.context-usage-switcher__btn:hover:not(:disabled) {
background: var(--theme-chip-bg);
}
.context-usage-switcher__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.context-usage-ring {
--context-progress: 0%;
--context-ring-color: var(--claude-accent);
width: 18px;
height: 18px;
border-radius: 50%;
background:
conic-gradient(var(--context-ring-color) var(--context-progress), rgba(127, 135, 146, 0.28) 0);
display: inline-flex;
align-items: center;
justify-content: center;
}
.context-usage-ring__inner {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--theme-surface-soft);
border: 1px solid var(--theme-control-border);
}
.context-usage-switcher__tooltip {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
min-width: 94px;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-soft);
box-shadow: var(--theme-shadow-mid);
color: var(--claude-text);
font-size: 12px;
line-height: 1.35;
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition: opacity 0.16s ease, transform 0.16s ease;
}
.context-usage-switcher:hover .context-usage-switcher__tooltip {
opacity: 1;
transform: translateY(0);
}
.permission-switcher__btn {
border: none;
background: transparent;
color: var(--claude-text);
border-radius: 999px;
padding: 4px 12px;
font-size: 14px;
line-height: 1.2;
cursor: pointer;
box-shadow: none;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.permission-switcher__btn:hover:not(:disabled) {
color: var(--claude-text);
text-decoration: none;
background: var(--theme-chip-bg);
box-shadow: none;
}
.permission-switcher__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.permission-switcher__menu {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
width: 250px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--theme-control-border);
border-radius: 14px;
background: var(--theme-surface-soft);
box-shadow: var(--theme-shadow-mid);
}
.permission-switcher__caret {
font-size: 14px;
color: var(--claude-text-secondary);
transform: rotate(90deg);
transition: transform 0.18s ease;
}
.permission-switcher__caret.open {
transform: rotate(270deg);
}
.permission-switcher__item {
border: none;
background: transparent;
text-align: left;
border-radius: 10px;
padding: 8px 10px;
cursor: pointer;
color: var(--claude-text);
display: flex;
flex-direction: column;
gap: 2px;
}
.permission-switcher__item:hover {
background: var(--theme-chip-bg);
}
.permission-switcher__item.active {
background: var(--theme-tab-active);
}
.permission-switcher__item-label {
font-size: 13px;
font-weight: 600;
}
.permission-switcher__item-desc {
font-size: 11px;
color: var(--claude-text-secondary);
line-height: 1.35;
}
@media (max-width: 768px) {
.permission-switcher {
left: 20px;
bottom: -28px;
}
.context-usage-switcher {
right: 25px;
bottom: -28px;
}
}
.stadium-shell {
--stadium-radius: 24px;
position: relative;

View File

@ -1762,6 +1762,12 @@
max-width: 100vw;
}
.mobile-panel-sheet--approval {
background: var(--claude-panel);
width: min(460px, 92vw);
max-width: 92vw;
}
.mobile-panel-sheet--focus {
background: var(--claude-panel);
}
@ -1827,7 +1833,8 @@
padding: 12px 16px 0;
}
.mobile-panel-sheet .focused-files {
.mobile-panel-sheet .focused-files,
.mobile-panel-sheet .approval-panel-body {
padding-top: 8px;
}
}
@ -3511,6 +3518,14 @@ body[data-theme='dark'] {
background: #353535;
}
.approval-close-btn {
background: #2a2a2a;
}
.approval-close-btn:hover {
background: #353535;
}
/* 表格 */
.text-output .text-content table {
background: #2a2a2a;

View File

@ -1,101 +0,0 @@
/* 聚焦文件面板 */
.sidebar.right-sidebar {
display: flex;
flex-direction: column;
}
.sidebar.right-sidebar .sidebar-header {
padding: 20px 20px 8px;
border-bottom: 1px solid var(--claude-border);
background: var(--claude-panel);
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sidebar.right-sidebar .sidebar-header h3 {
font-size: 15px;
font-weight: 600;
color: var(--claude-text);
margin: 0;
}
.focused-files {
flex: 1 1 auto;
padding: 12px 20px 20px;
overflow-y: auto;
}
.focus-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: rgba(0, 0, 0, 0.08);
color: var(--claude-text);
font-size: 20px;
cursor: pointer;
}
.no-files {
text-align: center;
color: var(--claude-text-secondary);
padding: 60px 20px;
font-size: 14px;
}
.file-tabs {
display: flex;
flex-direction: column;
gap: 12px;
}
.file-tab {
border: 1px solid var(--claude-border);
border-radius: 12px;
overflow: hidden;
background: rgba(255, 255, 255, 0.75);
box-shadow: 0 12px 28px rgba(61, 57, 41, 0.08);
}
.tab-header {
background: var(--theme-chip-bg);
padding: 10px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
border-bottom: 1px solid var(--claude-border);
}
.file-name {
font-weight: 500;
color: var(--claude-text);
}
.file-size {
color: var(--claude-text-secondary);
font-size: 12px;
}
.file-content {
max-height: 320px;
overflow-y: auto;
background: #ffffff;
}
.file-content pre {
margin: 0;
padding: 16px;
}
.file-content code {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: #1f2227;
}

View File

@ -368,11 +368,15 @@
min-width: 0 !important;
border-left: none;
overflow: hidden;
opacity: 0;
transform: translateX(8px);
}
.sidebar.right-sidebar.collapsed .sidebar-header,
.sidebar.right-sidebar.collapsed .focused-files {
display: none;
.sidebar.right-sidebar.collapsed .focused-files,
.sidebar.right-sidebar.collapsed .approval-panel-body {
opacity: 0;
pointer-events: none;
}
/* 文件树 */

View File

@ -0,0 +1,282 @@
/* 聚焦文件面板 */
.sidebar.right-sidebar {
display: flex;
flex-direction: column;
background: var(--theme-surface-soft);
transition:
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.22s ease,
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar.right-sidebar .sidebar-header {
padding: 16px 20px 8px;
border-bottom: none;
background: transparent;
position: static;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sidebar.right-sidebar .sidebar-header h3 {
font-size: 15px;
font-weight: 600;
color: var(--claude-text);
margin: 0;
}
.focused-files {
flex: 1 1 auto;
padding: 10px 20px 20px;
overflow-y: auto;
}
.approval-panel-body {
flex: 1 1 auto;
padding: 10px 20px 20px;
overflow-y: auto;
}
.focus-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: rgba(0, 0, 0, 0.08);
color: var(--claude-text);
font-size: 20px;
cursor: pointer;
}
.approval-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: rgba(0, 0, 0, 0.08);
color: var(--claude-text);
font-size: 20px;
cursor: pointer;
}
.no-files {
text-align: center;
color: var(--claude-text-secondary);
padding: 60px 20px;
font-size: 14px;
}
.file-tabs {
display: flex;
flex-direction: column;
gap: 12px;
}
.file-tab {
border: 1px solid var(--claude-border);
border-radius: 12px;
overflow: hidden;
background: rgba(255, 255, 255, 0.75);
box-shadow: 0 12px 28px rgba(61, 57, 41, 0.08);
}
.tab-header {
background: var(--theme-chip-bg);
padding: 10px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
border-bottom: 1px solid var(--claude-border);
}
.file-name {
font-weight: 500;
color: var(--claude-text);
}
.file-size {
color: var(--claude-text-secondary);
font-size: 12px;
}
.file-content {
max-height: 320px;
overflow-y: auto;
background: #ffffff;
}
.file-content pre {
margin: 0;
padding: 16px;
}
.file-content code {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: #1f2227;
}
.tool-approval-panel .approval-panel-body {
padding-top: 14px;
}
.approval-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.approval-card {
border: 1px solid var(--claude-border);
border-radius: 12px;
background: var(--theme-surface-soft);
box-shadow: var(--theme-shadow-soft);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.approval-card__title {
font-size: 13px;
font-weight: 700;
color: var(--claude-text);
}
.approval-card__summary {
font-size: 12px;
color: var(--claude-text-secondary);
}
.approval-path {
font-size: 12px;
color: var(--claude-text-secondary);
word-break: break-all;
}
.approval-lines {
margin: 0;
padding: 8px;
border-radius: 8px;
font-size: 11px;
line-height: 1.45;
overflow: auto;
max-height: 200px;
white-space: pre-wrap;
word-break: break-word;
background: var(--theme-surface-muted);
scrollbar-width: none;
-ms-overflow-style: none;
}
.approval-lines::-webkit-scrollbar {
width: 0;
height: 0;
}
.approval-diff {
margin-top: 12px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 8px;
background: rgba(0, 0, 0, 0.01);
}
.approval-kv {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--claude-text-secondary);
background: var(--theme-surface-muted);
border: 1px solid var(--theme-control-border);
border-radius: 8px;
padding: 8px;
}
.approval-kv strong {
white-space: nowrap;
}
.approval-value {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.approval-note {
padding: 6px 8px;
font-size: 11px;
color: var(--claude-text-secondary);
}
.approval-lines--old {
background: color-mix(in srgb, var(--claude-warning) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--claude-warning) 50%, transparent);
}
.approval-lines--new {
background: color-mix(in srgb, var(--claude-success) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--claude-success) 50%, transparent);
}
.approval-lines--cmd,
.approval-lines--generic {
background: var(--theme-surface-muted);
}
.tool-approval-panel .diff-line {
padding: 2px 4px;
margin: 1px 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.tool-approval-panel .diff-remove {
background: rgba(255, 0, 0, 0.1);
color: #c00;
}
.tool-approval-panel .diff-add {
background: rgba(0, 255, 0, 0.1);
color: #0a0;
}
.approval-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.approval-btn {
border: none;
border-radius: 8px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.approval-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.approval-btn--approve {
color: var(--theme-surface-strong);
background: var(--claude-success);
}
.approval-btn--reject {
color: var(--theme-surface-strong);
background: var(--claude-accent-strong);
}

View File

@ -5,7 +5,7 @@
@use './layout/panels';
@use './components/sidebar/conversation';
@use './components/panels/left-panel';
@use './components/panels/focus-panel';
@use './components/panels/right-panel';
@use './components/panels/resource-panel';
@use './components/chat/chat-area';
@use './components/chat/virtual-monitor';

View File

@ -185,6 +185,13 @@ export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean })
const label = RUNNING_STATUS_TEXTS[tool.name] || tool.display_name || tool.name || '';
return label ? label : '调用工具中';
}
if (
tool.status === 'awaiting_approval' ||
tool.status === 'pending_approval' ||
tool.status === 'pending'
) {
return '等待审批...';
}
if (tool.status === 'completed') {
if (tool.name === 'read_file') {
return describeReadFileResult(tool);