fix(B03): preauthorize full access and replace composer with active approval

This commit is contained in:
Codex committed 2026-10-21 14:44:11 +09:00
1 parent 0bc9faa129
commit adbbb3bd37
15 files changed
+627 -63

No files matched your search

+111 -33
View File
@@ -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
+21 -10
View File
@@ -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',