fix: close alignment review races and preserve local conversation state
This commit is contained in:
1 parent
c7231be2c5
commit
37ddb2ce24
14 files changed
+203
-45
No files matched your search
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user