diff --git a/.gitignore b/.gitignore index c4a5c37bb..898cbf387 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ target target bin .serena +chatroom # Editor directories and files .vscode/* diff --git a/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs b/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs index 004e02496..6e8de7ee5 100644 --- a/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs +++ b/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs @@ -8,6 +8,7 @@ test("chat stream recovery detects released attach streams", () => { isChatStreamNotAvailableEvent, isChatStreamNotAvailableMessage, resolveChatStreamUnavailableRecoveryAction, + shouldHydrateRestoredConversationSnapshot, } = loader.loadModule("src/lib/chatStreamRecovery.ts"); assert.equal(isChatStreamNotAvailableMessage("chat stream not available"), true); @@ -40,4 +41,38 @@ test("chat stream recovery detects released attach streams", () => { resolveChatStreamUnavailableRecoveryAction("__local_draft__:conversation-1"), "reload-history", ); + + assert.equal( + shouldHydrateRestoredConversationSnapshot({ + currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], + liveEntries: [{ id: "live-assistant", kind: "assistant", text: "partial", round: 1 }], + historyEntries: [ + { id: "history-user", kind: "user", text: "hello", attachments: [] }, + { id: "history-assistant", kind: "assistant", text: "partial and final", round: 1 }, + ], + }), + true, + ); + + assert.equal( + shouldHydrateRestoredConversationSnapshot({ + currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], + historyEntries: [{ id: "history-user", kind: "user", text: "hello", attachments: [] }], + }), + false, + ); + + assert.equal( + shouldHydrateRestoredConversationSnapshot({ + currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], + liveEntries: [ + { id: "live-assistant", kind: "assistant", text: "partial text that is newer", round: 1 }, + ], + historyEntries: [ + { id: "history-user", kind: "user", text: "hello", attachments: [] }, + { id: "history-assistant", kind: "assistant", text: "partial", round: 1 }, + ], + }), + false, + ); }); diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index d8e7734ef..ba683e931 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -130,7 +130,9 @@ import { isChatStreamNotAvailableEvent, isChatStreamNotAvailableMessage, resolveChatStreamUnavailableRecoveryAction, + shouldHydrateRestoredConversationSnapshot, } from "./lib/chatStreamRecovery"; +import { memoryDeleteProject } from "./lib/memory/api"; import { appendCommittedLiveEntries, hasEquivalentTailEntries, @@ -376,6 +378,7 @@ const HISTORY_TITLE_POSITION_LOCK_MS = 1200; const SECONDS_TIMESTAMP_MAX = 10_000_000_000; const DRAFT_HISTORY_ADOPTION_WINDOW_MS = 30_000; const LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS = 30_000; +const PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS = 900; const DEFAULT_BROWSER_TITLE = "LiveAgent Gateway"; const NEW_CONVERSATION_BROWSER_TITLE = "LiveAgent"; const SHARED_HISTORY_BROWSER_TITLE = "分享会话"; @@ -864,6 +867,7 @@ export default function App() { const chatToolStatusRef = useRef(chatToolStatus); const chatToolStatusIsCompactionRef = useRef(chatToolStatusIsCompaction); const selectedHistoryRef = useRef(selectedHistory); + const selectedHistoryEntriesRef = useRef(selectedHistoryEntries); const historyItemsRef = useRef(historyItems); const historyTotalRef = useRef(historyTotal); const historyHasMoreRef = useRef(historyHasMore); @@ -897,6 +901,7 @@ export default function App() { const titlePositionLockTimeoutsRef = useRef>(new Map()); const blockedHistoryHydrationConversationIdsRef = useRef>(new Set()); const visibleHistorySnapshotRefreshSeqRef = useRef>(new Map()); + const restoredPageHistoryRefreshAtRef = useRef>(new Map()); const historyLoadSequenceRef = useRef(0); const visibleConversationRevisionRef = useRef(0); const previousDisplayedConversationIdRef = useRef(""); @@ -1152,6 +1157,10 @@ export default function App() { selectedHistoryRef.current = selectedHistory; }, [selectedHistory]); + useEffect(() => { + selectedHistoryEntriesRef.current = selectedHistoryEntries; + }, [selectedHistoryEntries]); + useEffect(() => { historyItemsRef.current = historyItems; }, [historyItems]); @@ -3718,6 +3727,181 @@ export default function App() { ], ); + const recoverCompletedVisibleConversationFromHistorySnapshot = useCallback( + async (targetConversationId: string, currentApi = api) => { + const conversationIdValue = targetConversationId.trim(); + if (!currentApi || !conversationIdValue) { + return false; + } + + const isStillVisible = () => + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + conversationIdValue; + + if (!isStillVisible()) { + return false; + } + + const refreshSeq = + (visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) ?? 0) + 1; + visibleHistorySnapshotRefreshSeqRef.current.set(conversationIdValue, refreshSeq); + + let detail: HistoryDetail; + let entries: ChatEntry[]; + try { + detail = await currentApi.getHistory(conversationIdValue, { + maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES, + }); + entries = await parseHistoryMessagesJsonAsync(detail.messages_json); + } catch { + return false; + } + + if ( + visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) !== refreshSeq || + !isStillVisible() + ) { + return false; + } + + const detailConversationId = detail.conversation_id.trim(); + if (detailConversationId !== "" && detailConversationId !== conversationIdValue) { + return false; + } + + const liveStore = liveConversationStreamStoresRef.current.get(conversationIdValue); + liveStore?.flush(); + const liveEntries = liveStore?.getSnapshot().entries ?? []; + const currentEntries = + conversationIdRef.current.trim() === conversationIdValue && + (selectedHistoryIdRef.current.trim() === "" || + selectedHistoryIdRef.current.trim() === conversationIdValue) + ? chatMessagesRef.current + : selectedHistoryIdRef.current.trim() === conversationIdValue + ? selectedHistoryEntriesRef.current + : (conversationRuntimeCacheRef.current.get(conversationIdValue)?.messages ?? []); + + if ( + !shouldHydrateRestoredConversationSnapshot({ + currentEntries, + historyEntries: entries, + liveEntries, + }) + ) { + return false; + } + + const mergeOptions = { isFullSnapshot: detail.has_more === false }; + pendingHistoryRefreshAfterLiveCompletionRef.current.delete(conversationIdValue); + blockedHistoryHydrationConversationIdsRef.current.delete(conversationIdValue); + clearConversationLiveStream(conversationIdValue); + clearConversationStreamingState(conversationIdValue); + setHistoryDetailLoading(false); + + if (selectedHistoryIdRef.current.trim() === conversationIdValue) { + selectedHistoryRef.current = detail; + setSelectedHistory(detail); + setSelectedHistoryEntries((current) => + mergeHistorySnapshotEntries(current, entries, mergeOptions), + ); + } + + updateConversationRuntimeEntry(conversationIdValue, (current) => ({ + ...current, + messages: mergeHistorySnapshotEntries(current.messages, entries, mergeOptions), + error: null, + toolStatus: null, + toolStatusIsCompaction: false, + isSending: false, + })); + pendingDisplayedConversationAutoBottomRef.current = conversationIdValue; + return true; + }, + [ + api, + clearConversationLiveStream, + clearConversationStreamingState, + updateConversationRuntimeEntry, + ], + ); + + const recoverVisibleConversationAfterPageRestore = useCallback( + (currentApi = api) => { + if (!currentApi) { + return; + } + + const visibleConversationId = resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ).trim(); + if (!visibleConversationId) { + return; + } + + const now = Date.now(); + const lastRefreshAt = + restoredPageHistoryRefreshAtRef.current.get(visibleConversationId) ?? 0; + if (now - lastRefreshAt < PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS) { + return; + } + restoredPageHistoryRefreshAtRef.current.set(visibleConversationId, now); + + if (isLocalDraftConversationId(visibleConversationId)) { + void reloadHistory(currentApi, { + preferredConversationId: visibleConversationId, + hydrateSelection: true, + silent: true, + adoptPendingDraftConversation: true, + }); + return; + } + + const hasLocalRunningState = + getConversationAbortController(visibleConversationId) !== null || + localRunningConversationIdsRef.current.has(visibleConversationId) || + blockedHistoryHydrationConversationIdsRef.current.has(visibleConversationId); + const hasRetainedLiveStream = hasRetainedConversationLiveStream(visibleConversationId); + const isRemoteRunning = remoteRunningConversationIdsRef.current.has(visibleConversationId); + + if (hasLocalRunningState || hasRetainedLiveStream || isRemoteRunning) { + void recoverCompletedVisibleConversationFromHistorySnapshot( + visibleConversationId, + currentApi, + ).then((hydrated) => { + if (hydrated) { + return; + } + if (remoteRunningConversationIdsRef.current.has(visibleConversationId)) { + attachVisibleConversationLiveStream(visibleConversationId, currentApi); + } + }); + return; + } + + void refreshVisibleConversationHistorySnapshot(visibleConversationId, currentApi, { + allowIdle: true, + }); + }, + [ + api, + attachVisibleConversationLiveStream, + getConversationAbortController, + hasRetainedConversationLiveStream, + recoverCompletedVisibleConversationFromHistorySnapshot, + refreshVisibleConversationHistorySnapshot, + reloadHistory, + ], + ); + const recoverVisibleConversationAfterPageRestoreRef = useRef( + recoverVisibleConversationAfterPageRestore, + ); + + useEffect(() => { + recoverVisibleConversationAfterPageRestoreRef.current = + recoverVisibleConversationAfterPageRestore; + }, [recoverVisibleConversationAfterPageRestore]); + useEffect(() => { if (!api || !status?.online) { return; @@ -3738,6 +3922,49 @@ export default function App() { }); }, [api, historyScopeKey, status?.online]); + useEffect(() => { + if (!api || historyShareToken || status?.online !== true) { + return; + } + + let delayedRestoreTimer: number | null = null; + const runRecovery = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + recoverVisibleConversationAfterPageRestoreRef.current(api); + if (delayedRestoreTimer !== null) { + window.clearTimeout(delayedRestoreTimer); + } + delayedRestoreTimer = window.setTimeout(() => { + delayedRestoreTimer = null; + recoverVisibleConversationAfterPageRestoreRef.current(api); + }, PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS + 350); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + runRecovery(); + } + }; + + window.addEventListener("pageshow", runRecovery); + window.addEventListener("focus", runRecovery); + document.addEventListener("visibilitychange", handleVisibilityChange); + document.addEventListener("resume", runRecovery); + runRecovery(); + + return () => { + if (delayedRestoreTimer !== null) { + window.clearTimeout(delayedRestoreTimer); + } + window.removeEventListener("pageshow", runRecovery); + window.removeEventListener("focus", runRecovery); + document.removeEventListener("visibilitychange", handleVisibilityChange); + document.removeEventListener("resume", runRecovery); + }; + }, [api, historyShareToken, status?.online]); + async function sendChat(message: string, options?: SendChatOptions) { if (!api || chatBusyRef.current) { return; @@ -4288,6 +4515,13 @@ export default function App() { Boolean(visibleConversationId && deletedConversationIds.has(visibleConversationId)) || Boolean(pathKey && workspaceProjectPathKey(visibleWorkdir) === pathKey); + if (path) { + await memoryDeleteProject({ + workdir: path, + actor: "tool", + reason: "workspace project removed", + }); + } removeWorkspaceProjectFromSettings(project); if (shouldResetVisibleConversation) { startNewConversation({ diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index 303ea6ca2..47d788561 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx @@ -1657,10 +1657,20 @@ function DiffReviewCard(props: { function statusTone(entry: GitStatusEntry) { if (entry.conflicted) return "text-destructive"; if (entry.untracked) return "text-sky-600 dark:text-sky-300"; + if (isDeletedStatusEntry(entry)) return "text-rose-600 dark:text-rose-300"; if (entry.staged) return "text-emerald-600 dark:text-emerald-300"; return "text-amber-600 dark:text-amber-300"; } +function isDeletedStatusEntry(entry: GitStatusEntry) { + if (entry.untracked) return false; + return ( + entry.kind === "deleted" || + entry.indexStatus === "D" || + entry.worktreeStatus === "D" + ); +} + function statusLabel(entry: GitStatusEntry) { if (entry.conflicted) return "U"; if (entry.untracked) return "U"; @@ -4158,6 +4168,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const TypeIcon = getFileTypeIcon(entry.path, "file"); const fileName = basename(entry.path); const filePath = parentPath(entry.path); + const deleted = isDeletedStatusEntry(entry); return (