merge: integrate foreground conversation reconciliation

# Conflicts:
#	tests/e2e/pi-coding-first-chat.spec.ts
This commit is contained in:
inman
2026-09-03 10:47:56 +08:00
6 changed files with 439 additions and 22 deletions

View File

@@ -17,6 +17,7 @@ import {
abortCodingConversation,
forkCodingConversation,
} from '@/lib/coding-conversations';
import { subscribeHostEvent } from '@/lib/host-events';
import {
codingConversationStore,
selectCodingConversationDraft,
@@ -148,6 +149,7 @@ export function CodingChatPanel({
>({});
const appliedNavigationDraftRef = useRef<string | null>(null);
const automaticCreationKeyRef = useRef<string | null>(null);
const selectedConversationContextRef = useRef<string | null>(null);
const attachmentsRef = useRef(attachmentsByDraftKey);
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
@@ -183,6 +185,11 @@ export function CodingChatPanel({
attachmentsRef.current = attachmentsByDraftKey;
const selectProjectConversation = useCallback((projectId: string, conversationId: string) => {
selectedConversationContextRef.current = `${projectId}:${conversationId}`;
return selectConversation(conversationId);
}, [selectConversation]);
const selectDraft = useCallback((state: CodingConversationStoreState) => (
targetConversationId
? selectCodingConversationDraft(targetConversationId)(state)
@@ -236,6 +243,24 @@ export function CodingChatPanel({
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
useEffect(() => subscribeHostEvent('lifecycle:sleep', () => {
disconnectEvents();
}), [disconnectEvents]);
useEffect(() => {
if (!targetConversationId) return undefined;
const refreshVisibleConversation = () => {
if (document.visibilityState !== 'visible') return;
void selectConversation(targetConversationId).catch(() => undefined);
};
window.addEventListener('focus', refreshVisibleConversation);
document.addEventListener('visibilitychange', refreshVisibleConversation);
return () => {
window.removeEventListener('focus', refreshVisibleConversation);
document.removeEventListener('visibilitychange', refreshVisibleConversation);
};
}, [selectConversation, targetConversationId]);
useEffect(() => () => {
for (const attachments of Object.values(attachmentsRef.current)) {
for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl);
@@ -251,15 +276,23 @@ export function CodingChatPanel({
useEffect(() => {
if (!activeProject || !selectedAgent) {
selectedConversationContextRef.current = null;
clearConversationSelection();
return;
}
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return;
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) {
const selectionContext = `${activeProject.id}:${selectedConversation.id}`;
if (selectedConversationContextRef.current !== selectionContext) {
void selectProjectConversation(activeProject.id, selectedConversation.id)
.catch(() => undefined);
}
return;
}
const next = newestConversation(conversations, selectedAgent.id);
if (next) {
markConversationUnread(next.id, false);
if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined);
void selectConversation(next.id).catch(() => undefined);
void selectProjectConversation(activeProject.id, next.id).catch(() => undefined);
return;
}
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
@@ -271,7 +304,8 @@ export function CodingChatPanel({
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === activeProject.id
&& current.selectedAgentId === selectedAgent.id) {
void selectConversation(conversation.id).catch(() => undefined);
void selectProjectConversation(activeProject.id, conversation.id)
.catch(() => undefined);
}
})
.catch(() => undefined);
@@ -283,7 +317,7 @@ export function CodingChatPanel({
markConversationUnread,
patchConversation,
primeConversation,
selectConversation,
selectProjectConversation,
selectedAgent,
selectedConversation,
]);
@@ -332,8 +366,8 @@ export function CodingChatPanel({
if (conversation.unread) {
void patchConversation(conversation.id, { unread: false }).catch(() => undefined);
}
void selectConversation(conversation.id).catch(() => undefined);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]);
void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]);
const handleCreateConversation = useCallback(async () => {
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
@@ -344,14 +378,14 @@ export function CodingChatPanel({
primeConversation(createLocalConversationSnapshot(projectId, conversation));
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
await selectConversation(conversation.id).catch(() => undefined);
await selectProjectConversation(projectId, conversation.id).catch(() => undefined);
}
}, [
activeProject,
createConversation,
creatingAgentIds,
primeConversation,
selectConversation,
selectProjectConversation,
selectedAgent,
]);
@@ -369,9 +403,9 @@ export function CodingChatPanel({
if (workspace.activeProjectId === sourceProjectId
&& workspace.selectedAgentId === sourceAgentId
&& selectedId === sourceConversationId) {
await selectConversation(forked.id);
await selectProjectConversation(sourceProjectId, forked.id);
}
}, [activeProject, primeConversation, selectConversation, selectedAgent, targetConversationId, upsertConversation]);
}, [activeProject, primeConversation, selectProjectConversation, selectedAgent, targetConversationId, upsertConversation]);
const handleAddFiles = useCallback((files: File[]) => {
if (!draftKey

View File

@@ -538,14 +538,12 @@ export function createCodingConversationStore(
};
});
const entry = get().entriesByConversationId[conversationId];
const snapshotFlight = !entry?.reducer.snapshot
|| entry.reducer.invalidation
|| entry.loadState !== 'live'
? get().loadSnapshot(
conversationId,
entry?.reducer.invalidation ? 'recovering' : 'loading',
)
: Promise.resolve(entry.reducer.snapshot);
const refreshMode = entry?.reducer.invalidation
? 'recovering'
: entry?.reducer.snapshot && entry.loadState === 'live'
? 'silent'
: 'loading';
const snapshotFlight = get().loadSnapshot(conversationId, refreshMode);
await Promise.all([snapshotFlight, get().connectEvents()]);
},