fix: close alignment review races and preserve local conversation state

This commit is contained in:
Codex committed 2026-10-21 04:55:11 +09:00
1 parent c7231be2c5
commit 37ddb2ce24
14 files changed
+203 -45

No files matched your search

+4 -3
View File
@@ -573,15 +573,17 @@ export class ClaudeKernel {
return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' }
}
if (toolName === 'AskUserQuestion') {
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } })
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 } })
const response = await active.request.interactions.question(
options.toolUseID,
options.title ?? 'Claude 想确认一些信息',
questions,
options.signal
)
if (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: '用户关闭了问题,未提供回答。' }
const questionText = new Map(questions.map((question) => [question.id, question.question]))
@@ -589,8 +591,6 @@ export class ClaudeKernel {
for (const answer of response.answer.questions) {
answers[questionText.get(answer.id) ?? answer.id] = answer.answers.join(', ')
}
if (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' } })
return { behavior: 'allow', updatedInput: { ...input, answers } }
}
if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) {
@@ -641,6 +641,7 @@ export class ClaudeKernel {
)
if (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: '用户拒绝了此操作。' }
}
if (response.optionId === 'allow-always' && !grant) return { behavior: 'deny', message: '会话授权不可用。' }
@@ -77,8 +77,8 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
}
const streaming = message.status === 'streaming'
const parts = getMessageParts(message)
const questionProjection = projectQuestionAnswers(message)
const parts = getMessageParts(message).filter(part => part.type !== 'tool' || !questionProjection.unpositionedToolIds.has(part.toolId))
const tools = questionProjection.tools.map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool)
: tool.metadata?.capability === 'mcp' ? { ...tool, name: 'mcp_call_tool', args: { serverName: tool.metadata.serverName, toolName: tool.metadata.toolName, arguments: tool.args } } : tool)
const activeApprovals = new Map(tools.flatMap(tool => {
+18 -6
View File
@@ -180,8 +180,7 @@ function sortSessions(sessions: SessionMeta[]): SessionMeta[] {
}
export const useChatStore = create<ChatState>((set, get) => {
const saveRequestSettings = async (patch: Pick<SessionContext, 'modelId' | 'thinkingLevel' | 'permissionMode'>): Promise<void> => {
const owner = get().currentSessionId
const saveRequestSettings = async (patch: Pick<SessionContext, 'modelId' | 'thinkingLevel' | 'permissionMode'>, owner = get().currentSessionId): Promise<void> => {
if (!owner) { set(state => ({ draftContext: { ...state.draftContext, ...patch } })); return }
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(owner, patch))
trackRequestSettings(owner, write, Object.keys(patch) as (keyof SessionContext)[])
@@ -190,6 +189,19 @@ export const useChatStore = create<ChatState>((set, get) => {
...contextUsageState(state.sessionUsage, state.sessionContextWindow, owner,
lastContextUsage(state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [])) }))
}
const settleRequestModel = async (owner: string): Promise<void> => {
// A pending explicit choice must win over automatic invalid-model repair.
// Keep the original owner even if the user switches sessions while waiting.
await awaitRequestSettings(owner)
const settings = useSettingsStore.getState()
const context = get().sessions.find(item => item.id === owner)
const model = resolveSelectedModel(settings.models, context?.modelId, settings.selectedModelId)
if (!model) throw new Error('请先配置自定义模型')
if (context?.modelId !== model.id) {
await saveRequestSettings({ modelId: model.id }, owner)
await awaitRequestSettings(owner)
}
}
const streamCommitter = new ChatStreamCommitter((key, events) => {
const first = events[0]
if (!first) return
@@ -504,7 +516,7 @@ export const useChatStore = create<ChatState>((set, get) => {
const context = get().currentSessionId ? get().sessions.find(item => item.id === get().currentSessionId) : get().draftContext
const model = resolveSelectedModel(settings.models, context?.modelId, settings.selectedModelId)
if (!model) throw new Error('请先配置自定义模型')
const modelWrite = context?.modelId !== model.id ? get().selectModel(model.id) : null
const modelWrite = !get().currentSessionId && context?.modelId !== model.id ? get().selectModel(model.id) : null
// Observe rejection immediately; the guarded submission below surfaces it.
void modelWrite?.catch(() => {})
// Read the saved choice at submission time; a React effect is not authoritative.
@@ -537,7 +549,7 @@ export const useChatStore = create<ChatState>((set, get) => {
if (sessionId && currentRun(get().runs, sessionId)) {
if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文')
if (modelWrite) await modelWrite
await awaitRequestSettings(sessionId)
await settleRequestModel(sessionId)
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
return
}
@@ -546,7 +558,7 @@ export const useChatStore = create<ChatState>((set, get) => {
if (queue && (queue.items.length || queue.editingDraft && (options.command || queue.editingDraft.id !== options.queueEditId))) {
if (options.action || options.command) throw new Error('请先处理待发送队列')
if (modelWrite) await modelWrite
await awaitRequestSettings(sessionId)
await settleRequestModel(sessionId)
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
return
}
@@ -556,7 +568,7 @@ export const useChatStore = create<ChatState>((set, get) => {
...(state.currentSessionId === owner ? { sending: true, error: null } : {}) }))
try {
if (modelWrite) await modelWrite
await awaitRequestSettings(owner)
await settleRequestModel(owner)
const result = await window.ycode.chat.send({ ...options, permissionMode: get().sessions.find(item => item.id === owner)?.permissionMode ?? savedPermission, sessionId: owner, text: trimmed })
const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant',
content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName,
+3 -1
View File
@@ -79,7 +79,9 @@ function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, class
// Keep this one-shot listener alive through the closing/unmounting of the layer.
const outsideTarget = event.target
const swallow = (click: MouseEvent): void => {
if (click.target === outsideTarget) { click.preventDefault(); click.stopImmediatePropagation() }
if (click.target instanceof Node && (click.target === outsideTarget || click.target.contains(outsideTarget))) {
click.preventDefault(); click.stopImmediatePropagation()
}
cleanup()
}
const cleanup = (): void => { document.removeEventListener('click', swallow, true); clearTimeout(timer) }
@@ -185,11 +185,22 @@ export function CompletedFold({
if (completedExpanded && model.segments.some(segment => segment.kind === 'completed')) {
return <CollapseSection id={`completed-${foldKey}`} variant="completed" title={model.title}
controlledExpanded onExpandedChange={setCompletedExpanded}>
{parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
return <Fragment key={`${part.type}:${index}`}>{render(part, index, { kind: 'part', classification })}</Fragment>
})}
{(() => {
// Keep local summary controls independent of the outer completion control.
// Suppressing only the outer fold preserves chronological presentation and
// classification context (including linked widgets) without flattening groups.
const expandedModel = buildCompletedFoldModel(parts, {
turnFinished, turnDurationMs, turnCancelled, suppressCompletedFold: true,
labels, classifyToolCall
})
return expandedModel ? expandedModel.segments.map(segment =>
segment.kind === 'unit' ? renderPresentationUnit(segment.unit, render, renderGroup) : null
) : parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
return <Fragment key={`${part.type}:${index}`}>{render(part, index, { kind: 'part', classification })}</Fragment>
})
})()}
</CollapseSection>
}
@@ -186,8 +186,18 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
rootRef.current?.querySelector<HTMLElement>('[role="button"][tabindex="0"], input:not(:disabled)')?.focus()
}, [index, questionKey])
const cancelAdvance = () => {
if (advanceTimer.current) clearTimeout(advanceTimer.current)
advanceTimer.current = undefined
}
const navigate = (delta: number) => {
cancelAdvance()
setIndex(value => Math.min(Math.max(value + delta, 0), questions.length - 1))
}
const commit = (action: () => void | Promise<void>) => {
if (submitting.current || responding) return
cancelAdvance()
submitting.current = true
setSubmitted(true)
setLocalError(null)
@@ -251,8 +261,15 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
if (isLast) {
submit(nextSelected, nextCustom)
} else {
if (advanceTimer.current) clearTimeout(advanceTimer.current)
advanceTimer.current = setTimeout(() => setIndex(value => Math.min(value + 1, questions.length - 1)), 120)
cancelAdvance()
const owner = generation.current
const page = currentIndex
advanceTimer.current = setTimeout(() => {
advanceTimer.current = undefined
if (owner === generation.current && !submitting.current) {
setIndex(value => value === page ? Math.min(page + 1, questions.length - 1) : value)
}
}, 120)
}
}
@@ -260,12 +277,12 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
if (responding || submitted) return
if (current.multiSelect) {
if (isLast) submit()
else if (multiSelectedCount > 0) setIndex(value => Math.min(value + 1, questions.length - 1))
else if (multiSelectedCount > 0) navigate(1)
return
}
if (!singleHasAnswer) return
if (isLast) submit()
else setIndex(value => Math.min(value + 1, questions.length - 1))
else navigate(1)
}
const skipCurrent = () => {
@@ -286,7 +303,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
}
commit(() => onSelect?.(answerText(payload, labels), payload))
} else {
setIndex(value => Math.min(value + 1, questions.length - 1))
navigate(1)
}
}
@@ -331,7 +348,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
className="question-floating__page-button"
aria-label={labels.previousQuestion}
disabled={currentIndex === 0 || responding || submitted}
onClick={() => setIndex(value => Math.max(value - 1, 0))}
onClick={() => navigate(-1)}
>
<QuestionChevronLeftIcon />
</button>
@@ -341,7 +358,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
className="question-floating__page-button"
aria-label={labels.nextQuestion}
disabled={isLast || responding || submitted}
onClick={() => setIndex(value => Math.min(value + 1, questions.length - 1))}
onClick={() => navigate(1)}
>
<QuestionChevronRightIcon />
</button>
@@ -1,15 +1,16 @@
import type { ChatMessage, ToolCallRecord } from '@shared/types'
import type { QuestionAnswer } from '@shared/conversation'
import { getMessageParts } from '@shared/message-parts'
/** Use the recorded tool position; never guess a timestamp/order for old unlinked answers. */
export function projectQuestionAnswers(message: ChatMessage): {
tools: ToolCallRecord[]
unpositionedToolIds: Set<string>
positionedAnswers: Map<string, QuestionAnswer>
unpositioned: Array<{ id: string; answer: QuestionAnswer }>
} {
const tools = (message.tools ?? []).map(tool => ({ ...tool }))
const positions = new Set(getMessageParts(message).flatMap(part => part.type === 'tool' ? [part.toolId] : []))
const positions = new Set((message.parts ?? []).flatMap(part => part.type === 'tool' ? [part.toolId] : []))
const unpositionedToolIds = new Set<string>()
const positionedAnswers = new Map<string, QuestionAnswer>()
const unpositioned: Array<{ id: string; answer: QuestionAnswer }> = []
const seen = new Set<string>()
@@ -24,7 +25,18 @@ export function projectQuestionAnswers(message: ChatMessage): {
if (tool) tool.metadata = { ...tool.metadata, questionAnswer: interaction.answer, questionSkipped: interaction.skipped ?? false }
} else {
unpositioned.unshift({ id: interaction.id, answer: interaction.answer })
if (tool) unpositionedToolIds.add(tool.id)
}
}
return { tools, positionedAnswers, unpositioned }
// Metadata-only legacy answers also have no chronological position without parts.
for (const tool of tools) {
if (positions.has(tool.id) || seen.has(tool.id)) continue
const answer = tool.metadata?.questionAnswer as QuestionAnswer | undefined
if (!answer || !Array.isArray(answer.questions) || !answer.questions.every(question =>
question != null && typeof question === 'object' && typeof question.id === 'string' && typeof question.question === 'string' &&
Array.isArray(question.answers) && question.answers.every(value => typeof value === 'string'))) continue
unpositioned.push({ id: `tool:${tool.id}`, answer })
unpositionedToolIds.add(tool.id)
}
return { tools, positionedAnswers, unpositioned, unpositionedToolIds }
}