Merge pull request #1292 from bradleypriest/pr/chat-thinking-tool
ui(chat): separate tool/thinking output and add toggle
This commit is contained in:
@@ -84,6 +84,11 @@
|
||||
color: rgba(150, 150, 150, 1);
|
||||
}
|
||||
|
||||
.chat-avatar.tool {
|
||||
background: rgba(134, 142, 150, 0.2);
|
||||
color: rgba(134, 142, 150, 1);
|
||||
}
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
display: inline-block;
|
||||
|
||||
@@ -12,10 +12,16 @@
|
||||
}
|
||||
|
||||
.chat-line.assistant,
|
||||
.chat-line.other {
|
||||
.chat-line.other,
|
||||
.chat-line.tool {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-line.tool .chat-bubble {
|
||||
border-style: dashed;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
CHAT TEXT STYLING
|
||||
============================================= */
|
||||
|
||||
.chat-thinking {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-thinking {
|
||||
border-color: rgba(16, 24, 40, 0.18);
|
||||
background: rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
|
||||
.chat-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
@@ -92,6 +92,18 @@ export function renderChatControls(state: AppViewState) {
|
||||
${refreshIcon}
|
||||
</button>
|
||||
<span class="chat-controls__separator">|</span>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${state.settings.chatShowThinking ? "active" : ""}"
|
||||
@click=${() =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatShowThinking: !state.settings.chatShowThinking,
|
||||
})}
|
||||
aria-pressed=${state.settings.chatShowThinking}
|
||||
title="Toggle assistant thinking/working output"
|
||||
>
|
||||
🧠
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
|
||||
@click=${() =>
|
||||
|
||||
@@ -382,6 +382,7 @@ export function renderApp(state: AppViewState) {
|
||||
void loadChatHistory(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking: state.settings.chatShowThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
messages: state.chatMessages,
|
||||
|
||||
@@ -106,8 +106,22 @@ export function renderMessageGroup(
|
||||
|
||||
function renderAvatar(role: string) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const initial = normalized === "user" ? "U" : normalized === "assistant" ? "A" : "?";
|
||||
const className = normalized === "user" ? "user" : normalized === "assistant" ? "assistant" : "other";
|
||||
const initial =
|
||||
normalized === "user"
|
||||
? "U"
|
||||
: normalized === "assistant"
|
||||
? "A"
|
||||
: normalized === "tool"
|
||||
? "⚙"
|
||||
: "?";
|
||||
const className =
|
||||
normalized === "user"
|
||||
? "user"
|
||||
: normalized === "assistant"
|
||||
? "assistant"
|
||||
: normalized === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
@@ -132,11 +146,10 @@ function renderGroupedMessage(
|
||||
const extractedThinking =
|
||||
opts.showReasoning && role === "assistant" ? extractThinking(message) : null;
|
||||
const markdownBase = extractedText?.trim() ? extractedText : null;
|
||||
const markdown = extractedThinking
|
||||
? [formatReasoningMarkdown(extractedThinking), markdownBase]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: markdownBase;
|
||||
const reasoningMarkdown = extractedThinking
|
||||
? formatReasoningMarkdown(extractedThinking)
|
||||
: null;
|
||||
const markdown = markdownBase;
|
||||
|
||||
const bubbleClasses = [
|
||||
"chat-bubble",
|
||||
@@ -156,6 +169,11 @@ function renderGroupedMessage(
|
||||
|
||||
return html`
|
||||
<div class="${bubbleClasses}">
|
||||
${reasoningMarkdown
|
||||
? html`<div class="chat-thinking">${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||
)}</div>`
|
||||
: nothing}
|
||||
${markdown
|
||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing}
|
||||
|
||||
@@ -64,11 +64,10 @@ export function renderMessage(
|
||||
display?.kind === "json"
|
||||
? ["```json", display.value, "```"].join("\n")
|
||||
: (display?.value ?? null);
|
||||
const markdown = extractedThinking
|
||||
? [formatReasoningMarkdown(extractedThinking), markdownBase]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: markdownBase;
|
||||
const reasoningMarkdown = extractedThinking
|
||||
? formatReasoningMarkdown(extractedThinking)
|
||||
: null;
|
||||
const markdown = markdownBase;
|
||||
|
||||
const timestamp =
|
||||
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
||||
@@ -79,13 +78,17 @@ export function renderMessage(
|
||||
? "assistant"
|
||||
: normalizedRole === "user"
|
||||
? "user"
|
||||
: "other";
|
||||
: normalizedRole === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
const who =
|
||||
normalizedRole === "assistant"
|
||||
? "Assistant"
|
||||
: normalizedRole === "user"
|
||||
? "You"
|
||||
: normalizedRole;
|
||||
: normalizedRole === "tool"
|
||||
? "Working"
|
||||
: normalizedRole;
|
||||
|
||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||
const toolCardBase =
|
||||
@@ -98,6 +101,11 @@ export function renderMessage(
|
||||
<div class="chat-line ${klass}">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
|
||||
${reasoningMarkdown
|
||||
? html`<div class="chat-thinking">${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||
)}</div>`
|
||||
: nothing}
|
||||
${markdown
|
||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing}
|
||||
|
||||
@@ -103,25 +103,25 @@ describe("message-normalizer", () => {
|
||||
});
|
||||
|
||||
describe("normalizeRoleForGrouping", () => {
|
||||
it("returns assistant for toolresult", () => {
|
||||
expect(normalizeRoleForGrouping("toolresult")).toBe("assistant");
|
||||
expect(normalizeRoleForGrouping("toolResult")).toBe("assistant");
|
||||
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("assistant");
|
||||
it("returns tool for toolresult", () => {
|
||||
expect(normalizeRoleForGrouping("toolresult")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("toolResult")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns assistant for tool_result", () => {
|
||||
expect(normalizeRoleForGrouping("tool_result")).toBe("assistant");
|
||||
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("assistant");
|
||||
it("returns tool for tool_result", () => {
|
||||
expect(normalizeRoleForGrouping("tool_result")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns assistant for tool", () => {
|
||||
expect(normalizeRoleForGrouping("tool")).toBe("assistant");
|
||||
expect(normalizeRoleForGrouping("Tool")).toBe("assistant");
|
||||
it("returns tool for tool", () => {
|
||||
expect(normalizeRoleForGrouping("tool")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("Tool")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns assistant for function", () => {
|
||||
expect(normalizeRoleForGrouping("function")).toBe("assistant");
|
||||
expect(normalizeRoleForGrouping("Function")).toBe("assistant");
|
||||
it("returns tool for function", () => {
|
||||
expect(normalizeRoleForGrouping("function")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("Function")).toBe("tool");
|
||||
});
|
||||
|
||||
it("preserves user role", () => {
|
||||
|
||||
@@ -14,8 +14,36 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
const m = message as Record<string, unknown>;
|
||||
let role = typeof m.role === "string" ? m.role : "unknown";
|
||||
|
||||
// Detect tool result messages by presence of toolCallId or tool_call_id
|
||||
if (typeof m.toolCallId === "string" || typeof m.tool_call_id === "string") {
|
||||
// Detect tool messages by common gateway shapes.
|
||||
// Some tool events come through as assistant role with tool_* items in the content array.
|
||||
const hasToolId =
|
||||
typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
|
||||
|
||||
const contentRaw = m.content;
|
||||
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;
|
||||
const hasToolContent =
|
||||
Array.isArray(contentItems) &&
|
||||
contentItems.some((item) => {
|
||||
const x = item as Record<string, unknown>;
|
||||
const t = String(x.type ?? "").toLowerCase();
|
||||
return (
|
||||
t === "toolcall" ||
|
||||
t === "tool_call" ||
|
||||
t === "tooluse" ||
|
||||
t === "tool_use" ||
|
||||
t === "toolresult" ||
|
||||
t === "tool_result" ||
|
||||
t === "tool_call" ||
|
||||
t === "tool_result" ||
|
||||
(typeof x.name === "string" && x.arguments != null)
|
||||
);
|
||||
});
|
||||
|
||||
const hasToolName =
|
||||
typeof (m as Record<string, unknown>).toolName === "string" ||
|
||||
typeof (m as Record<string, unknown>).tool_name === "string";
|
||||
|
||||
if (hasToolId || hasToolContent || hasToolName) {
|
||||
role = "toolResult";
|
||||
}
|
||||
|
||||
@@ -43,19 +71,22 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
|
||||
/**
|
||||
* Normalize role for grouping purposes.
|
||||
* Tool results should be grouped with assistant messages.
|
||||
*/
|
||||
export function normalizeRoleForGrouping(role: string): string {
|
||||
const lower = role.toLowerCase();
|
||||
// All tool-related roles should display as assistant
|
||||
// Keep tool-related roles distinct so the UI can style/toggle them.
|
||||
if (
|
||||
lower === "toolresult" ||
|
||||
lower === "tool_result" ||
|
||||
lower === "tool" ||
|
||||
lower === "function"
|
||||
lower === "function" ||
|
||||
lower === "toolresult"
|
||||
) {
|
||||
return "assistant";
|
||||
return "tool";
|
||||
}
|
||||
if (lower === "assistant") return "assistant";
|
||||
if (lower === "user") return "user";
|
||||
if (lower === "system") return "system";
|
||||
return role;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type UiSettings = {
|
||||
lastActiveSessionKey: string;
|
||||
theme: ThemeMode;
|
||||
chatFocusMode: boolean;
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
useNewChatLayout: boolean; // Slack-style grouped messages layout
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
@@ -28,6 +29,7 @@ export function loadSettings(): UiSettings {
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
useNewChatLayout: true, // Enabled by default
|
||||
navCollapsed: false,
|
||||
@@ -65,6 +67,10 @@ export function loadSettings(): UiSettings {
|
||||
typeof parsed.chatFocusMode === "boolean"
|
||||
? parsed.chatFocusMode
|
||||
: defaults.chatFocusMode,
|
||||
chatShowThinking:
|
||||
typeof parsed.chatShowThinking === "boolean"
|
||||
? parsed.chatShowThinking
|
||||
: defaults.chatShowThinking,
|
||||
splitRatio:
|
||||
typeof parsed.splitRatio === "number" &&
|
||||
parsed.splitRatio >= 0.4 &&
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ChatProps = {
|
||||
sessionKey: string;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
thinkingLevel: string | null;
|
||||
showThinking: boolean;
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
canAbort?: boolean;
|
||||
@@ -69,7 +70,7 @@ export function renderChat(props: ChatProps) {
|
||||
(row) => row.key === props.sessionKey,
|
||||
);
|
||||
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
|
||||
const showReasoning = reasoningLevel !== "off";
|
||||
const showReasoning = props.showThinking && reasoningLevel !== "off";
|
||||
|
||||
const composePlaceholder = props.connected
|
||||
? "Message (↩ to send, Shift+↩ for line breaks)"
|
||||
@@ -310,18 +311,27 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
||||
});
|
||||
}
|
||||
for (let i = historyStart; i < history.length; i++) {
|
||||
const msg = history[i];
|
||||
const normalized = normalizeMessage(msg);
|
||||
|
||||
if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(history[i], i),
|
||||
message: history[i],
|
||||
key: messageKey(msg, i),
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < tools.length; i++) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(tools[i], i + history.length),
|
||||
message: tools[i],
|
||||
});
|
||||
if (props.showThinking) {
|
||||
for (let i = 0; i < tools.length; i++) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(tools[i], i + history.length),
|
||||
message: tools[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.stream !== null) {
|
||||
|
||||
Reference in New Issue
Block a user