Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4526d99
fix(webui-v2): avoid duplicate logs header during chat runs
italic-jinxin Jul 1, 2026
34d247c
fix(webui-v2): hide chat run logs shortcut
italic-jinxin Jul 1, 2026
adf256e
test(webui-v2): remove redundant chat logs assertion
italic-jinxin Jul 1, 2026
c0ba4ab
Merge branch 'main' into fix-chat-duplicate-logs-header
italic-jinxin Jul 1, 2026
c8101d7
fix(webui-v2): move chat logs link into message actions
italic-jinxin Jul 1, 2026
3d9d9d1
fix(webui-v2): float chat logs shortcut
italic-jinxin Jul 1, 2026
369f7f7
fix(webui-v2): use terminal icon for chat logs shortcut
italic-jinxin Jul 1, 2026
080216b
fix(webui-v2): reserve chat logs space with spacer
italic-jinxin Jul 1, 2026
e98997d
style(webui-v2): soften floating logs shortcut
italic-jinxin Jul 1, 2026
e873abc
style(webui-v2): increase chat logs shortcut contrast
italic-jinxin Jul 1, 2026
0951ada
Merge branch 'main' into fix-chat-duplicate-logs-header
italic-jinxin Jul 1, 2026
40a2a25
Merge branch 'main' into fix-chat-duplicate-logs-header
italic-jinxin Jul 1, 2026
6742cfc
fix(webui-v2): reuse scoped logs path builder
italic-jinxin Jul 1, 2026
ed044ce
test(e2e): handle busy reborn sends before settling
italic-jinxin Jul 1, 2026
b116168
fix(webui-v2): keep chat logs route construction in chat
italic-jinxin Jul 1, 2026
e2c65b6
Merge branch 'main' into fix-chat-duplicate-logs-header
italic-jinxin Jul 1, 2026
0d27c42
Merge branch 'main' into fix-chat-duplicate-logs-header
italic-jinxin Jul 1, 2026
ddb407f
test(e2e): retry transient submit errors while settling
italic-jinxin Jul 1, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const paths = {
d="M4.5 6.5h.1M4.5 12h.1M4.5 17.5h.1"
/>`,

logs: html`<path d="M4.5 5.5h15v13h-15v-13Z" /><path
d="m7.5 10 2 2-2 2"
/><path d="M11.5 14h4.5" />`,

lock: html`<path d="M7.5 10V7.2a4.5 4.5 0 0 1 9 0V10" /><path
d="M5.5 10h13v10.5h-13V10Z"
/><path d="M12 14.4v2.3" />`,
Expand Down
26 changes: 2 additions & 24 deletions crates/ironclaw_webui_v2_static/static/js/pages/chat/chat.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { React, html } from "../../lib/html.js";
import { Link } from "react-router";
import { useT } from "../../lib/i18n.js";
import { Icon } from "../../design-system/icons.js";
import {
THREAD_STATE,
clearThreadState,
Expand Down Expand Up @@ -103,22 +101,14 @@ export function Chat({
// Scope the persisted composer draft to the open thread (or the
// shared new-conversation slot when there's no active thread yet).
const composerDraftKey = activeThreadId || NEW_DRAFT_KEY;
const logsPath = activeThreadId ? buildScopedLogsPath({ threadId: activeThreadId }) : null;
const canCancelRun = Boolean(
activeThreadId &&
activeRun?.runId &&
activeRun.threadId === activeThreadId &&
activeThreadIsProcessing &&
!activeThreadHasGate
);
const activeRunLogsPath =
activeThreadId &&
activeRun?.runId &&
activeRun.threadId === activeThreadId
? buildScopedLogsPath(
{ threadId: activeThreadId, runId: activeRun.runId },
{ absolute: true },
)
: null;
const handleSend = React.useCallback(
async (content, { images = [], attachments = [], displayContent } = {}) => {
if (activeThreadHasGate) {
Expand Down Expand Up @@ -231,19 +221,6 @@ export function Chat({
<div className="flex min-w-0 flex-1 flex-col">
<${ConnectionStatus} status=${sseStatus} />

${isProcessing && !pendingGate && activeRunLogsPath && html`
<div className="flex justify-end border-b border-[var(--v2-panel-border)] bg-[var(--v2-canvas-strong)] px-4 py-1.5">
<${Link}
to=${activeRunLogsPath}
className="inline-flex h-8 items-center gap-1.5 rounded-[8px] px-2.5 text-xs font-semibold text-[var(--v2-text-muted)] hover:bg-[var(--v2-surface-muted)] hover:text-[var(--v2-text-strong)]"
title=${t("nav.logs")}
>
<${Icon} name="list" className="h-3.5 w-3.5" />
${t("nav.logs")}
<//>
</div>
`}

${historyLoadError &&
html`
<div
Expand Down Expand Up @@ -279,6 +256,7 @@ export function Chat({
onLoadMore=${loadMore}
onRetryMessage=${retryMessage}
threadId=${activeThreadId}
logsPath=${logsPath}
pending=${activeThreadIsProcessing}
>
${recoveryNotice &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { React, html } from "../../../lib/html.js";
import { Link } from "react-router";
import { useT } from "../../../lib/i18n.js";
import { ActivityRun } from "./activity-run.js";
import { MessageBubble } from "./message-bubble.js";
Expand All @@ -7,6 +8,8 @@ import { groupMessages } from "../lib/message-groups.js";

export const BOTTOM_FOLLOW_THRESHOLD_PX = 100;
const TOP_LOAD_THRESHOLD_PX = 100;
const FLOATING_LOGS_BUTTON_CLASS =

@think-in-universe think-in-universe Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this floating logs button at right bottom corner?

image

@italic-jinxin italic-jinxin Jul 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there were duplicate logs at the top before. That was because the Logs button that only appears during chat was linking to the thread log. Moved it to the bottom and changed it into a floating button.

Another option is to show the Log button under each message, together with the Copy button.

"group absolute bottom-5 right-5 inline-flex size-9 items-center justify-center gap-0 overflow-hidden rounded-full border border-[color-mix(in_srgb,var(--v2-accent)_28%,var(--v2-panel-border))] bg-[color-mix(in_srgb,var(--v2-surface)_88%,var(--v2-accent)_12%)] text-xs font-semibold text-[var(--v2-text-base)] shadow-[0_14px_34px_-18px_rgba(0,0,0,0.95),0_0_0_1px_rgba(255,255,255,0.04)] backdrop-blur-md transition-all hover:border-[color-mix(in_srgb,var(--v2-accent)_50%,var(--v2-panel-border))] hover:bg-[color-mix(in_srgb,var(--v2-surface-muted)_82%,var(--v2-accent)_18%)] hover:text-[var(--v2-text-strong)] focus:outline-none focus:ring-2 focus:ring-[color-mix(in_srgb,var(--v2-accent)_42%,transparent)]";

export function distanceFromBottom(el) {
if (!el) return Number.POSITIVE_INFINITY;
Expand Down Expand Up @@ -44,6 +47,7 @@ export function MessageList({
onLoadMore,
onRetryMessage,
threadId,
logsPath,
pending = false,
children,
}) {
Expand Down Expand Up @@ -252,8 +256,20 @@ export function MessageList({
/>`
)}
${children}
${logsPath && html`<div aria-hidden="true" className="h-14 shrink-0" />`}
</div>
</div>
${logsPath &&
html`
<${Link}
to=${logsPath}
aria-label=${t("nav.logs")}
title=${t("nav.logs")}
className=${FLOATING_LOGS_BUTTON_CLASS}
>
<${Icon} name="logs" className="size-5" />
<//>
`}
${!atBottom &&
html`
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,41 @@ test("MessageList observes content growth from streamed markdown layout", () =>
"resize-driven follow should still respect intentional user scrollback",
);
});

test("MessageList renders a floating thread logs shortcut", () => {
assert.match(
messageListSource,
/import \{ Link \} from "react-router";/,
"thread logs shortcut should use React Router navigation",
);
assert.doesNotMatch(
messageListSource,
/buildScopedLogsPath/,
"message-list should receive a logsPath prop instead of building routes",
);
assert.match(
messageListSource,
/logsPath,/,
"message-list should accept a prebuilt thread logs route",
);
assert.match(
messageListSource,
/className="flex min-w-0 flex-1 overflow-y-auto px-4 pt-6 pb-14 sm:px-5 lg:px-8"/,
"scroll area should keep its normal bottom padding",
);
assert.match(
messageListSource,
/\$\{logsPath && html`<div aria-hidden="true" className="h-14 shrink-0" \/>`\}/,
"floating logs control should reserve space with an end-of-content spacer",
);
assert.match(
messageListSource,
/const FLOATING_LOGS_BUTTON_CLASS =[\s\S]*group absolute bottom-5 right-5[\s\S]*border-\[color-mix\(in_srgb,var\(--v2-accent\)_28%,var\(--v2-panel-border\)\)\][\s\S]*bg-\[color-mix\(in_srgb,var\(--v2-surface\)_88%,var\(--v2-accent\)_12%\)\]/,
"floating logs button classes should live in a module-level constant",
);
assert.match(
messageListSource,
/<\$\{Link\}\s+to=\$\{logsPath\}[\s\S]*className=\$\{FLOATING_LOGS_BUTTON_CLASS\}[\s\S]*<\$\{Icon\} name="logs"/,
"thread logs shortcut should render as a visible bottom-right icon button",
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ function renderChat({ hookState, activeThreadId = "thread-1" }) {
ChatInput() {},
ConnectionStatus() {},
EmptyState() {},
Icon() {},
KeyboardShortcuts() {},
Link() {},
MessageList() {},
Expand All @@ -84,17 +83,8 @@ function renderChat({ hookState, activeThreadId = "thread-1" }) {
},
NEW_DRAFT_KEY: "new",
THREAD_STATE: { NEEDS_ATTENTION: "needs_attention", RUNNING: "running" },
buildScopedLogsPath: (
{ threadId, runId } = {},
{ absolute = false } = {},
) => {
const params = [];
if (threadId) params.push(`thread_id=${encodeURIComponent(threadId)}`);
if (runId) params.push(`run_id=${encodeURIComponent(runId)}`);
const query = params.length > 0 ? `?${params.join("&")}` : "";
return `${absolute ? "/v2" : ""}/logs${query}`;
},
buildRuntimeContext: () => ({}),
buildScopedLogsPath: ({ threadId }) => `/logs?thread_id=${threadId}`,
clearThreadState: () => {},
globalThis: {},
html: (strings, ...values) => ({ strings: Array.from(strings), values }),
Expand Down Expand Up @@ -367,7 +357,7 @@ test("Chat renders a timeline load failure as an alert instead of the empty land
assert.equal(findComponent(tree, components.EmptyState), null);
});

test("Chat links to scoped logs for the active thread run", () => {
test("Chat does not render a top-level logs header for the active thread run", () => {
const { tree, components } = renderChat({
hookState: {
messages: [{ id: "message-1" }],
Expand All @@ -391,19 +381,25 @@ test("Chat links to scoped logs for the active thread run", () => {
},
});

const logsLink = findComponent(tree, components.Link);
assert.ok(logsLink, "active chat should render a scoped logs link");
assert.equal(
componentProps(logsLink, components.Link).to,
"/v2/logs?thread_id=thread-1&run_id=run-1",
findComponent(tree, components.Link),
null,
"active chat should not render an extra run logs router link outside message actions",
);
assert.ok(logsLink.values.includes("nav.logs"));

const messageList = findComponent(tree, components.MessageList);
assert.equal(
findComponent(messageList, components.Link),
componentProps(messageList, components.MessageList).logsPath,
"/logs?thread_id=thread-1",
"chat should pass a prebuilt thread-scoped logs path down to MessageList",
);
assert.equal(
findNode(tree, (node) =>
node.strings.some((part) =>
part.includes("justify-end border-b border-[var(--v2-panel-border)]")
)
),
null,
"active run logs link should not render in the message list footer near the composer",
"active run logs link should not render as a duplicate top header bar",
);
});

Expand Down
46 changes: 42 additions & 4 deletions tests/e2e/reborn_webui_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
YOLO_PROFILE = "local-dev-yolo"
DEFAULT_MODEL = "mock-model"
VISION_MODEL = "gpt-4o"
ACCEPTED_SEND_OUTCOMES = {"submitted", "already_submitted"}


def find_free_port() -> int:
Expand Down Expand Up @@ -383,15 +384,27 @@ async def create_thread(client: httpx.AsyncClient, base_url: str) -> str:
return response.json()["thread"]["thread_id"]


async def send_message(
async def _submit_message(
client: httpx.AsyncClient, base_url: str, thread_id: str, content: str
) -> None:
) -> dict:
response = await client.post(
f"{base_url}/api/webchat/v2/threads/{thread_id}/messages",
json={"client_action_id": client_action_id(), "content": content},
timeout=30,
)
assert response.status_code in (200, 202), response.text
return response.json()


async def send_message(
client: httpx.AsyncClient, base_url: str, thread_id: str, content: str
) -> dict:
body = await _submit_message(client, base_url, thread_id, content)
outcome = body.get("outcome")
assert outcome in ACCEPTED_SEND_OUTCOMES, (
f"Message was not accepted for a run; outcome={outcome!r}, body={body}"
)
return body


async def fetch_timeline(client: httpx.AsyncClient, base_url: str, thread_id: str) -> dict:
Expand Down Expand Up @@ -453,7 +466,31 @@ async def send_and_settle(
expected: int,
) -> None:
"""Send a text turn and wait until ``expected`` assistant replies finalize."""
await send_message(client, base_url, thread_id, content)
submit_body: dict = {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to change this function?

@italic-jinxin italic-jinxin Jul 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was changed because the E2E tests were failing.

now wrapped with try catch block,

code block

last_submit_error = None
for _ in range(12):
try:
submit_body = await _submit_message(client, base_url, thread_id, content)
last_submit_error = None
except httpx.HTTPError as error:
last_submit_error = error
await asyncio.sleep(0.5)
continue
outcome = submit_body.get("outcome")
if outcome in ACCEPTED_SEND_OUTCOMES:
break
if outcome == "rejected_busy":
await asyncio.sleep(0.5)
continue
raise AssertionError(
f"Message was not accepted for a run; outcome={outcome!r}, body={submit_body}"
)
else:
raise AssertionError(
f"Thread {thread_id} remained busy before accepting a new turn; "
f"last submit response: {submit_body}; last submit error: {last_submit_error!r}"
)

Comment thread
italic-jinxin marked this conversation as resolved.
for _ in range(90):
try:
timeline = await fetch_timeline(client, base_url, thread_id)
Expand All @@ -464,5 +501,6 @@ async def send_and_settle(
return
await asyncio.sleep(0.5)
raise AssertionError(
f"Thread {thread_id} did not reach {expected} finalized assistant replies"
f"Thread {thread_id} did not reach {expected} finalized assistant replies; "
f"submit response: {submit_body}"
)
Loading