fix(webui): preserve range selection and turn timing
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user