Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion webui/src/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 = [],
Expand Down Expand Up @@ -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");
Expand Down
35 changes: 34 additions & 1 deletion webui/src/components/thread/ThreadMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 && activeTurnId !== null
? unit.message.turnId === activeTurnId
: index > currentTurnStartIndex
)
}
forkIndex={forkIndex}
showForkBoundary={index === forkBoundaryAfterUnitIndex}
forkBoundaryLabel={t("thread.forkedFromHistory")}
Expand Down Expand Up @@ -280,6 +291,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
) : (
<MessageBubble
message={unit.message}
isTurnStreaming={isTurnStreaming}
temporary={temporary}
cliApps={cliApps}
mcpPresets={mcpPresets}
Expand Down Expand Up @@ -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") {
Expand Down
16 changes: 10 additions & 6 deletions webui/src/components/thread/ThreadShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
137 changes: 137 additions & 0 deletions webui/src/tests/thread-messages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,143 @@ 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("falls back to the latest user boundary while the active turn id is pending", () => {
const { container } = render(
<ThreadMessages
messages={[
{ id: "old-user", role: "user", content: "old question", turnId: "old", createdAt: 1 },
{ id: "old", role: "assistant", content: "old answer", turnId: "old", createdAt: 2 },
{ id: "new-user", role: "user", content: "new question", turnId: "new", createdAt: 3 },
{ id: "live", role: "assistant", content: "live slice", turnId: "new", createdAt: 4 },
]}
isStreaming
activeTurnId={null}
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("shows copy on adjacent assistant text slices", () => {
const messages: UIMessage[] = [
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
Expand Down
48 changes: 48 additions & 0 deletions webui/src/tests/thread-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading