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>>( const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
() => new Set(), () => new Set(),
); );
const deleteSelectionAnchorRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const clearPaneDropTarget = () => setPaneDropTarget(null); const clearPaneDropTarget = () => setPaneDropTarget(null);
@@ -421,6 +422,7 @@ export const ChatList = memo(function ChatList({
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
setDeleteSelectionMode(false); setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set()); setSelectedDeleteKeys(new Set());
deleteSelectionAnchorRef.current = null;
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
@@ -530,15 +532,39 @@ export const ChatList = memo(function ChatList({
const updated = new Set(updatedChatIds); const updated = new Set(updatedChatIds);
const compact = density === "compact"; const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); 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 beginDeleteSelection = (keys: string[]) => {
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
setDeleteSelectionMode(true); 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) => { setSelectedDeleteKeys((current) => {
const next = new Set(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)); const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
for (const key of validKeys) { for (const key of validKeys) {
if (remove) next.delete(key); if (remove) next.delete(key);
@@ -546,10 +572,12 @@ export const ChatList = memo(function ChatList({
} }
return next; return next;
}); });
if (!range) deleteSelectionAnchorRef.current = targetKey ?? null;
}; };
const closeDeleteSelection = () => { const closeDeleteSelection = () => {
setDeleteSelectionMode(false); setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set()); setSelectedDeleteKeys(new Set());
deleteSelectionAnchorRef.current = null;
}; };
const requestDeleteItems = (items: SidebarDeleteItem[]) => { const requestDeleteItems = (items: SidebarDeleteItem[]) => {
if (items.length === 0) return; if (items.length === 0) return;
@@ -801,7 +829,11 @@ export const ChatList = memo(function ChatList({
selected={tabSelected} selected={tabSelected}
partiallySelected={tabPartiallySelected} partiallySelected={tabPartiallySelected}
onToggle={() => togglePaneGroup(s.key)} onToggle={() => togglePaneGroup(s.key)}
onToggleSelection={() => toggleDeleteSelection(tabDeleteKeys)} onToggleSelection={(shiftKey) => toggleDeleteSelection(
tabDeleteKeys,
shiftKey,
tabDeleteKeys[0],
)}
onRequestRename={onRequestRenameTab onRequestRename={onRequestRenameTab
? () => onRequestRenameTab(s.key, title) ? () => onRequestRenameTab(s.key, title)
: undefined} : undefined}
@@ -901,9 +933,9 @@ export const ChatList = memo(function ChatList({
<SidebarItemTooltip label={tooltipTitle}> <SidebarItemTooltip label={tooltipTitle}>
<button <button
type="button" type="button"
onClick={() => { onClick={(event) => {
if (deleteSelectionMode) { if (deleteSelectionMode) {
toggleDeleteSelection(tabDeleteKeys); toggleDeleteSelection(tabDeleteKeys, event.shiftKey, s.key);
return; return;
} }
if (!topicActive) onSelect(s.key); if (!topicActive) onSelect(s.key);
@@ -1143,7 +1175,7 @@ function WorkbenchTabHeader({
selected: boolean; selected: boolean;
partiallySelected: boolean; partiallySelected: boolean;
onToggle: () => void; onToggle: () => void;
onToggleSelection: () => void; onToggleSelection: (shiftKey: boolean) => void;
onRequestRename?: () => void; onRequestRename?: () => void;
onDissolve?: () => void; onDissolve?: () => void;
onRequestDelete: () => void; onRequestDelete: () => void;
@@ -1167,7 +1199,10 @@ function WorkbenchTabHeader({
<SidebarItemTooltip label={title}> <SidebarItemTooltip label={title}>
<button <button
type="button" type="button"
onClick={deleteSelectionMode ? onToggleSelection : onToggle} onClick={(event) => {
if (deleteSelectionMode) onToggleSelection(event.shiftKey);
else onToggle();
}}
draggable={false} draggable={false}
aria-label={t("workbench.tabAria", { title })} aria-label={t("workbench.tabAria", { title })}
aria-expanded={deleteSelectionMode ? undefined : !collapsed} aria-expanded={deleteSelectionMode ? undefined : !collapsed}
@@ -1313,7 +1348,11 @@ function ActivePaneRows({
) => void; ) => void;
deleteSelectionMode: boolean; deleteSelectionMode: boolean;
selectedDeleteKeys: ReadonlySet<string>; selectedDeleteKeys: ReadonlySet<string>;
onToggleDeleteSelection: (keys: string[]) => void; onToggleDeleteSelection: (
keys: string[],
shiftKey?: boolean,
targetKey?: string,
) => void;
onBeginDeleteSelection: (keys: string[]) => void; onBeginDeleteSelection: (keys: string[]) => void;
actionMenuPortalContainer?: HTMLElement | null; actionMenuPortalContainer?: HTMLElement | null;
actionMenus: SidebarActionMenuController; actionMenus: SidebarActionMenuController;
@@ -1368,9 +1407,9 @@ function ActivePaneRows({
<SidebarItemTooltip label={pane.title}> <SidebarItemTooltip label={pane.title}>
<button <button
type="button" type="button"
onClick={() => { onClick={(event) => {
if (deleteSelectionMode) { if (deleteSelectionMode) {
onToggleDeleteSelection([pane.key]); onToggleDeleteSelection([pane.key], event.shiftKey, pane.key);
return; return;
} }
onSelectPane?.(group.tabKey, pane.key); 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({ function SelectionIndicator({
checked, checked,
partial, partial,
+21 -3
View File
@@ -76,8 +76,10 @@ export function ThreadMessages({
); );
const forkFlags = useMemo(() => assistantForkFlags(units), [units]); const forkFlags = useMemo(() => assistantForkFlags(units), [units]);
const liveActivityClusterIndices = useMemo( const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(), () => isStreaming
[isStreaming, units], ? currentActivityClusterIndices(units, activeTurnId)
: new Set<number>(),
[activeTurnId, isStreaming, units],
); );
const pendingTurn = useMemo( const pendingTurn = useMemo(
() => pendingTurnProjection(messages, activeTurnId), () => 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>(); 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; let markedCurrentActivity = false;
for (let i = units.length - 1; i >= 0; i -= 1) { for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i]; const unit = units[i];
+10 -2
View File
@@ -814,8 +814,11 @@ export function ThreadShell({
const rememberedViewportTurnId = chatId const rememberedViewportTurnId = chatId
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null ? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
: null; : null;
const canonicalRunTurnId = chatId && messagesReady && turnActive
? client.getRunTurnId(chatId)
: null;
const viewportTurnId = messagesReady && turnActive const viewportTurnId = messagesReady && turnActive
? rememberedViewportTurnId ?? restoredViewportTurnId ? canonicalRunTurnId ?? rememberedViewportTurnId ?? restoredViewportTurnId
: null; : null;
const activeTurnStartedHere = const activeTurnStartedHere =
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId; viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
@@ -1313,7 +1316,12 @@ export function ThreadShell({
(content: string, images?: SendAttachment[], options?: SendOptions) => { (content: string, images?: SendAttachment[], options?: SendOptions) => {
setFallbackModelName(null); setFallbackModelName(null);
const submitted = send(content, images, withWorkspaceScope(options)); 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); activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
setSubmittedViewportTurnId(submitted.turnId); setSubmittedViewportTurnId(submitted.turnId);
} }
+3
View File
@@ -260,6 +260,9 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
isStreaming: message.reasoningStreaming, isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId, activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs, 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; 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. */ /** Clear the optimistic run state immediately after the user stops a turn. */
finishRunLocally(chatId: string): void { finishRunLocally(chatId: string): void {
const unsettled = [...(this.unsettledRunTurnIdsByChatId.get(chatId) ?? [])]; const unsettled = [...(this.unsettledRunTurnIdsByChatId.get(chatId) ?? [])];
+1
View File
@@ -260,6 +260,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
return () => runStatusHandlers.delete(handler); return () => runStatusHandlers.delete(handler);
}; };
getRunStartedAt = () => null; getRunStartedAt = () => null;
getRunTurnId = () => null;
getGoalState = () => undefined; getGoalState = () => undefined;
sendMessage = sendMessageSpy; sendMessage = sendMessageSpy;
newChat = vi.fn(); newChat = vi.fn();
+36
View File
@@ -662,6 +662,42 @@ describe("ChatList", () => {
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument(); 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 () => { it("shows temporary chats separately and lets the user reopen or close them", async () => {
const temporarySession = session({ const temporarySession = session({
key: "temporary:temporary-one", key: "temporary:temporary-one",
+60
View File
@@ -547,6 +547,66 @@ describe("ThreadMessages", () => {
expect(screen.queryByText("Working for 10s")).not.toBeInTheDocument(); 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", () => { it("folds final answer reasoning into the preceding activity timeline", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
+67
View File
@@ -107,6 +107,7 @@ function makeClient() {
}; };
}, },
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null, getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
getRunTurnId: (chatId: string) => latestRunTurnIdByChatId.get(chatId) ?? null,
finishRunLocally: vi.fn((chatId: string) => { finishRunLocally: vi.fn((chatId: string) => {
runStartedAtByChatId.delete(chatId); runStartedAtByChatId.delete(chatId);
latestRunTurnIdByChatId.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 () => { it("refreshes the current thread when the page returns to the foreground", async () => {
const client = makeClient(); const client = makeClient();
let historyCalls = 0; let historyCalls = 0;