Merge remote-tracking branch 'origin/main' into codex/review-pr-3894

# Conflicts:
#	tests/utils/test_webui_transcript.py
This commit is contained in:
Xubin Ren
2026-05-19 23:19:33 +08:00
89 changed files with 9512 additions and 1505 deletions
+54 -22
View File
@@ -215,18 +215,19 @@ function absorbCompleteAssistantMessage(
}
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
return `${edit.call_id}|${edit.tool}|${edit.path}`;
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.path || !edit.tool) return null;
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
edit.phase === "error"
? "error"
: edit.phase === "end"
? "done"
: "editing";
return {
const normalized: UIFileEdit = {
...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
@@ -235,6 +236,8 @@ function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
? edit.status
: inferredStatus,
};
if (edit.pending && !edit.path) normalized.pending = true;
return normalized;
}
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
@@ -250,11 +253,31 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit
next.push(edit);
continue;
}
next[existingIndex] = { ...next[existingIndex], ...edit };
const merged = { ...next[existingIndex], ...edit };
if (edit.path && !edit.pending) delete merged.pending;
next[existingIndex] = merged;
}
return next;
}
function findFileEditTraceIndex(
prev: UIMessage[],
segmentId: string | null,
incoming: UIFileEdit[],
): number | null {
const incomingKeys = new Set(incoming.map(fileEditKey));
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace" || !candidate.fileEdits?.length) continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits) {
if (incomingKeys.has(fileEditKey(existing))) return i;
}
}
return null;
}
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
@@ -534,6 +557,7 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : "";
if (!chunk) return;
clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
schedulePendingStreamFlush();
@@ -544,6 +568,7 @@ export function useNanobotStream(
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text;
if (!chunk) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
schedulePendingStreamFlush();
@@ -622,6 +647,7 @@ export function useNanobotStream(
if (ev.kind === "reasoning") {
const line = ev.text;
if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
ensure: ensureActivitySegmentId,
})));
@@ -691,6 +717,7 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice.
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
clearActivitySegment();
setMessages((prev) => {
const activeId = buffer.current?.messageId;
buffer.current = null;
@@ -715,27 +742,32 @@ export function useNanobotStream(
if (ev.event === "file_edit") {
const edits = Array.isArray(ev.edits) ? ev.edits : [];
if (edits.length === 0) return;
const normalized = mergeFileEdits(undefined, edits);
if (normalized.length === 0) return;
const opensFileEditPhase = normalized.some(
(edit) => edit.status === "editing" || edit.phase === "start",
);
let eventSegmentId = fileEditSegmentRef.current;
if (!eventSegmentId && opensFileEditPhase) {
eventSegmentId = detachedActivitySegmentId();
fileEditSegmentRef.current = eventSegmentId;
}
setMessages((prev) => {
const last = prev[prev.length - 1];
let segmentId = fileEditSegmentRef.current;
if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
segmentId = detachedActivitySegmentId();
fileEditSegmentRef.current = segmentId;
}
if (
last
&& last.kind === "trace"
&& !last.isStreaming
&& !!last.fileEdits?.length
&& last.activitySegmentId === segmentId
) {
let segmentId = eventSegmentId;
const targetIndex = findFileEditTraceIndex(prev, segmentId, normalized);
if (targetIndex !== null) {
const target = prev[targetIndex];
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const merged: UIMessage = {
...last,
fileEdits: mergeFileEdits(last.fileEdits, edits),
activitySegmentId: last.activitySegmentId ?? segmentId,
...target,
fileEdits: mergeFileEdits(target.fileEdits, normalized),
activitySegmentId: segmentId,
};
return [...prev.slice(0, -1), merged];
return replaceMessageAt(prev, targetIndex, merged);
}
segmentId = segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
return [
...prev,
{
@@ -744,7 +776,7 @@ export function useNanobotStream(
kind: "trace",
content: "",
traces: [],
fileEdits: mergeFileEdits(undefined, edits),
fileEdits: normalized,
activitySegmentId: segmentId,
createdAt: Date.now(),
},
+206
View File
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import {
fetchSidebarState,
updateSidebarState as persistSidebarState,
} from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
title_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "updated_desc",
},
updated_at: null,
};
function uniqueStrings(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") continue;
const cleaned = item.trim();
if (!cleaned || seen.has(cleaned)) continue;
seen.add(cleaned);
out.push(cleaned);
}
return out;
}
function stringMap(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw !== "string") continue;
const cleanedKey = key.trim();
const cleanedValue = raw.trim();
if (!cleanedKey || !cleanedValue) continue;
out[cleanedKey] = cleanedValue;
}
return out;
}
function tagsMap(value: unknown): Record<string, string[]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, string[]> = {};
for (const [key, raw] of Object.entries(value)) {
const cleanedKey = key.trim();
if (!cleanedKey) continue;
const tags = uniqueStrings(raw).slice(0, 12);
if (tags.length) out[cleanedKey] = tags;
}
return out;
}
function boolMap(value: unknown): Record<string, boolean> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, boolean> = {};
for (const [key, raw] of Object.entries(value)) {
const cleanedKey = key.trim();
if (cleanedKey) out[cleanedKey] = Boolean(raw);
}
return out;
}
export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
}
const value = raw as Partial<SidebarStatePayload>;
const view = value.view && typeof value.view === "object"
? value.view
: DEFAULT_SIDEBAR_STATE.view;
const density = view.density === "compact" ? "compact" : "comfortable";
const sort = ["updated_desc", "created_desc", "title_asc"].includes(view.sort)
? view.sort
: "updated_desc";
return {
schema_version: 1,
pinned_keys: uniqueStrings(value.pinned_keys),
archived_keys: uniqueStrings(value.archived_keys),
title_overrides: stringMap(value.title_overrides),
tags_by_key: tagsMap(value.tags_by_key),
collapsed_groups: boolMap(value.collapsed_groups),
view: {
density,
show_previews: Boolean(view.show_previews),
show_timestamps: Boolean(view.show_timestamps),
show_archived: Boolean(view.show_archived),
sort,
},
updated_at: typeof value.updated_at === "string" ? value.updated_at : null,
};
}
function pruneMissingSessions(
state: SidebarStatePayload,
sessions: ChatSummary[],
): SidebarStatePayload {
const valid = new Set(sessions.map((session) => session.key));
const filterKeys = (keys: string[]) => keys.filter((key) => valid.has(key));
const filterMap = <T,>(map: Record<string, T>): Record<string, T> => {
const out: Record<string, T> = {};
for (const [key, value] of Object.entries(map)) {
if (valid.has(key)) out[key] = value;
}
return out;
};
return {
...state,
pinned_keys: filterKeys(state.pinned_keys),
archived_keys: filterKeys(state.archived_keys),
title_overrides: filterMap(state.title_overrides),
tags_by_key: filterMap(state.tags_by_key),
};
}
function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export function useSidebarState(
sessions: ChatSummary[],
sessionsLoaded: boolean,
): {
state: SidebarStatePayload;
loading: boolean;
update: (
updater: (state: SidebarStatePayload) => SidebarStatePayload,
) => Promise<void>;
} {
const { token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const persistVersionRef = useRef(0);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
stateRef.current = state;
useEffect(() => {
let cancelled = false;
setLoading(true);
(async () => {
try {
const loaded = normalizeSidebarState(await fetchSidebarState(tokenRef.current));
if (cancelled) return;
stateRef.current = loaded;
setState(loaded);
} catch {
if (cancelled) return;
stateRef.current = DEFAULT_SIDEBAR_STATE;
setState(DEFAULT_SIDEBAR_STATE);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current));
const version = persistVersionRef.current + 1;
persistVersionRef.current = version;
stateRef.current = next;
setState(next);
try {
const persisted = normalizeSidebarState(
await persistSidebarState(tokenRef.current, next),
);
if (persistVersionRef.current !== version) return;
stateRef.current = persisted;
setState(persisted);
} catch {
// Keep the optimistic UI state. Older gateways or transient auth expiry
// should not break the chat list; the next refresh can try again.
}
},
[],
);
const pruned = useMemo(() => {
if (!sessionsLoaded || loading) return state;
return pruneMissingSessions(state, sessions);
}, [loading, sessions, sessionsLoaded, state]);
useEffect(() => {
if (!sessionsLoaded || loading || sameState(pruned, state)) return;
void update(() => pruned);
}, [loading, pruned, sessionsLoaded, state, update]);
return { state, loading, update };
}