mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 01:01:52 +03:00
fix(webui): hide actions until turn end
This commit is contained in:
@@ -52,6 +52,8 @@ import type {
|
|||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
|
/** The containing agent turn has not received turn_end yet. */
|
||||||
|
isTurnStreaming?: boolean;
|
||||||
/** Give temporary-chat user turns the dashed private-mode treatment. */
|
/** Give temporary-chat user turns the dashed private-mode treatment. */
|
||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
/** When false, hide this message's copy button. Default true. */
|
/** 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. */
|
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
|
isTurnStreaming = false,
|
||||||
temporary = false,
|
temporary = false,
|
||||||
showCopyAction = true,
|
showCopyAction = true,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
@@ -381,7 +384,8 @@ export function MessageBubble({
|
|||||||
: "";
|
: "";
|
||||||
const automationTriggeredLabel = t("message.automationTriggered");
|
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 showCopyButton = showCopyAction && showAssistantActions;
|
||||||
const showForkButton = showAssistantActions && !!onForkFromHere;
|
const showForkButton = showAssistantActions && !!onForkFromHere;
|
||||||
const forkLabel = t("message.forkFromHere");
|
const forkLabel = t("message.forkFromHere");
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export function ThreadMessages({
|
|||||||
&& pendingTurn !== null
|
&& pendingTurn !== null
|
||||||
&& !pendingTurn.hasVisibleOutput
|
&& !pendingTurn.hasVisibleOutput
|
||||||
) ? pendingTurn : null;
|
) ? pendingTurn : null;
|
||||||
|
const currentTurnStartIndex = isStreaming
|
||||||
|
? activeTurnStartIndex(units, activeTurnId)
|
||||||
|
: units.length;
|
||||||
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
|
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
|
||||||
let nextUserIndex = hiddenUserMessageCount;
|
let nextUserIndex = hiddenUserMessageCount;
|
||||||
|
|
||||||
@@ -140,7 +143,15 @@ export function ThreadMessages({
|
|||||||
userPromptId={userPromptId}
|
userPromptId={userPromptId}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
deferOffscreenRender={deferOffscreenRender}
|
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}
|
forkIndex={forkIndex}
|
||||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||||
@@ -280,6 +291,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
message={unit.message}
|
message={unit.message}
|
||||||
|
isTurnStreaming={isTurnStreaming}
|
||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
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 {
|
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
||||||
if (previous.type !== next.type) return false;
|
if (previous.type !== next.type) return false;
|
||||||
if (previous.type === "message" && next.type === "message") {
|
if (previous.type === "message" && next.type === "message") {
|
||||||
|
|||||||
@@ -216,17 +216,18 @@ function isStaleThreadSnapshot(
|
|||||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
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) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
if (message.isStreaming && message.turnId) return message.turnId;
|
if (message.isStreaming && message.turnId) return message.turnId;
|
||||||
}
|
}
|
||||||
|
if (runStartedAt === null) return null;
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
if (
|
if (
|
||||||
message.role === "user"
|
message.role !== "user"
|
||||||
&& message.deliveryStatus !== "failed"
|
|
||||||
&& message.turnId
|
&& message.turnId
|
||||||
|
&& message.createdAt >= runStartedAt * 1000
|
||||||
) return message.turnId;
|
) return message.turnId;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -808,8 +809,8 @@ export function ThreadShell({
|
|||||||
const currentGoalState = messagesReady ? goalState : undefined;
|
const currentGoalState = messagesReady ? goalState : undefined;
|
||||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||||
const restoredViewportTurnId = useMemo(
|
const restoredViewportTurnId = useMemo(
|
||||||
() => turnActive ? latestActiveTurnId(displayMessages) : null,
|
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||||
[displayMessages, turnActive],
|
[currentRunStartedAt, displayMessages, turnActive],
|
||||||
);
|
);
|
||||||
const rememberedViewportTurnId = chatId
|
const rememberedViewportTurnId = chatId
|
||||||
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
|
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
|
||||||
@@ -818,7 +819,10 @@ export function ThreadShell({
|
|||||||
? client.getRunTurnId(chatId)
|
? client.getRunTurnId(chatId)
|
||||||
: null;
|
: null;
|
||||||
const viewportTurnId = messagesReady && turnActive
|
const viewportTurnId = messagesReady && turnActive
|
||||||
? canonicalRunTurnId ?? rememberedViewportTurnId ?? restoredViewportTurnId
|
? canonicalRunTurnId
|
||||||
|
?? rememberedViewportTurnId
|
||||||
|
?? historyActiveTurnId
|
||||||
|
?? restoredViewportTurnId
|
||||||
: null;
|
: null;
|
||||||
const activeTurnStartedHere =
|
const activeTurnStartedHere =
|
||||||
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
|
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
|
||||||
|
|||||||
@@ -1055,6 +1055,122 @@ describe("ThreadMessages", () => {
|
|||||||
expect(screen.getByText("final reply")).toBeInTheDocument();
|
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", () => {
|
it("shows copy on adjacent assistant text slices", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
|
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
|
||||||
|
|||||||
@@ -471,6 +471,54 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.queryByText("failed to read file")).not.toBeInTheDocument();
|
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 () => {
|
it("does not navigate away when clicking the chat title", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const onGoHome = vi.fn();
|
const onGoHome = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user