fix(webui): preserve range selection and turn timing

This commit is contained in:
Xubin Ren
2026-08-15 15:40:52 +08:00
parent 4de728a555
commit e630e78075
9 changed files with 262 additions and 16 deletions
+59 -11
View File
@@ -298,6 +298,7 @@ export const ChatList = memo(function ChatList({
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
() => new Set(),
);
const deleteSelectionAnchorRef = useRef<string | null>(null);
useEffect(() => {
const clearPaneDropTarget = () => setPaneDropTarget(null);
@@ -421,6 +422,7 @@ export const ChatList = memo(function ChatList({
if (event.key !== "Escape") return;
setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set());
deleteSelectionAnchorRef.current = null;
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
@@ -530,15 +532,39 @@ export const ChatList = memo(function ChatList({
const updated = new Set(updatedChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
group.sessions.flatMap((session) => {
const paneGroup = paneGroups[session.key];
const isWorkbenchTab = paneGroup?.visible
?? ((paneGroup?.panes.length ?? 0) > 1);
return isWorkbenchTab
? paneGroup?.panes.map((pane) => pane.key) ?? [session.key]
: [session.key];
})
))));
const beginDeleteSelection = (keys: string[]) => {
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
setDeleteSelectionMode(true);
setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key))));
setSelectedDeleteKeys(new Set(validKeys));
deleteSelectionAnchorRef.current = validKeys[0] ?? null;
};
const toggleDeleteSelection = (keys: string[]) => {
const toggleDeleteSelection = (
keys: string[],
shiftKey = false,
targetKey = keys[0],
) => {
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
const anchorKey = deleteSelectionAnchorRef.current;
const range = shiftKey && anchorKey && targetKey
? selectionRange(selectableDeleteKeys, anchorKey, targetKey)
: null;
setSelectedDeleteKeys((current) => {
const next = new Set(current);
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
if (range) {
for (const key of range) next.add(key);
return next;
}
const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
for (const key of validKeys) {
if (remove) next.delete(key);
@@ -546,10 +572,12 @@ export const ChatList = memo(function ChatList({
}
return next;
});
if (!range) deleteSelectionAnchorRef.current = targetKey ?? null;
};
const closeDeleteSelection = () => {
setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set());
deleteSelectionAnchorRef.current = null;
};
const requestDeleteItems = (items: SidebarDeleteItem[]) => {
if (items.length === 0) return;
@@ -801,7 +829,11 @@ export const ChatList = memo(function ChatList({
selected={tabSelected}
partiallySelected={tabPartiallySelected}
onToggle={() => togglePaneGroup(s.key)}
onToggleSelection={() => toggleDeleteSelection(tabDeleteKeys)}
onToggleSelection={(shiftKey) => toggleDeleteSelection(
tabDeleteKeys,
shiftKey,
tabDeleteKeys[0],
)}
onRequestRename={onRequestRenameTab
? () => onRequestRenameTab(s.key, title)
: undefined}
@@ -901,9 +933,9 @@ export const ChatList = memo(function ChatList({
<SidebarItemTooltip label={tooltipTitle}>
<button
type="button"
onClick={() => {
onClick={(event) => {
if (deleteSelectionMode) {
toggleDeleteSelection(tabDeleteKeys);
toggleDeleteSelection(tabDeleteKeys, event.shiftKey, s.key);
return;
}
if (!topicActive) onSelect(s.key);
@@ -1143,7 +1175,7 @@ function WorkbenchTabHeader({
selected: boolean;
partiallySelected: boolean;
onToggle: () => void;
onToggleSelection: () => void;
onToggleSelection: (shiftKey: boolean) => void;
onRequestRename?: () => void;
onDissolve?: () => void;
onRequestDelete: () => void;
@@ -1167,7 +1199,10 @@ function WorkbenchTabHeader({
<SidebarItemTooltip label={title}>
<button
type="button"
onClick={deleteSelectionMode ? onToggleSelection : onToggle}
onClick={(event) => {
if (deleteSelectionMode) onToggleSelection(event.shiftKey);
else onToggle();
}}
draggable={false}
aria-label={t("workbench.tabAria", { title })}
aria-expanded={deleteSelectionMode ? undefined : !collapsed}
@@ -1313,7 +1348,11 @@ function ActivePaneRows({
) => void;
deleteSelectionMode: boolean;
selectedDeleteKeys: ReadonlySet<string>;
onToggleDeleteSelection: (keys: string[]) => void;
onToggleDeleteSelection: (
keys: string[],
shiftKey?: boolean,
targetKey?: string,
) => void;
onBeginDeleteSelection: (keys: string[]) => void;
actionMenuPortalContainer?: HTMLElement | null;
actionMenus: SidebarActionMenuController;
@@ -1368,9 +1407,9 @@ function ActivePaneRows({
<SidebarItemTooltip label={pane.title}>
<button
type="button"
onClick={() => {
onClick={(event) => {
if (deleteSelectionMode) {
onToggleDeleteSelection([pane.key]);
onToggleDeleteSelection([pane.key], event.shiftKey, pane.key);
return;
}
onSelectPane?.(group.tabKey, pane.key);
@@ -1484,6 +1523,15 @@ function ActivePaneRows({
);
}
function selectionRange(order: string[], anchorKey: string, targetKey: string): string[] | null {
const anchorIndex = order.indexOf(anchorKey);
const targetIndex = order.indexOf(targetKey);
if (anchorIndex < 0 || targetIndex < 0) return null;
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
return order.slice(start, end + 1);
}
function SelectionIndicator({
checked,
partial,
+21 -3
View File
@@ -76,8 +76,10 @@ export function ThreadMessages({
);
const forkFlags = useMemo(() => assistantForkFlags(units), [units]);
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units],
() => isStreaming
? currentActivityClusterIndices(units, activeTurnId)
: new Set<number>(),
[activeTurnId, isStreaming, units],
);
const pendingTurn = useMemo(
() => pendingTurnProjection(messages, activeTurnId),
@@ -362,8 +364,24 @@ function ForkBoundaryDivider({ label }: { label: string }) {
);
}
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
function currentActivityClusterIndices(
units: DisplayUnit[],
activeTurnId: string | null,
): Set<number> {
const indices = new Set<number>();
if (activeTurnId) {
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (
unit.type === "activity"
&& unit.messages.some((message) => message.turnId === activeTurnId)
) {
indices.add(i);
return indices;
}
}
}
let markedCurrentActivity = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
+10 -2
View File
@@ -814,8 +814,11 @@ export function ThreadShell({
const rememberedViewportTurnId = chatId
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
: null;
const canonicalRunTurnId = chatId && messagesReady && turnActive
? client.getRunTurnId(chatId)
: null;
const viewportTurnId = messagesReady && turnActive
? rememberedViewportTurnId ?? restoredViewportTurnId
? canonicalRunTurnId ?? rememberedViewportTurnId ?? restoredViewportTurnId
: null;
const activeTurnStartedHere =
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
@@ -1313,7 +1316,12 @@ export function ThreadShell({
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setFallbackModelName(null);
const submitted = send(content, images, withWorkspaceScope(options));
if (chatId && submitted && !submitted.sideChannel) {
if (
chatId
&& submitted
&& !submitted.sideChannel
&& options?.continueActiveTurn !== true
) {
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
setSubmittedViewportTurnId(submitted.turnId);
}
+3
View File
@@ -260,6 +260,9 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs,
turnId: message.turnId,
turnPhase: "reasoning",
turnSeq: message.turnSeq,
};
}
+5
View File
@@ -308,6 +308,11 @@ export class NanobotClient {
return v === undefined ? null : v;
}
/** Canonical lifecycle turn currently owning the run for *chatId*, if known. */
getRunTurnId(chatId: string): string | null {
return this.latestRunTurnIdByChatId.get(chatId) ?? null;
}
/** Clear the optimistic run state immediately after the user stops a turn. */
finishRunLocally(chatId: string): void {
const unsettled = [...(this.unsettledRunTurnIdsByChatId.get(chatId) ?? [])];
+1
View File
@@ -260,6 +260,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
return () => runStatusHandlers.delete(handler);
};
getRunStartedAt = () => null;
getRunTurnId = () => null;
getGoalState = () => undefined;
sendMessage = sendMessageSpy;
newChat = vi.fn();
+36
View File
@@ -662,6 +662,42 @@ describe("ChatList", () => {
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
});
it("selects a contiguous session range with Shift-click", async () => {
render(
<ChatList
sessions={[
session({ chatId: "first", title: "First topic" }),
session({ chatId: "second", title: "Second topic" }),
session({ chatId: "third", title: "Third topic" }),
session({ chatId: "fourth", title: "Fourth topic" }),
]}
activeKey="websocket:first"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", {
name: "Topic actions for First topic",
}), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
fireEvent.click(screen.getByRole("button", { name: "Fourth topic" }), {
shiftKey: true,
});
expect(screen.getByText("4 selected")).toBeInTheDocument();
for (const title of ["First topic", "Second topic", "Third topic", "Fourth topic"]) {
expect(screen.getByRole("button", { name: title })).toHaveAttribute(
"aria-pressed",
"true",
);
}
});
it("shows temporary chats separately and lets the user reopen or close them", async () => {
const temporarySession = session({
key: "temporary:temporary-one",
+60
View File
@@ -547,6 +547,66 @@ describe("ThreadMessages", () => {
expect(screen.queryByText("Working for 10s")).not.toBeInTheDocument();
});
it("keeps a guided run's timer on its original activity cluster", () => {
vi.useFakeTimers();
const startedAt = 1_700_000_000_000;
vi.setSystemTime(startedAt + 215_000);
const messages: UIMessage[] = [
{
id: "u-original",
role: "user",
content: "research this",
turnId: "turn-original",
turnPhase: "user",
turnSeq: 0,
createdAt: startedAt,
},
{
id: "t-original",
role: "tool",
kind: "trace",
content: "web_search()",
traces: ["web_search()"],
turnId: "turn-original",
turnPhase: "activity",
turnSeq: 1,
createdAt: startedAt + 500,
},
{
id: "a-original",
role: "assistant",
content: "Continuing the search.",
latencyMs: 1_000,
turnId: "turn-original",
turnPhase: "answer",
turnSeq: 2,
createdAt: startedAt + 1_000,
},
{
id: "u-guidance",
role: "user",
content: "How is it going?",
turnId: "turn-guidance",
turnPhase: "user",
turnSeq: 0,
createdAt: startedAt + 215_000,
},
];
render(
<ThreadMessages
messages={messages}
isStreaming
activeTurnId="turn-original"
runStartedAt={startedAt / 1000}
/>,
);
expect(screen.getByText("Working for 3m 35s")).toBeInTheDocument();
expect(screen.queryByText("Worked for 1s")).not.toBeInTheDocument();
expect(screen.queryByText("Thinking for 3m 35s")).not.toBeInTheDocument();
});
it("folds final answer reasoning into the preceding activity timeline", () => {
const messages: UIMessage[] = [
{
+67
View File
@@ -107,6 +107,7 @@ function makeClient() {
};
},
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
getRunTurnId: (chatId: string) => latestRunTurnIdByChatId.get(chatId) ?? null,
finishRunLocally: vi.fn((chatId: string) => {
runStartedAtByChatId.delete(chatId);
latestRunTurnIdByChatId.delete(chatId);
@@ -2784,6 +2785,72 @@ describe("ThreadShell", () => {
));
});
it("keeps active-run timing attached to the original turn after guidance", async () => {
const client = makeClient();
const turnId = "turn-active-timing";
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("websocket%3Atiming-chat/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([{
role: "user",
content: "research this",
turnId,
}]));
}
return { ok: false, status: 404, json: async () => ({}) };
}),
);
render(
wrap(
client,
<ThreadShell
session={session("timing-chat")}
title="Timing chat"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("research this")).toBeInTheDocument());
act(() => {
client._emitChat("timing-chat", {
event: "goal_status",
chat_id: "timing-chat",
status: "running",
started_at: Date.now() / 1000 - 215,
turn_id: turnId,
});
client._emitChat("timing-chat", {
event: "message",
chat_id: "timing-chat",
kind: "progress",
text: "web_search()",
turn_id: turnId,
});
client._emitChat("timing-chat", {
event: "message",
chat_id: "timing-chat",
text: "Continuing the search.",
latency_ms: 1_000,
turn_id: turnId,
});
});
await waitFor(() => expect(screen.getByText("Continuing the search.")).toBeInTheDocument());
const input = screen.getByRole("textbox", { name: "Message input" });
fireEvent.change(input, { target: { value: "How is it going?" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(screen.getByText("How is it going?")).toBeInTheDocument());
expect(screen.getByRole("button", { name: /^Working for / })).toBeInTheDocument();
expect(screen.queryByText("Worked for 1s")).not.toBeInTheDocument();
expect(screen.queryByRole("status", { name: /^Thinking for / })).not.toBeInTheDocument();
});
it("refreshes the current thread when the page returns to the foreground", async () => {
const client = makeClient();
let historyCalls = 0;