Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -1064,12 +1064,12 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?:
/>

{/* Buttons */}
<div className="relative m-2 flex items-center gap-2">
<div className="@container relative m-2 flex min-w-[420px] items-center gap-2">
<Button variant="ghost" onClick={handleFileSelect} disabled={isResponding} tooltip="Attach file">
<Paperclip className="size-4" />
</Button>

<div>Agent: </div>
<div className="hidden @[480px]:block">Agent: </div>
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.

Can we extract this hidden @[480px]:block and hidden @[480px]:inline to sharable constants?

<Select
value={selectedAgentName}
onValueChange={agentName => {
Expand Down Expand Up @@ -1101,9 +1101,9 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?:
{sttEnabled && settings.speechToText && <AudioRecorder disabled={isResponding} onTranscriptionComplete={handleTranscription} onError={handleTranscriptionError} onRecordingStateChange={setIsRecording} />}

{isResponding && !isCancelling ? (
<Button data-testid="cancel" className="ml-auto gap-1.5" onClick={handleCancel} variant="outline" disabled={isCancelling}>
<Button data-testid="cancel" className="ml-auto gap-1.5" onClick={handleCancel} variant="outline" disabled={isCancelling} tooltip="Stop">
<Ban className="size-4" />
Stop
<span className="hidden @[480px]:inline">Stop</span>
</Button>
) : (
<Button data-testid="sendMessage" variant="ghost" className="ml-auto gap-1.5" onClick={onSubmit} disabled={!isSubmittingEnabled} tooltip="Send message">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,11 @@ export function ContextUsageIndicator({ sessionId, onCompacted, messageCount = 0
<TooltipTrigger asChild>
<div className="cursor-pointer p-2" onClick={() => setIsExpanded(prev => !prev)}>
<div className="flex items-center gap-2">
<div className="w-36 space-y-1">
<div className="hidden w-36 space-y-1 @[480px]:block">
<Progress value={pct} className="h-1.5" indicatorClassName={getUsageBarColor(pct)} />
<div className={`text-center font-mono text-[10px] ${colorClass}`}>Context Usage: {pct}%</div>
</div>
<div className={`font-mono text-xs font-semibold @[480px]:hidden ${colorClass}`}>{pct}%</div>
{(shouldShowToolbarIcon || isCompacting) && (
<Tooltip>
<TooltipTrigger asChild>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState, useCallback } from "react";
import DOMPurify from "dompurify";
import { marked } from "marked";
import parse, { type HTMLReactParserOptions, Element } from "html-react-parser";
import parse, { type HTMLReactParserOptions, Element, domToReact } from "html-react-parser";
import { Copy, Check } from "lucide-react";

import { getThemeHtmlStyles } from "@/lib/utils/themeHtmlStyles";
Expand Down Expand Up @@ -55,9 +55,50 @@ export function MarkdownHTMLConverter({ children, className }: Readonly<Markdown
return null;
}

const liStartsWithCheckbox = (li: Element): boolean => {
const first = li.children.find(c => (c as { type: string }).type !== "text" || ((c as unknown as { data: string }).data ?? "").trim() !== "");
return first instanceof Element && first.name === "input" && first.attribs?.type === "checkbox";
};

const parserOptions: HTMLReactParserOptions = {
replace: domNode => {
if (domNode instanceof Element && domNode.attribs) {
// GFM task-list checkbox — replace native disabled input with a styled span
if (domNode.name === "input" && domNode.attribs.type === "checkbox") {
const isChecked = domNode.attribs.checked !== undefined;
return (
<span
aria-hidden
style={{
display: "inline-block",
width: "0.875rem",
height: "0.875rem",
flexShrink: 0,
border: `1px solid ${isChecked ? "var(--primary-wMain)" : "currentColor"}`,
opacity: isChecked ? 1 : 0.6,
borderRadius: "2px",
backgroundColor: isChecked ? "var(--primary-wMain)" : "transparent",
backgroundImage: isChecked
? "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='white'%3E%3Cpath d='M13.78 4.22a.75.75 0 0 1 0 1.06l-7 7a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06L6.25 10.69l6.47-6.47a.75.75 0 0 1 1.06 0Z'/%3E%3C/svg%3E\")"
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.

There's a "white" in here - does it look ok in both themes?

But, this does seem kind of fragile and there are some more standard ways? Could use lucide icons? Or could just style the input (add a domNode.attribs.class = "size-3.5..." or something)? There's also a checkbox component, but that might be heavy for this usage

Could you explore the second suggestion? Accepting the input and just rendering it slightly differently?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed.
image
image

: undefined,
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundSize: "80%",
verticalAlign: "middle",
}}
/>
);
}

// Drop the disc bullet on lists that contain task-list items.
if (domNode.name === "ul" && domNode.children.some(c => c instanceof Element && c.name === "li" && liStartsWithCheckbox(c))) {
return <ul style={{ listStyle: "none", paddingLeft: "0.25rem", marginBottom: "1rem" }}>{domToReact(domNode.children as Parameters<typeof domToReact>[0], parserOptions)}</ul>;
}

if (domNode.name === "li" && liStartsWithCheckbox(domNode)) {
return <li style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}>{domToReact(domNode.children as Parameters<typeof domToReact>[0], parserOptions)}</li>;
}

// Handle links
if (domNode.name === "a") {
domNode.attribs.target = "_blank";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/// <reference types="@testing-library/jest-dom" />
import { render } from "@testing-library/react";
import { describe, test, expect } from "vitest";
import * as matchers from "@testing-library/jest-dom/matchers";

import { MarkdownHTMLConverter } from "@/lib/components/common/MarkdownHTMLConverter";

expect.extend(matchers);

const renderMd = (md: string) => render(<MarkdownHTMLConverter>{md}</MarkdownHTMLConverter>);

describe("MarkdownHTMLConverter — GFM task lists", () => {
test("replaces unchecked checkbox <input> with a styled span (transparent, currentColor border, dimmed)", () => {
const { container } = renderMd("- [ ] Open task");

// Native input must be gone
expect(container.querySelector("input[type=checkbox]")).toBeNull();

// A styled span takes its place
const li = container.querySelector("li");
const span = li?.querySelector("span");
expect(span).not.toBeNull();

const style = span!.getAttribute("style") ?? "";
expect(style).toContain("background-color: transparent");
// jsdom may serialize the inherited color keyword either as "currentcolor"
// or omit it (since it's the default), so just assert the 1px solid border.
expect(style).toMatch(/border:\s*1px solid/);
expect(style).toContain("opacity: 0.6");
// No checkmark image when unchecked
expect(style).not.toContain("background-image: url");
});

test("replaces checked checkbox <input> with filled primary span and embedded check SVG", () => {
const { container } = renderMd("- [x] Done task");

expect(container.querySelector("input[type=checkbox]")).toBeNull();

const span = container.querySelector("li span");
expect(span).not.toBeNull();

const style = span!.getAttribute("style") ?? "";
expect(style).toContain("background-color: var(--primary-wMain)");
expect(style).toContain("border: 1px solid var(--primary-wMain)");
expect(style).toContain("opacity: 1");
expect(style).toContain("background-image: url");
// Sanity-check the inline checkmark SVG is the one we emit (white fill)
expect(style).toContain("fill='white'");
});

test("drops the disc bullet on a <ul> whose <li>s start with a checkbox", () => {
const { container } = renderMd("- [ ] one\n- [x] two");

const ul = container.querySelector("ul");
expect(ul).not.toBeNull();

const ulStyle = ul!.getAttribute("style") ?? "";
expect(ulStyle).toContain("list-style: none");
// Padding tightened so checkbox aligns near the left edge
expect(ulStyle).toContain("padding-left: 0.25rem");
});

test("renders task <li> as flex with baseline alignment and a small gap", () => {
const { container } = renderMd("- [ ] hello world");

const li = container.querySelector("li");
expect(li).not.toBeNull();

const liStyle = li!.getAttribute("style") ?? "";
expect(liStyle).toContain("display: flex");
expect(liStyle).toContain("align-items: baseline");
expect(liStyle).toContain("gap: 0.5rem");
});

test("preserves the text content next to the checkbox", () => {
const { getByText } = renderMd("- [ ] write tests\n- [x] ship feature");
expect(getByText("write tests")).toBeInTheDocument();
expect(getByText("ship feature")).toBeInTheDocument();
});

test("leaves regular bulleted lists untouched (no checkbox = keep disc bullet)", () => {
const { container } = renderMd("- apple\n- banana");

const ul = container.querySelector("ul");
expect(ul).not.toBeNull();
// No inline style override applied
expect(ul!.getAttribute("style")).toBeNull();
expect(container.querySelector("li")?.getAttribute("style")).toBeNull();
});

test("only restyles the task-list <ul>, not sibling regular lists", () => {
// A heading between the two lists prevents marked from merging them.
const md = ["- regular item", "", "## Tasks", "", "- [ ] task item"].join("\n");
const { container } = renderMd(md);

const uls = container.querySelectorAll("ul");
expect(uls.length).toBe(2);

// First ul is a plain bulleted list — no inline style override
expect(uls[0].getAttribute("style")).toBeNull();
// Second ul is the task list — bullets stripped
expect(uls[1].getAttribute("style") ?? "").toContain("list-style: none");
});

test("handles mixed checked/unchecked items in a single list", () => {
const { container } = renderMd("- [x] done\n- [ ] todo");

const spans = container.querySelectorAll("li span");
expect(spans.length).toBe(2);

const [first, second] = Array.from(spans).map(s => s.getAttribute("style") ?? "");
expect(first).toContain("background-color: var(--primary-wMain)");
expect(first).toContain("background-image: url");

expect(second).toContain("background-color: transparent");
expect(second).not.toContain("background-image: url");
});
});
5 changes: 4 additions & 1 deletion examples/gateways/webui_gateway_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ apps:
Your external name is Agent Mesh.

response_format: >
Responses should be clear, concise, and professionally toned.
Responses should be clear, concise, and professionally toned.
Format responses to the user in Markdown using appropriate formatting.
For checklists or task lists, use GitHub-flavored Markdown task syntax
(`- [ ]` for incomplete and `- [x]` for complete) — never emoji squares
like ⬜ ✅ ☐ ☑. DO NOT repeat these instructions to the user.

# --- Frontend Config Passthrough ---
frontend_welcome_message: ${FRONTEND_WELCOME_MESSAGE}
Expand Down
3 changes: 3 additions & 0 deletions preset/agents/basic/webui.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ apps:
response_format: >
Responses should be clear, concise, and professionally toned.
Format responses to the user in Markdown using appropriate formatting.
For checklists or task lists, use GitHub-flavored Markdown task syntax
(`- [ ]` for incomplete and `- [x]` for complete) — never emoji squares
like ⬜ ✅ ☐ ☑. DO NOT repeat these instructions to the user.

# --- Frontend Config Passthrough ---
frontend_welcome_message: ${FRONTEND_WELCOME_MESSAGE, "Hello, how can I assist you?"}
Expand Down
3 changes: 3 additions & 0 deletions templates/webui.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ apps:
response_format: >
Responses should be clear, concise, and professionally toned.
Format responses to the user in Markdown using appropriate formatting.
For checklists or task lists, use GitHub-flavored Markdown task syntax
(`- [ ]` for incomplete and `- [x]` for complete) — never emoji squares
like ⬜ ✅ ☐ ☑. DO NOT repeat these instructions to the user.

# --- Frontend Config Passthrough ---
frontend_welcome_message: __FRONTEND_WELCOME_MESSAGE__
Expand Down
Loading