mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-17 09:36:33 +03:00
fix(webui): preserve range selection and turn timing
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) ?? [])];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user