diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 39b641826..237fdb24a 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -18,6 +18,21 @@ justify-content: flex-start; } +.chat-group.system { + justify-content: center; + margin-left: 0; + margin-right: 0; +} + +.chat-group.system .chat-group-messages { + align-items: center; + max-width: min(820px, 90%); +} + +.chat-group.system .chat-group-footer { + justify-content: center; +} + .chat-group-messages { display: flex; flex-direction: column; @@ -89,6 +104,10 @@ color: var(--muted); } +.chat-group.system .chat-avatar { + display: none; +} + /* Image avatar support */ img.chat-avatar { display: block; @@ -228,6 +247,21 @@ img.chat-avatar { border-color: transparent; } +.chat-group.system .chat-bubble { + background: var(--bg-muted); + border-color: var(--border); + color: var(--muted); + font-size: 12px; + line-height: 1.4; + padding: 8px 12px; + text-align: center; +} + +:root[data-theme="light"] .chat-group.system .chat-bubble { + background: rgba(148, 163, 184, 0.12); + border-color: rgba(148, 163, 184, 0.35); +} + :root[data-theme="light"] .chat-group.user .chat-bubble { border-color: rgba(234, 88, 12, 0.2); background: rgba(251, 146, 60, 0.12); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 4a9ccec14..77bff47f2 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -120,12 +120,16 @@ export function renderMessageGroup( ? "You" : normalizedRole === "assistant" ? assistantName + : normalizedRole === "system" + ? "System" : normalizedRole; const roleClass = normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" + : normalizedRole === "system" + ? "system" : "other"; const timestamp = new Date(group.timestamp).toLocaleTimeString([], { hour: "numeric", @@ -171,6 +175,8 @@ function renderAvatar( ? "U" : normalized === "assistant" ? assistantName.charAt(0).toUpperCase() || "A" + : normalized === "system" + ? "!" : normalized === "tool" ? "⚙" : "?"; @@ -179,6 +185,8 @@ function renderAvatar( ? "user" : normalized === "assistant" ? "assistant" + : normalized === "system" + ? "system" : normalized === "tool" ? "tool" : "other"; diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts index 132a4be17..151898efb 100644 --- a/ui/src/ui/chat/message-normalizer.test.ts +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -3,6 +3,7 @@ import { normalizeMessage, normalizeRoleForGrouping, isToolResultMessage, + splitSystemPreface, } from "./message-normalizer"; describe("message-normalizer", () => { @@ -138,6 +139,48 @@ describe("message-normalizer", () => { }); }); + describe("splitSystemPreface", () => { + it("splits leading system block into system message", () => { + const normalized = normalizeMessage({ + role: "user", + content: "System: [2026-01-26 10:00:00 UTC] Exec finished\n\nRun complete", + timestamp: 123, + }); + const result = splitSystemPreface(normalized); + expect(result.systemMessage?.role).toBe("system"); + expect(result.systemMessage?.content).toEqual([ + { type: "text", text: expect.stringContaining("Exec finished") }, + ]); + expect(result.message.content).toEqual([ + { type: "text", text: "Run complete" }, + ]); + }); + + it("keeps user message when no system prefix", () => { + const normalized = normalizeMessage({ + role: "user", + content: "Hello", + timestamp: 123, + }); + const result = splitSystemPreface(normalized); + expect(result.systemMessage).toBeNull(); + expect(result.message.content).toEqual([{ type: "text", text: "Hello" }]); + }); + + it("creates system message even when remainder is empty", () => { + const normalized = normalizeMessage({ + role: "user", + content: "System: [2026-01-26 10:00:00 UTC] Gateway restart", + timestamp: 123, + }); + const result = splitSystemPreface(normalized); + expect(result.systemMessage?.content).toEqual([ + { type: "text", text: expect.stringContaining("Gateway restart") }, + ]); + expect(result.message.content).toEqual([]); + }); + }); + describe("isToolResultMessage", () => { it("returns true for toolresult role", () => { expect(isToolResultMessage({ role: "toolresult" })).toBe(true); diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index e4cbab81d..c3b57fdeb 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -59,6 +59,74 @@ export function normalizeMessage(message: unknown): NormalizedMessage { return { role, content, timestamp, id }; } +type SplitSystemMessageResult = { + systemMessage: NormalizedMessage | null; + message: NormalizedMessage; +}; + +const SYSTEM_LINE_PREFIX = "System: "; + +function parseSystemLine(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed.startsWith(SYSTEM_LINE_PREFIX)) return null; + const payload = trimmed.slice(SYSTEM_LINE_PREFIX.length).trim(); + return payload ? payload : null; +} + +export function splitSystemPreface( + message: NormalizedMessage, +): SplitSystemMessageResult { + if (message.role.toLowerCase() !== "user") { + return { systemMessage: null, message }; + } + + const textParts = message.content + .filter((item) => item.type === "text" && typeof item.text === "string") + .map((item) => item.text ?? "") + .filter((text) => text.trim().length > 0); + + if (textParts.length === 0) return { systemMessage: null, message }; + + const joined = textParts.join("\n").trim(); + if (!joined.startsWith(SYSTEM_LINE_PREFIX)) { + return { systemMessage: null, message }; + } + + const lines = joined.split(/\r?\n/); + const systemLines: string[] = []; + let idx = 0; + while (idx < lines.length) { + const parsed = parseSystemLine(lines[idx] ?? ""); + if (!parsed) break; + systemLines.push(parsed); + idx += 1; + } + + if (systemLines.length === 0) return { systemMessage: null, message }; + + // Skip blank separator line after system block, if present. + while (idx < lines.length && !lines[idx]?.trim()) idx += 1; + const remainder = lines.slice(idx).join("\n").trim(); + + const systemMessage: NormalizedMessage = { + role: "system", + content: [{ type: "text", text: systemLines.join("\n") }], + timestamp: message.timestamp, + }; + + if (!remainder) { + return { systemMessage, message: { ...message, content: [] } }; + } + + return { + systemMessage, + message: { + ...message, + content: [{ type: "text", text: remainder }], + }, + }; +} + /** * Normalize role for grouping purposes. */ diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index f5fb6e80b..65de8e4b6 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -8,6 +8,7 @@ import { icons } from "../icons"; import { normalizeMessage, normalizeRoleForGrouping, + splitSystemPreface, } from "../chat/message-normalizer"; import { renderMessageGroup, @@ -438,10 +439,23 @@ function buildChatItems(props: ChatProps): Array { continue; } + const split = splitSystemPreface(normalized); + if (split.systemMessage) { + items.push({ + kind: "message", + key: `system:${messageKey(msg, i)}`, + message: split.systemMessage, + }); + } + + if (split.systemMessage && split.message.content.length === 0) { + continue; + } + items.push({ kind: "message", key: messageKey(msg, i), - message: msg, + message: split.systemMessage ? split.message : msg, }); } if (props.showThinking) {