Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ target
target
bin
.serena
chatroom

# Editor directories and files
.vscode/*
Expand Down
35 changes: 35 additions & 0 deletions crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
);
});
234 changes: 234 additions & 0 deletions crates/agent-gateway/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ import {
isChatStreamNotAvailableEvent,
isChatStreamNotAvailableMessage,
resolveChatStreamUnavailableRecoveryAction,
shouldHydrateRestoredConversationSnapshot,
} from "./lib/chatStreamRecovery";
import { memoryDeleteProject } from "./lib/memory/api";
import {
appendCommittedLiveEntries,
hasEquivalentTailEntries,
Expand Down Expand Up @@ -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 = "分享会话";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -897,6 +901,7 @@ export default function App() {
const titlePositionLockTimeoutsRef = useRef<Map<string, number>>(new Map());
const blockedHistoryHydrationConversationIdsRef = useRef<Set<string>>(new Set());
const visibleHistorySnapshotRefreshSeqRef = useRef<Map<string, number>>(new Map());
const restoredPageHistoryRefreshAtRef = useRef<Map<string, number>>(new Map());
const historyLoadSequenceRef = useRef(0);
const visibleConversationRevisionRef = useRef(0);
const previousDisplayedConversationIdRef = useRef("");
Expand Down Expand Up @@ -1152,6 +1157,10 @@ export default function App() {
selectedHistoryRef.current = selectedHistory;
}, [selectedHistory]);

useEffect(() => {
selectedHistoryEntriesRef.current = selectedHistoryEntries;
}, [selectedHistoryEntries]);

useEffect(() => {
historyItemsRef.current = historyItems;
}, [historyItems]);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div
key={`${section}:${entry.kind}:${entry.oldPath ?? ""}:${entry.path}`}
Expand All @@ -4181,8 +4192,20 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel
>
<TypeIcon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 select-none">
<span className="block truncate text-xs font-medium text-foreground">{fileName}</span>
<span className="block truncate text-[11px] leading-4 text-muted-foreground">
<span
className={cn(
"block truncate text-xs font-medium text-foreground",
deleted && "line-through",
)}
>
{fileName}
</span>
<span
className={cn(
"block truncate text-[11px] leading-4 text-muted-foreground",
deleted && "line-through",
)}
>
{filePath}
</span>
</span>
Expand Down
Loading
Loading