fix(pi): retain ownership for uncertain mutations

This commit is contained in:
2026-08-25 23:53:54 +08:00
parent 621ebb1781
commit 019cbb115a
21 changed files with 1113 additions and 67 deletions

View File

@@ -53,6 +53,25 @@ interface LocalComposerAttachment {
previewUrl: string;
}
interface LocalSubmissionError {
message: string;
backendCode?: string;
}
function localSubmissionError(error: unknown): LocalSubmissionError {
const message = error instanceof Error ? error.message : String(error);
const details = error && typeof error === 'object'
&& 'details' in error
&& error.details
&& typeof error.details === 'object'
? error.details as { backendCode?: unknown }
: null;
return {
message,
...(typeof details?.backendCode === 'string' ? { backendCode: details.backendCode } : {}),
};
}
function newestConversation(
conversations: CodingConversationMetadata[],
agentId: string,
@@ -124,7 +143,7 @@ export function CodingChatPanel({
const conversationSummaries = useCodingConversationStore((state) => state.summariesByConversationId);
const [provisionalDrafts, setProvisionalDrafts] = useState<Record<string, string>>({});
const [submissionErrors, setSubmissionErrors] = useState<Record<string, string>>({});
const [submissionErrors, setSubmissionErrors] = useState<Record<string, LocalSubmissionError>>({});
const [modesByDraftKey, setModesByDraftKey] = useState<Record<string, PromptMode>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
@@ -387,7 +406,7 @@ export function CodingChatPanel({
}
setSubmissionErrors((current) => {
const next = { ...current };
if (validationError) next[draftKey] = validationError;
if (validationError) next[draftKey] = { message: validationError };
else delete next[draftKey];
return next;
});
@@ -488,7 +507,7 @@ export function CodingChatPanel({
}).catch((error) => {
setSubmissionErrors((current) => ({
...current,
[draftKey]: error instanceof Error ? error.message : String(error),
[draftKey]: localSubmissionError(error),
}));
}).finally(() => {
submissionFlightsRef.current.delete(conversationId);
@@ -506,6 +525,17 @@ export function CodingChatPanel({
const workerStatus = snapshot?.worker.status ?? 'stopped';
const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null;
const preparationError = entryError ?? runtimeError?.message ?? null;
useEffect(() => {
if (!draftKey
|| uncertainRequestCount > 0
|| runtimeError?.code === 'CODING_REQUEST_UNCERTAIN') return;
setSubmissionErrors((current) => {
if (current[draftKey]?.backendCode !== 'CODING_REQUEST_UNCERTAIN') return current;
const next = { ...current };
delete next[draftKey];
return next;
});
}, [draftKey, runtimeError?.code, uncertainRequestCount]);
const preparing = entryLoadState === 'loading'
|| workerStatus === 'starting'
|| workerStatus === 'recovering'
@@ -744,7 +774,7 @@ export function CodingChatPanel({
if (!draftKey) return;
setSubmissionErrors((current) => ({
...current,
[draftKey]: error instanceof Error ? error.message : String(error),
[draftKey]: localSubmissionError(error),
}));
});
}}
@@ -772,7 +802,7 @@ export function CodingChatPanel({
runStatus={runStatus}
mode={promptMode}
queue={snapshot?.queue ?? { items: [] }}
error={submissionError ?? preparationError}
error={submissionError?.message ?? preparationError}
recoverableError={!submissionError && Boolean(preparationError)}
acceptedCount={acceptedRequestCount}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}

View File

@@ -170,7 +170,7 @@ export function CodingConversationHeader({
value={modelValue}
aria-label="当前对话模型"
className="h-9 min-w-52 max-w-72 text-xs"
disabled={Boolean(busyAction)}
disabled={Boolean(busyAction) || running}
onChange={(event) => {
const selected = parseCodingModelKey(event.target.value);
if (!selected) return;
@@ -191,7 +191,7 @@ export function CodingConversationHeader({
value={thinkingLevel}
aria-label="当前对话思考级别"
className="h-9 w-36 shrink-0 text-xs"
disabled={Boolean(busyAction) || !model}
disabled={Boolean(busyAction) || !model || running}
onChange={(event) => perform('thinking', async () => {
await setCodingConversationThinking(
conversation.id,
@@ -215,7 +215,7 @@ export function CodingConversationHeader({
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
</Button>
)}
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction)} onClick={() => perform('fork', onFork)}>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction) || running} onClick={() => perform('fork', onFork)}>
<GitFork className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction)} onClick={() => perform('unread', onToggleUnread)}>

View File

@@ -163,9 +163,13 @@ function withoutReconciledRequests(
snapshot: ConversationSnapshot,
): Record<string, CodingPromptRequestState> {
const reconciled = requestIdsInSnapshot(snapshot);
if (!requests || reconciled.size === 0) return requests ?? {};
const requestUncertaintySettled = snapshot.run.status === 'idle' || snapshot.run.status === 'error';
if (!requests || (reconciled.size === 0 && !requestUncertaintySettled)) return requests ?? {};
return Object.fromEntries(
Object.entries(requests).filter(([clientRequestId]) => !reconciled.has(clientRequestId)),
Object.entries(requests).filter(([clientRequestId, request]) => (
!reconciled.has(clientRequestId)
&& !(request.status === 'uncertain' && requestUncertaintySettled)
)),
);
}
@@ -962,21 +966,17 @@ export function createCodingConversationStore(
unread,
};
const requests = state.requestsByConversationId[event.conversationId] ?? {};
const reconciledRequestIds = new Set(incomingMessages.flatMap((message) => (
message.clientRequestId ? [message.clientRequestId] : []
)));
const nextRequests = reconciledRequestIds.size > 0
? Object.fromEntries(
Object.entries(requests).filter(([id]) => !reconciledRequestIds.has(id)),
)
const nextRequests = reducer.snapshot
? withoutReconciledRequests(requests, reducer.snapshot)
: requests;
const requestsChanged = Object.keys(nextRequests).length !== Object.keys(requests).length;
return {
entriesByConversationId: {
...state.entriesByConversationId,
[event.conversationId]: entry,
},
summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry),
requestsByConversationId: nextRequests === requests
requestsByConversationId: !requestsChanged
? state.requestsByConversationId
: {
...state.requestsByConversationId,