fix(B03): preauthorize full access and replace composer with active approval
This commit is contained in:
1 parent
0bc9faa129
commit
adbbb3bd37
15 files changed
+627
-63
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
createSdkMcpServer,
|
||||
query,
|
||||
@@ -256,10 +256,10 @@ function modelEnvironment(model: ResolvedModel): NodeJS.ProcessEnv {
|
||||
return env
|
||||
}
|
||||
|
||||
function permissionModeFor(mode: PermissionMode, agentMode: AgentMode): 'default' | 'acceptEdits' | 'plan' {
|
||||
if (agentMode === 'plan') return 'plan'
|
||||
if (agentMode === 'ask') return 'default'
|
||||
return mode === 'full-access' ? 'acceptEdits' : 'default'
|
||||
function permissionModeFor(_mode: PermissionMode, agentMode: AgentMode): 'default' | 'plan' {
|
||||
// Full access is explicit host preauthorization in PreToolUse, not acceptEdits
|
||||
// (which still asks for commands), nor a process-wide bypass capability.
|
||||
return agentMode === 'plan' ? 'plan' : 'default'
|
||||
}
|
||||
|
||||
function thinkingOptions(level: ThinkingLevel | undefined): {
|
||||
@@ -328,6 +328,10 @@ export class ClaudeKernel {
|
||||
private active: ActiveTurn | null = null
|
||||
private starting = false
|
||||
private permissionUpdates: Promise<void> = Promise.resolve()
|
||||
private policyTransitions = 0
|
||||
private policyRevision = 0
|
||||
private policyError: Error | null = null
|
||||
private sdkPermissionMode: ReturnType<typeof permissionModeFor> | null = null
|
||||
private draining: Promise<void> | null = null
|
||||
private contextQuery: ReturnType<Query['getContextUsage']> | null = null
|
||||
private lastRequestId?: string
|
||||
@@ -358,6 +362,9 @@ export class ClaudeKernel {
|
||||
}
|
||||
|
||||
async ensureStarted(): Promise<void> {
|
||||
await this.permissionUpdates
|
||||
await this.draining
|
||||
if (this.policyError) throw this.policyError
|
||||
if (this.instance) return
|
||||
if (this.closed) throw new Error('Claude 内核已释放')
|
||||
const executable = claudeExecutablePath()
|
||||
@@ -399,10 +406,22 @@ export class ClaudeKernel {
|
||||
if (hook.hook_event_name !== 'PreToolUse') return {}
|
||||
const input = hook.tool_input && typeof hook.tool_input === 'object' ? hook.tool_input as Record<string, unknown> : {}
|
||||
// SDK allow rules can bypass canUseTool, but never this host mode boundary.
|
||||
if (!this.active || this.active.request.signal.aborted || (this.agentMode !== 'craft' && !isModeAllowedCall(this.agentMode, hook.tool_name, input))) {
|
||||
if (this.policyTransitions || this.policyError || !this.active || this.active.settled || this.active.request.signal.aborted || (this.agentMode !== 'craft' && !isModeAllowedCall(this.agentMode, hook.tool_name, input))) {
|
||||
this.recordPermissionDecision(hook.tool_use_id, 'hook', 'mode-or-inactive-deny')
|
||||
return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const,
|
||||
permissionDecisionReason: '当前回合或对话模式不允许此操作。' } }
|
||||
permissionDecisionReason: '当前回合、权限切换或对话模式不允许此操作。' } }
|
||||
}
|
||||
// Business questions are not tool permission approvals. Force the
|
||||
// callback even when a session grant would otherwise auto-allow.
|
||||
if (hook.tool_name === 'AskUserQuestion') {
|
||||
this.recordPermissionDecision(hook.tool_use_id, 'hook', 'business-question')
|
||||
return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'ask' as const } }
|
||||
}
|
||||
if (hook.tool_name !== 'ExitPlanMode' && this.permissionMode === 'full-access' && this.agentMode === 'craft') {
|
||||
this.recordPermissionDecision(hook.tool_use_id, 'hook', 'full-access-preauthorized')
|
||||
return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'allow' as const } }
|
||||
}
|
||||
this.recordPermissionDecision(hook.tool_use_id, 'hook', hook.tool_name === 'ExitPlanMode' ? 'plan-content' : 'sdk-policy')
|
||||
return {}
|
||||
}]
|
||||
}, {
|
||||
@@ -510,6 +529,7 @@ export class ClaudeKernel {
|
||||
this.input = input
|
||||
this.processAbort = processAbort
|
||||
this.instance = instance
|
||||
this.sdkPermissionMode = permissionModeFor(this.permissionMode, this.agentMode)
|
||||
const consuming = this.consume(instance)
|
||||
this.consuming = consuming
|
||||
void consuming.finally(() => {
|
||||
@@ -543,6 +563,7 @@ export class ClaudeKernel {
|
||||
instance.close()
|
||||
this.instance = null
|
||||
this.contextQuery = null
|
||||
this.sdkPermissionMode = null
|
||||
this.input = null
|
||||
this.processAbort = null
|
||||
}
|
||||
@@ -551,10 +572,16 @@ export class ClaudeKernel {
|
||||
|
||||
private canUseTool: CanUseTool = async (toolName, input, options): Promise<PermissionResult | null> => {
|
||||
const active = await this.waitForSegment()
|
||||
if (!active) return { behavior: 'deny', message: '当前没有正在运行的 YCode 回合。' }
|
||||
const revision = this.policyRevision
|
||||
const record = (branch: PermissionBranch): void => this.recordPermissionDecision(options.toolUseID, 'callback', branch, options)
|
||||
if (this.policyTransitions || this.policyError || !active || active.settled) {
|
||||
record('mode-or-inactive-deny')
|
||||
return { behavior: 'deny', message: '当前没有可执行操作的 YCode 回合,或权限正在切换。' }
|
||||
}
|
||||
if (options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
|
||||
try {
|
||||
if (toolName === 'ExitPlanMode') {
|
||||
record('plan-content')
|
||||
if (!active.planEmitted) {
|
||||
active.planEmitted = true
|
||||
const plan = planFromExitInput(input, active.content)
|
||||
@@ -567,12 +594,15 @@ export class ClaudeKernel {
|
||||
return { behavior: 'deny', message: '计划已显示在 YCode 中,请切换到 craft 后开始实施。' }
|
||||
}
|
||||
if (this.agentMode === 'ask' && !isModeAllowedCall('ask', toolName, input)) {
|
||||
record('mode-or-inactive-deny')
|
||||
return { behavior: 'deny', message: 'Ask 模式只允许读取资料、搜索和提问,不能修改文件或执行命令。' }
|
||||
}
|
||||
if (this.agentMode === 'plan' && !isModeAllowedCall('plan', toolName, input)) {
|
||||
record('mode-or-inactive-deny')
|
||||
return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' }
|
||||
}
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
record('business-question')
|
||||
const questions = questionsFromInput(input)
|
||||
if (!questions.length) return { behavior: 'deny', message: '问题内容为空。' }
|
||||
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } })
|
||||
@@ -582,7 +612,7 @@ export class ClaudeKernel {
|
||||
questions,
|
||||
options.signal
|
||||
)
|
||||
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
|
||||
if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
|
||||
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
|
||||
if (response.kind !== 'question') return { behavior: 'deny', message: '问答响应无效。' }
|
||||
if (response.skipped) return { behavior: 'deny', message: '用户关闭了问题,未提供回答。' }
|
||||
@@ -593,9 +623,11 @@ export class ClaudeKernel {
|
||||
}
|
||||
return { behavior: 'allow', updatedInput: { ...input, answers } }
|
||||
}
|
||||
if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) {
|
||||
if (this.permissionMode === 'full-access' && this.agentMode === 'craft') {
|
||||
record('full-access-preauthorized')
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
record(options.matchedAskRule ? 'rule-approval' : 'permission-approval')
|
||||
const grant = !options.suppressAlwaysAllowRule ? sessionGrantProposal(options.suggestions) : undefined
|
||||
const optionsForApproval = [
|
||||
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
|
||||
@@ -623,12 +655,12 @@ export class ClaudeKernel {
|
||||
if (decision.kind !== 'approval' || decision.optionId !== 'allow-always') return
|
||||
if (!grant) throw new Error('会话授权不可用。')
|
||||
const apply = async (): Promise<void> => {
|
||||
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) throw abortError()
|
||||
if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) throw abortError()
|
||||
const instance = this.instance
|
||||
if (!instance) throw new Error('内核已断开,授权未应用。')
|
||||
const rules = [...new Set([...sessionGrantRules(this.options.sessionId, this.options.workspaceRoot), ...grant.rules])]
|
||||
await instance.applyFlagSettings({ permissions: { allow: rules } })
|
||||
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) {
|
||||
if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) {
|
||||
instance.close()
|
||||
throw abortError()
|
||||
}
|
||||
@@ -639,7 +671,7 @@ export class ClaudeKernel {
|
||||
await applying
|
||||
}
|
||||
)
|
||||
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
|
||||
if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
|
||||
if (response.kind !== 'approval' || response.optionId === 'reject-once') {
|
||||
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
|
||||
return { behavior: 'deny', message: '用户拒绝了此操作。' }
|
||||
@@ -654,6 +686,7 @@ export class ClaudeKernel {
|
||||
: {})
|
||||
}
|
||||
} catch (error) {
|
||||
record('error-deny')
|
||||
return { behavior: 'deny', message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -912,6 +945,8 @@ export class ClaudeKernel {
|
||||
try {
|
||||
this.capabilities.setRequestSelection({ explicitSkillIds: request.skillIds, selectedMcpServerIds: request.mcpServerIds })
|
||||
await this.ensureStarted()
|
||||
await this.permissionUpdates
|
||||
if (this.policyError) throw this.policyError
|
||||
const input = this.input
|
||||
if (!input) throw new Error('Claude Code 输入流未就绪')
|
||||
if (request.signal.aborted) throw abortError()
|
||||
@@ -1019,10 +1054,67 @@ export class ClaudeKernel {
|
||||
}
|
||||
|
||||
async setPolicy(permissionMode: PermissionMode, agentMode: AgentMode): Promise<void> {
|
||||
await this.draining
|
||||
this.permissionMode = permissionMode
|
||||
this.agentMode = agentMode
|
||||
if (this.instance) await this.instance.setPermissionMode(permissionModeFor(permissionMode, agentMode))
|
||||
// Invalidate outstanding approvals immediately, including while queued behind
|
||||
// an earlier SDK update. No tool may run against an intermediate policy.
|
||||
this.policyTransitions++
|
||||
this.policyRevision++
|
||||
const apply = async (): Promise<void> => {
|
||||
await this.draining
|
||||
if (this.closed) throw new Error('Claude 内核已释放')
|
||||
const instance = this.instance
|
||||
const sdkMode = permissionModeFor(permissionMode, agentMode)
|
||||
try {
|
||||
if (instance) await instance.setPermissionMode(sdkMode)
|
||||
this.policyError = null
|
||||
this.permissionMode = permissionMode
|
||||
this.agentMode = agentMode
|
||||
this.sdkPermissionMode = instance ? sdkMode : null
|
||||
} catch (error) {
|
||||
// A rejected control request leaves the CLI state uncertain. Never keep
|
||||
// using that producer or silently grant the requested host policy.
|
||||
this.policyError = new Error('权限切换失败,请重试。')
|
||||
this.failActive(this.policyError)
|
||||
this.input?.close()
|
||||
this.processAbort?.abort()
|
||||
instance?.close()
|
||||
if (this.instance === instance) {
|
||||
this.instance = null
|
||||
this.input = null
|
||||
this.processAbort = null
|
||||
this.sdkPermissionMode = null
|
||||
}
|
||||
const draining = (this.consuming ?? Promise.resolve()).catch(() => undefined)
|
||||
this.draining = draining
|
||||
void draining.finally(() => { if (this.draining === draining) this.draining = null })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const applying = this.permissionUpdates.then(apply).finally(() => { this.policyTransitions-- })
|
||||
this.permissionUpdates = applying.catch(() => undefined)
|
||||
await applying
|
||||
}
|
||||
|
||||
private recordPermissionDecision(toolUseId: unknown, source: 'hook' | 'callback', branch: PermissionBranch,
|
||||
options?: Parameters<CanUseTool>[2]): void {
|
||||
const metadata = options as unknown as Record<string, unknown> | undefined
|
||||
// SDK 0.3.304 forwards these fields at runtime but omits them from CanUseTool.
|
||||
// Never log arbitrary strings: unknown categories/codes remain explicitly
|
||||
// unknown, and IDs are one-way correlations rather than raw tool/request data.
|
||||
const category = metadata?.decisionReasonType
|
||||
console.info('[claude-permission]', JSON.stringify({
|
||||
permissionMode: this.permissionMode,
|
||||
agentMode: this.agentMode,
|
||||
sdkMode: this.sdkPermissionMode ?? 'unknown',
|
||||
sdkModeSource: 'host-query-or-acknowledged-control',
|
||||
policyTransition: this.policyTransitions > 0,
|
||||
source,
|
||||
toolCall: typeof toolUseId === 'string' ? createHash('sha256').update(toolUseId).digest('hex').slice(0, 16) : 'unknown',
|
||||
decisionCategory: typeof category === 'string' && ['subcommandResults', 'safetyCheck', 'rule', 'mode', 'other'].includes(category) ? category : 'unknown',
|
||||
decisionCode: 'unknown',
|
||||
decisionCodePresent: typeof metadata?.decisionReasonCode === 'string' && metadata.decisionReasonCode.length > 0,
|
||||
matchedAskRule: options ? !!options.matchedAskRule : 'unknown',
|
||||
branch
|
||||
}))
|
||||
}
|
||||
|
||||
/** Read only: never start a kernel or request per-category token-count calls. */
|
||||
@@ -1086,22 +1178,8 @@ export class ClaudeKernel {
|
||||
}
|
||||
}
|
||||
|
||||
function mustAskInFullAccess(options: Parameters<CanUseTool>[2]): boolean {
|
||||
const raw = options as unknown as Record<string, unknown>
|
||||
return raw.classifierApprovable === false ||
|
||||
raw.classifier_approvable === false ||
|
||||
raw.decisionReasonType === 'subcommandResults' ||
|
||||
raw.decision_reason_type === 'subcommandResults' ||
|
||||
(raw.decisionReasonType === 'safetyCheck' && raw.classifierApprovable !== true) ||
|
||||
(raw.decision_reason_type === 'safetyCheck' && raw.classifier_approvable !== true) ||
|
||||
raw.matchedAskRule != null ||
|
||||
raw.matchesAskRule != null ||
|
||||
raw.matched_ask_rule != null ||
|
||||
raw.requiresUserInteraction === true ||
|
||||
raw.requires_user_interaction === true ||
|
||||
(raw.decisionReasonCode != null && raw.decisionReasonCode !== '') ||
|
||||
(raw.decision_reason_code != null && raw.decision_reason_code !== '')
|
||||
}
|
||||
type PermissionBranch = 'mode-or-inactive-deny' | 'business-question' | 'plan-content' |
|
||||
'full-access-preauthorized' | 'sdk-policy' | 'rule-approval' | 'permission-approval' | 'error-deny'
|
||||
|
||||
function isModeAllowedCall(mode: AgentMode, toolName: string, input: Record<string, unknown>): boolean {
|
||||
if (mode === 'ask' && ['TodoWrite', 'EnterPlanMode'].includes(toolName)) return false
|
||||
|
||||
@@ -412,8 +412,13 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : []
|
||||
const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) })
|
||||
|
||||
const activeInteraction = messages.flatMap((message) => message.interactions ?? []).findLast((item) =>
|
||||
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status))
|
||||
const activeInteraction = !hidden && !loading && !loadError && currentId ? messages.flatMap((message) => message.interactions ?? []).findLast((item) =>
|
||||
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status)) : undefined
|
||||
const respondToActive: typeof respond = (id, response) => {
|
||||
const state = useChatStore.getState()
|
||||
if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换'))
|
||||
return respond(id, response)
|
||||
}
|
||||
const composerError = error ?? queueState.error ?? capabilityCatalog.error
|
||||
const composition = composeComposer({
|
||||
interaction: activeInteraction ? { id: activeInteraction.id, kind: activeInteraction.kind, status: activeInteraction.status } : null,
|
||||
@@ -612,20 +617,20 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
}
|
||||
|
||||
const floatingSurface = activeInteraction?.kind === 'question'
|
||||
? <QuestionFloating key={activeInteraction.id} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
|
||||
? <QuestionFloating key={`${currentId}:${activeInteraction.id}`} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
|
||||
responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
|
||||
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
|
||||
onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })}
|
||||
onSkip={() => respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true })} />
|
||||
onSelect={(_text, answer) => respondToActive(activeInteraction.id, { kind: 'question', answer })}
|
||||
onSkip={() => respondToActive(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true })} />
|
||||
: activeInteraction?.kind === 'approval'
|
||||
? <PermissionConfirm key={activeInteraction.id} className="permission-floating"
|
||||
? <PermissionConfirm key={`${currentId}:${activeInteraction.id}`} className="permission-floating"
|
||||
pendingId={activeInteraction.id} details={activeInteraction.details} toolName={activeInteraction.details?.toolName ?? messages.flatMap(message => message.tools ?? []).find(tool => tool.id === activeInteraction.toolCallId)?.name ?? '工具操作'} title={activeInteraction.title}
|
||||
options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error}
|
||||
labels={{ confirm: '允许一次', alwaysAllow: '本次会话内始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }}
|
||||
resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })}
|
||||
resolve={(id, decision) => respondToActive(id, { kind: 'approval', optionId: decision.optionId })}
|
||||
reject={(id) => {
|
||||
const option = activeInteraction.options.find((item) => item.kind.startsWith('reject'))
|
||||
return option ? respond(id, { kind: 'approval', optionId: option.optionId }) : Promise.reject(new Error('审批选项不可用'))
|
||||
return option ? respondToActive(id, { kind: 'approval', optionId: option.optionId }) : Promise.reject(new Error('审批选项不可用'))
|
||||
}} />
|
||||
: composition.notice?.kind === 'error' && composerError ? <div className="wb-composer__error" role="alert">{composerError}</div> : undefined
|
||||
const queuePanel = composition.stack.queue && queueState.snapshot
|
||||
@@ -673,7 +678,9 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
}}
|
||||
userContent={entry.userContent}
|
||||
onUserContentChange={userContent => setDrafts(all => ({ ...all, [draftKey]: { ...(all[draftKey] ?? EMPTY_DRAFT), userContent } }))}
|
||||
floatingSlot={floatingSurface}
|
||||
approvalKey={activeInteraction ? `${currentId}:${activeInteraction.id}` : undefined}
|
||||
approvalSlot={!composition.input.visible ? floatingSurface : undefined}
|
||||
floatingSlot={composition.input.visible ? floatingSurface : undefined}
|
||||
queueSlot={queuePanel}
|
||||
onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? EMPTY_DRAFT), resources } }))}
|
||||
quotes={entry.quotes ?? []}
|
||||
@@ -705,7 +712,11 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ }
|
||||
}}
|
||||
onError={(message) => useChatStore.setState({ error: message })} />}
|
||||
onSend={submit} sending={sending} onStop={running ? cancel : undefined}
|
||||
onSend={submit} sending={sending} onStop={running ? () => {
|
||||
const state = useChatStore.getState()
|
||||
if (state.currentSessionId !== currentId || state.running?.runId !== running.runId || hidden) return Promise.reject(new Error('运行所属会话已切换'))
|
||||
return cancel()
|
||||
} : undefined}
|
||||
modelSlot={<span title={running || sending ? '下次发送生效' : undefined}><SourceModelSelector models={models} selectedId={effectiveModelId} label={modelLabel} darkMode={darkMode}
|
||||
thinkingLevel={effectiveThinkingLevel}
|
||||
onThinkingLevelChange={(level) => useChatStore.getState().selectThinkingLevel(level)}
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface ComposerControlsProps {
|
||||
permissionSlot?: ReactNode
|
||||
modeSlot?: ReactNode
|
||||
expertSlot?: ReactNode
|
||||
/** An active tool approval replaces, rather than floats above, normal editing. */
|
||||
approvalSlot?: ReactNode
|
||||
approvalKey?: string
|
||||
floatingSlot?: ReactNode
|
||||
queueSlot?: ReactNode
|
||||
capabilityItems?: readonly CapabilityPickerItem[]
|
||||
@@ -143,6 +146,27 @@ export function readReferenceUris(transfer: Pick<DataTransfer, 'getData'>): stri
|
||||
return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#'))
|
||||
}
|
||||
|
||||
/** Keyed by request identity so a late cancellation result cannot affect its successor. */
|
||||
function ApprovalComposerSurface({ children, onStop }: { children: ReactNode; onStop?: () => void | Promise<void> }): React.JSX.Element {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
return <div className="conversation-input-area__approval" data-composer-approval="true">
|
||||
{children}
|
||||
{onStop && <button type="button" className="wb-composer__approval-stop" aria-label="停止当前运行" disabled={busy} onClick={async () => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try { await onStop() } catch (reason) { if (mounted.current) setError(errorText(reason)) }
|
||||
finally { if (mounted.current) { busyRef.current = false; setBusy(false) } }
|
||||
}}>停止当前运行</button>}
|
||||
{error && <div className="wb-composer__error" role="alert">{error}</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ComposerControls({
|
||||
value,
|
||||
onChange,
|
||||
@@ -172,6 +196,8 @@ export function ComposerControls({
|
||||
modeSlot,
|
||||
expertSlot,
|
||||
floatingSlot,
|
||||
approvalSlot,
|
||||
approvalKey,
|
||||
queueSlot,
|
||||
capabilityItems = [],
|
||||
selectedCapabilities = [],
|
||||
@@ -186,7 +212,7 @@ export function ComposerControls({
|
||||
onOpenAddMenu,
|
||||
workspaceFooter,
|
||||
permissionPlacement = 'toolbar',
|
||||
disabled = false,
|
||||
disabled: disabledProp = false,
|
||||
sendDisabled = false,
|
||||
sending = false,
|
||||
onStop,
|
||||
@@ -194,6 +220,19 @@ export function ComposerControls({
|
||||
inputAriaLabel,
|
||||
className
|
||||
}: ComposerControlsProps): React.JSX.Element {
|
||||
const approvalTakeover = approvalSlot != null
|
||||
const disabled = disabledProp || approvalTakeover
|
||||
const takeoverRef = useRef(approvalTakeover)
|
||||
takeoverRef.current = approvalTakeover
|
||||
const inputStackRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!approvalTakeover) return
|
||||
restoreCapabilityFocus.current = false
|
||||
setSlash(null)
|
||||
setDragging(false)
|
||||
const focused = document.activeElement
|
||||
if (focused instanceof HTMLElement && inputStackRef.current?.contains(focused)) focused.blur()
|
||||
}, [approvalTakeover])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const queueRef = useRef<HTMLDivElement>(null)
|
||||
const [queueHeight, setQueueHeight] = useState(0)
|
||||
@@ -216,23 +255,25 @@ export function ComposerControls({
|
||||
const dismissedSlashValue = useRef<string | null>(null)
|
||||
const restoreCapabilityFocus = useRef(false)
|
||||
useEffect(() => {
|
||||
if (slash || !restoreCapabilityFocus.current) return
|
||||
if (approvalTakeover || slash || !restoreCapabilityFocus.current) return
|
||||
// The picker input must be removed before restoring Slate's native selection.
|
||||
const timer = window.setTimeout(() => {
|
||||
if (takeoverRef.current) return
|
||||
restoreCapabilityFocus.current = false
|
||||
editorRef.current?.focus()
|
||||
editorRef.current?.setSelectionRange(cursorRef.current, cursorRef.current)
|
||||
richEditorRef.current?.focus()
|
||||
}, 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [slash, value, skills.length])
|
||||
}, [slash, value, skills.length, approvalTakeover])
|
||||
useEffect(() => {
|
||||
if (!canSelectCapabilities || composingRef.current) return
|
||||
if (approvalTakeover || !canSelectCapabilities || composingRef.current) return
|
||||
if (dismissedSlashValue.current === value) return
|
||||
dismissedSlashValue.current = null
|
||||
setSlash(detectCapabilitySlashTrigger(value, cursorRef.current, capabilityItems))
|
||||
}, [value, capabilityItems, canSelectCapabilities, compositionVersion])
|
||||
}, [value, capabilityItems, canSelectCapabilities, compositionVersion, approvalTakeover])
|
||||
const selectCapability = (item: CapabilityPickerItem): void => {
|
||||
if (takeoverRef.current) return
|
||||
if (slash) {
|
||||
const text = value.slice(0, slash.start) + value.slice(slash.end)
|
||||
const content = richEditorRef.current?.deleteTextRange(slash.start, slash.end) ?? [{ type: 'text' as const, text }]
|
||||
@@ -245,7 +286,7 @@ export function ComposerControls({
|
||||
onSelectCapability?.(item, { source: 'slash', text, userContent: content })
|
||||
editorRef.current?.focus()
|
||||
richEditorRef.current?.focus()
|
||||
requestAnimationFrame(() => { editorRef.current?.focus(); editorRef.current?.setSelectionRange(slash.start, slash.start); richEditorRef.current?.focus() })
|
||||
requestAnimationFrame(() => { if (takeoverRef.current) return; editorRef.current?.focus(); editorRef.current?.setSelectionRange(slash.start, slash.start); richEditorRef.current?.focus() })
|
||||
} else onSelectCapability?.(item, { source: 'menu' })
|
||||
}
|
||||
const mounted = useRef(true)
|
||||
@@ -350,7 +391,7 @@ export function ComposerControls({
|
||||
}
|
||||
|
||||
const pickAttachments = async (): Promise<void> => {
|
||||
if (pickInFlightRef.current) return
|
||||
if (takeoverRef.current || pickInFlightRef.current) return
|
||||
if (!onAttachmentsChange) {
|
||||
setError('附件列表为只读')
|
||||
return
|
||||
@@ -427,7 +468,7 @@ export function ComposerControls({
|
||||
const submit = async (): Promise<void> => {
|
||||
const editorContent = richEditorRef.current?.content() ?? userContent
|
||||
const plainText = draftPlainText({ text: value, userContent: editorContent })
|
||||
if (sendInFlightRef.current || disabled || sendDisabled || sending || busyCountRef.current > 0 || pendingAttachments.length ||
|
||||
if (takeoverRef.current || sendInFlightRef.current || disabled || sendDisabled || sending || busyCountRef.current > 0 || pendingAttachments.length ||
|
||||
(!plainText.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0 &&
|
||||
!skills.length && !selectedCapabilities.length && !hasDraftUserContent({ userContent: editorContent }))) return
|
||||
const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES)
|
||||
@@ -457,6 +498,7 @@ export function ComposerControls({
|
||||
}
|
||||
|
||||
const receiveResources = async (transfer: DataTransfer): Promise<void> => {
|
||||
if (takeoverRef.current) return
|
||||
if (transfer.files.length) await stageFiles(transfer.files)
|
||||
for (const candidate of readReferenceUris(transfer)) {
|
||||
const path = parseResourcePath(candidate)
|
||||
@@ -473,7 +515,7 @@ export function ComposerControls({
|
||||
} : undefined })
|
||||
const previouslyRich = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!referenceViews.length && previouslyRich.current && editorRef.current) {
|
||||
if (!takeoverRef.current && !referenceViews.length && previouslyRich.current && editorRef.current) {
|
||||
editorRef.current.focus()
|
||||
const offset = Math.min(value.length, cursorRef.current)
|
||||
editorRef.current.setSelectionRange(offset, offset)
|
||||
@@ -493,7 +535,7 @@ export function ComposerControls({
|
||||
<div
|
||||
className={['wb-composer', 'conversation-input-area', className].filter(Boolean).join(' ')}
|
||||
data-dragging={dragging || undefined}
|
||||
onDragEnter={(event) => { event.preventDefault(); setDragging(true) }}
|
||||
onDragEnter={(event) => { event.preventDefault(); if (!approvalTakeover) setDragging(true) }}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false)
|
||||
@@ -514,8 +556,13 @@ export function ComposerControls({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{floatingSlot && <div className="conversation-input-area__custom-floating">{floatingSlot}</div>}
|
||||
<div className={'wb-composer__input-stack conversation-input-area__input-stack' + (permissionPlacement === 'footer' ? ' wb-home-route__input' : '')}>
|
||||
{approvalTakeover && <ApprovalComposerSurface key={approvalKey ?? sessionId} onStop={onStop}>{approvalSlot}</ApprovalComposerSurface>}
|
||||
{!approvalTakeover && floatingSlot && <div className="conversation-input-area__custom-floating">{floatingSlot}</div>}
|
||||
<div ref={inputStackRef} hidden={approvalTakeover} inert={approvalTakeover} aria-hidden={approvalTakeover || undefined}
|
||||
onFocusCapture={event => { if (approvalTakeover) { event.stopPropagation(); (event.target as HTMLElement).blur() } }}
|
||||
onKeyDownCapture={event => { if (approvalTakeover) { event.preventDefault(); event.stopPropagation() } }}
|
||||
onClickCapture={event => { if (approvalTakeover) { event.preventDefault(); event.stopPropagation() } }}
|
||||
className={'wb-composer__input-stack conversation-input-area__input-stack' + (permissionPlacement === 'footer' ? ' wb-home-route__input' : '')}>
|
||||
{!floatingSlot && queueSlot && <div style={{ height: Math.max(0, queueHeight - 36), position: 'relative' }}>
|
||||
<div ref={queueRef} className="conversation-input-area__prompt-queue-overlay" style={{ bottom: 0 }}>{queueSlot}</div>
|
||||
</div>}
|
||||
@@ -565,7 +612,7 @@ export function ComposerControls({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="wb-composer__toolbar cr-input-toolbar">
|
||||
{!approvalTakeover && <div className="wb-composer__toolbar cr-input-toolbar">
|
||||
<div className="wb-composer__group cr-input-toolbar__left">
|
||||
<SourceComposerAddMenu scopeKey={addMenuScopeKey ?? sessionId ?? 'pending'} disabled={disabled || busy} onOpen={onOpenAddMenu}
|
||||
entries={[...(addMenuEntries ?? [{ id: 'add-files', children: [{ id: 'local-files', label: '本地文件' }] }, ...(onOpenExperts ? [{ id: 'experts' }] : [])]),
|
||||
@@ -604,7 +651,7 @@ export function ComposerControls({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
{homeFooter}
|
||||
@@ -612,7 +659,7 @@ export function ComposerControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="wb-composer__error" role="alert">{error}</div>}
|
||||
{!approvalTakeover && error && <div className="wb-composer__error" role="alert">{error}</div>}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -546,3 +546,8 @@
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* B03: approval owns the bottom surface; keep draft state mounted, never visible/focusable. */
|
||||
.wb-composer .conversation-input-area__input-stack[hidden] { display: none; }
|
||||
.conversation-input-area__approval { width: 100%; min-width: 0; }
|
||||
.wb-composer__approval-stop { display: block; margin: var(--yc-space-3) 0 0 auto; cursor: pointer; }
|
||||
@@ -66,6 +66,8 @@ export interface ComposerQueueComposition {
|
||||
export interface ComposerInputComposition {
|
||||
/** The editor stays mounted so draft, attachments and capability selections survive takeover. */
|
||||
mounted: true
|
||||
/** Only real tool approval replaces the normal input surface. */
|
||||
visible: boolean
|
||||
disabled: boolean
|
||||
blockedByInteraction: boolean
|
||||
destination: 'send' | 'queue' | 'blocked'
|
||||
@@ -190,6 +192,7 @@ export function composeComposer(input: ComposerCompositionInput = {}): ComposerC
|
||||
queue,
|
||||
input: {
|
||||
mounted: true,
|
||||
visible: interaction?.kind !== 'approval',
|
||||
disabled,
|
||||
blockedByInteraction: interactionTakeover,
|
||||
destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',
|
||||
|
||||
Reference in new issue
Block a user