Merge remote-tracking branch 'origin/main' into codex/review-pr-3894
# Conflicts: # tests/utils/test_webui_transcript.py
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user