mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(webui): hide actions until turn end
This commit is contained in:
@@ -52,6 +52,8 @@ import type {
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: UIMessage;
|
||||
/** The containing agent turn has not received turn_end yet. */
|
||||
isTurnStreaming?: boolean;
|
||||
/** Give temporary-chat user turns the dashed private-mode treatment. */
|
||||
temporary?: boolean;
|
||||
/** When false, hide this message's copy button. Default true. */
|
||||
@@ -260,6 +262,7 @@ function UserDeliveryStatus({
|
||||
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||
export function MessageBubble({
|
||||
message,
|
||||
isTurnStreaming = false,
|
||||
temporary = false,
|
||||
showCopyAction = true,
|
||||
cliApps = [],
|
||||
@@ -381,7 +384,8 @@ export function MessageBubble({
|
||||
: "";
|
||||
const automationTriggeredLabel = t("message.automationTriggered");
|
||||
|
||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||
const showAssistantActions =
|
||||
message.role === "assistant" && !message.isStreaming && !isTurnStreaming && !empty;
|
||||
const showCopyButton = showCopyAction && showAssistantActions;
|
||||
const showForkButton = showAssistantActions && !!onForkFromHere;
|
||||
const forkLabel = t("message.forkFromHere");
|
||||
|
||||
@@ -91,6 +91,9 @@ export function ThreadMessages({
|
||||
&& pendingTurn !== null
|
||||
&& !pendingTurn.hasVisibleOutput
|
||||
) ? pendingTurn : null;
|
||||
const currentTurnStartIndex = isStreaming
|
||||
? activeTurnStartIndex(units, activeTurnId)
|
||||
: units.length;
|
||||
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
|
||||
let nextUserIndex = hiddenUserMessageCount;
|
||||
|
||||
@@ -140,7 +143,15 @@ export function ThreadMessages({
|
||||
userPromptId={userPromptId}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
deferOffscreenRender={deferOffscreenRender}
|
||||
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||
isTurnStreaming={
|
||||
unit.type === "activity"
|
||||
? liveActivityClusterIndices.has(index)
|
||||
: isStreaming && (
|
||||
unit.message.turnId
|
||||
? unit.message.turnId === activeTurnId
|
||||
: index > currentTurnStartIndex
|
||||
)
|
||||
}
|
||||
forkIndex={forkIndex}
|
||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||
@@ -280,6 +291,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
) : (
|
||||
<MessageBubble
|
||||
message={unit.message}
|
||||
isTurnStreaming={isTurnStreaming}
|
||||
temporary={temporary}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
@@ -317,6 +329,27 @@ function threadDisplayUnitPropsEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function activeTurnStartIndex(units: DisplayUnit[], activeTurnId: string | null): number {
|
||||
if (activeTurnId) {
|
||||
const index = units.findIndex((unit) => (
|
||||
unit.type === "message"
|
||||
&& unit.message.role === "user"
|
||||
&& unit.message.deliveryStatus !== "failed"
|
||||
&& unit.message.turnId === activeTurnId
|
||||
));
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||
const unit = units[i];
|
||||
if (
|
||||
unit.type === "message"
|
||||
&& unit.message.role === "user"
|
||||
&& unit.message.deliveryStatus !== "failed"
|
||||
) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
||||
if (previous.type !== next.type) return false;
|
||||
if (previous.type === "message" && next.type === "message") {
|
||||
|
||||
@@ -216,17 +216,18 @@ function isStaleThreadSnapshot(
|
||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||
}
|
||||
|
||||
function latestActiveTurnId(messages: UIMessage[]): string | null {
|
||||
function latestActiveTurnId(messages: UIMessage[], runStartedAt: number | null): string | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.isStreaming && message.turnId) return message.turnId;
|
||||
}
|
||||
if (runStartedAt === null) return null;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message.role === "user"
|
||||
&& message.deliveryStatus !== "failed"
|
||||
message.role !== "user"
|
||||
&& message.turnId
|
||||
&& message.createdAt >= runStartedAt * 1000
|
||||
) return message.turnId;
|
||||
}
|
||||
return null;
|
||||
@@ -808,8 +809,8 @@ export function ThreadShell({
|
||||
const currentGoalState = messagesReady ? goalState : undefined;
|
||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||
const restoredViewportTurnId = useMemo(
|
||||
() => turnActive ? latestActiveTurnId(displayMessages) : null,
|
||||
[displayMessages, turnActive],
|
||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||
[currentRunStartedAt, displayMessages, turnActive],
|
||||
);
|
||||
const rememberedViewportTurnId = chatId
|
||||
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
|
||||
@@ -818,7 +819,10 @@ export function ThreadShell({
|
||||
? client.getRunTurnId(chatId)
|
||||
: null;
|
||||
const viewportTurnId = messagesReady && turnActive
|
||||
? canonicalRunTurnId ?? rememberedViewportTurnId ?? restoredViewportTurnId
|
||||
? canonicalRunTurnId
|
||||
?? rememberedViewportTurnId
|
||||
?? historyActiveTurnId
|
||||
?? restoredViewportTurnId
|
||||
: null;
|
||||
const activeTurnStartedHere =
|
||||
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
|
||||
|
||||
@@ -1055,6 +1055,122 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.getByText("final reply")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides current turn actions until turn_end", () => {
|
||||
const activeTurnId = "turn-2";
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "new question", turnId: activeTurnId, createdAt: 3 },
|
||||
{
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "first answer slice",
|
||||
turnId: activeTurnId,
|
||||
createdAt: 4,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
turnId: activeTurnId,
|
||||
createdAt: 5,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
role: "assistant",
|
||||
content: "second answer slice",
|
||||
turnId: activeTurnId,
|
||||
createdAt: 6,
|
||||
},
|
||||
];
|
||||
const props = { messages, onForkFromMessage: vi.fn() };
|
||||
const { container, rerender } = render(
|
||||
<ThreadMessages {...props} isStreaming activeTurnId={activeTurnId} />,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(1);
|
||||
|
||||
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />);
|
||||
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps active turn actions hidden across guidance and failed user rows", () => {
|
||||
const activeTurnId = "turn-active";
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "old-user", role: "user", content: "old question", turnId: "turn-old", createdAt: 1 },
|
||||
{ id: "old", role: "assistant", content: "old answer", turnId: "turn-old", createdAt: 2 },
|
||||
{ id: "active-user", role: "user", content: "new question", turnId: activeTurnId, createdAt: 3 },
|
||||
{ id: "live", role: "assistant", content: "live slice", createdAt: 4 },
|
||||
{ id: "guide", role: "user", content: "focus", turnId: "turn-guide", createdAt: 5 },
|
||||
{
|
||||
id: "failed",
|
||||
role: "user",
|
||||
content: "retry",
|
||||
turnId: "turn-failed",
|
||||
deliveryStatus: "failed",
|
||||
createdAt: 6,
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
<ThreadMessages
|
||||
messages={messages}
|
||||
isStreaming
|
||||
activeTurnId={activeTurnId}
|
||||
onForkFromMessage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("only hides the active assistant-only automation turn", () => {
|
||||
const { container } = render(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
{ id: "old", role: "assistant", content: "old answer", turnId: "turn-old", createdAt: 1 },
|
||||
{
|
||||
id: "automation",
|
||||
role: "assistant",
|
||||
content: "automation result",
|
||||
turnId: "turn-automation",
|
||||
createdAt: 2,
|
||||
},
|
||||
]}
|
||||
isStreaming
|
||||
activeTurnId="turn-automation"
|
||||
onForkFromMessage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to the latest user boundary for untagged active slices", () => {
|
||||
const { container } = render(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
{ id: "user", role: "user", content: "question", createdAt: 1 },
|
||||
{ id: "live", role: "assistant", content: "live slice", createdAt: 2 },
|
||||
]}
|
||||
isStreaming
|
||||
activeTurnId="turn-active"
|
||||
onForkFromMessage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-assistant-footer] [aria-label="Copy"]'))
|
||||
.not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-assistant-footer] [aria-label="Fork"]'))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copy on adjacent assistant text slices", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
|
||||
|
||||
@@ -471,6 +471,54 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByText("failed to read file")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides actions for a complete assistant-only message until turn_end", async () => {
|
||||
const client = makeClient();
|
||||
vi.mocked(fetch).mockImplementation(async (input) => (
|
||||
String(input).includes("websocket%3Aassistant-only-actions/webui-thread")
|
||||
? httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "assistant", content: "old automation", turnId: "turn-old" },
|
||||
]))
|
||||
: { ok: false, status: 404, json: async () => ({}) }
|
||||
) as Response);
|
||||
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("assistant-only-actions")}
|
||||
title="Assistant-only actions"
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1));
|
||||
const turnId = "turn-automation";
|
||||
const startedAt = Date.now() / 1000;
|
||||
act(() => client._emitChat("assistant-only-actions", {
|
||||
event: "goal_status",
|
||||
chat_id: "assistant-only-actions",
|
||||
status: "running",
|
||||
started_at: startedAt,
|
||||
turn_id: turnId,
|
||||
}));
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
|
||||
act(() => client._emitChat("assistant-only-actions", {
|
||||
event: "message",
|
||||
chat_id: "assistant-only-actions",
|
||||
text: "new automation",
|
||||
turn_id: turnId,
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText("new automation")).toBeInTheDocument());
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
|
||||
act(() => client._emitChat("assistant-only-actions", {
|
||||
event: "turn_end",
|
||||
chat_id: "assistant-only-actions",
|
||||
turn_id: turnId,
|
||||
}));
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2));
|
||||
});
|
||||
|
||||
it("does not navigate away when clicking the chat title", async () => {
|
||||
const client = makeClient();
|
||||
const onGoHome = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user