refactor chat rendering: new message roles, tool call pairing, interruption styling
This commit is contained in:
parent
6d21f142af
commit
aa0e99213d
|
|
@ -244,13 +244,22 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
|
|||
<div class="message-empty">/s/{sid}/chat</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
{messages.map((msg, i) => {
|
||||
// tool results rendered inline with their preceding call
|
||||
if (msg.role === 'tool' && i > 0 && messages[i-1]?.role === 'call' && messages[i-1]?.name === msg.name) return null
|
||||
return (
|
||||
<div key={i} class={`message ${msg.role}`}>
|
||||
<div class="msg-bubble">
|
||||
{msg.role === 'user' ? (
|
||||
<div class="md" dangerouslySetInnerHTML={{ __html: linkifyPaths((marked.parse(msg.content) as string).replace(/^<p>/, '<p><span class="msg-prefix">u: </span>'), session.cwd) }} />
|
||||
) : msg.role === 'call' ? (
|
||||
<ToolCall name={msg.name ?? ''} args={msg.content} output={messages[i+1]?.role === 'tool' && messages[i+1]?.name === msg.name ? messages[i+1].content : ''} />
|
||||
) : msg.role === 'tool' ? (
|
||||
<ToolCall content={msg.content} />
|
||||
<ToolOutput text={msg.content} />
|
||||
) : msg.role === 'reasoning' ? (
|
||||
<details class="reasoning"><summary>thinking</summary><pre>{msg.content}</pre></details>
|
||||
) : msg.role === 'error' ? (
|
||||
<div class="msg-error">{msg.content}</div>
|
||||
) : msg.role === 'info' ? (
|
||||
<InfoMessage content={msg.content} />
|
||||
) : (
|
||||
|
|
@ -261,7 +270,8 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{isRunning && (
|
||||
<div class="typing">
|
||||
|
|
@ -361,15 +371,7 @@ function DiagramModal({ html, onClose }: { html: string; onClose: () => void })
|
|||
)
|
||||
}
|
||||
|
||||
function formatToolContent(raw: string): { name: string; detail: string; body: string; output: string } {
|
||||
// Call is always on the first line; output follows after the newline.
|
||||
const nl = raw.indexOf("\n")
|
||||
const callLine = nl === -1 ? raw : raw.slice(0, nl)
|
||||
const output = nl === -1 ? "" : raw.slice(nl + 1).trimEnd()
|
||||
const m = callLine.match(/^(\w+)\((.*)\)$/)
|
||||
if (!m) return { name: "", detail: "", body: raw, output: "" }
|
||||
const name = m[1]
|
||||
const argsRaw = m[2]
|
||||
function formatToolArgs(name: string, argsRaw: string): { detail: string; body: string } {
|
||||
try {
|
||||
const obj = JSON.parse(argsRaw)
|
||||
let detail = ""
|
||||
|
|
@ -381,9 +383,9 @@ function formatToolContent(raw: string): { name: string; detail: string; body: s
|
|||
} else {
|
||||
detail = obj.steps?.[0]?.language ?? "bash"
|
||||
}
|
||||
return { name, detail, body: JSON.stringify(obj, null, 2), output }
|
||||
return { detail, body: JSON.stringify(obj, null, 2) }
|
||||
} catch {
|
||||
return { name, detail: "", body: argsRaw, output }
|
||||
return { detail: "", body: argsRaw }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,25 +431,34 @@ function InfoMessage({ content }: { content: string }) {
|
|||
<span class="tool-name">shell</span>
|
||||
<span class="tool-detail">{cmd}</span>
|
||||
</div>
|
||||
<pre class="tool-output">{output || '(no output)'}</pre>
|
||||
<ToolOutput text={output || '(no output)'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolCall({ content }: { content: string }) {
|
||||
const { name, detail, body, output } = formatToolContent(content)
|
||||
function ToolOutput({ text }: { text: string }) {
|
||||
const parts = text.split(/\n*^<system-user-interruption>$\n?|\n?^<\/system-user-interruption>$\n*/m)
|
||||
return (
|
||||
<pre class="tool-output">
|
||||
{parts.map((p, i) => i % 2 === 1 ? <span class="user-interruption">{p}</span> : p)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolCall({ name, args, output }: { name: string; args: string; output: string }) {
|
||||
const { detail, body } = formatToolArgs(name, args)
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div class="tool-call">
|
||||
<details open={open} onToggle={(e: Event) => setOpen((e.target as HTMLDetailsElement).open)}>
|
||||
<summary class="tool-header">
|
||||
<span class="tool-arrow">{toolIcon(detail)}</span>
|
||||
{name && <span class="tool-name">{name}</span>}
|
||||
<span class="tool-arrow">{toolIcon(detail || name)}</span>
|
||||
<span class="tool-name">{name}</span>
|
||||
{detail && <span class="tool-detail">{detail}</span>}
|
||||
</summary>
|
||||
<pre class="tool-body">{body}</pre>
|
||||
</details>
|
||||
{output && <pre class="tool-output">{output}</pre>}
|
||||
{output && <ToolOutput text={output} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,27 @@
|
|||
export type ChatRole = 'user' | 'assistant' | 'reasoning' | 'call' | 'tool' | 'error' | 'info' | 'retry' | 'exec' | 'stalled' | 'maxsteps'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'tool' | 'info'
|
||||
role: ChatRole
|
||||
name?: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// Parse the ollie chat log format (lines returned by the gateway).
|
||||
// Lines starting with "user: " or "assistant: " begin a new message;
|
||||
// subsequent lines are appended to the current message content.
|
||||
const headerRe = /^\[(\w+)(?::([^\]]+))?\]$/
|
||||
|
||||
// Parse the ollie chat log format. Each block starts with a [role] or
|
||||
// [role:name] header line; content runs until the next header.
|
||||
export function parseChat(lines: string[]): ChatMessage[] {
|
||||
const messages: ChatMessage[] = []
|
||||
let cur: ChatMessage | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('user: ')) {
|
||||
const m = headerRe.exec(line)
|
||||
if (m) {
|
||||
if (cur) messages.push(cur)
|
||||
cur = { role: 'user', content: line.slice(6) }
|
||||
} else if (line.startsWith('assistant: ')) {
|
||||
if (cur) messages.push(cur)
|
||||
cur = { role: 'assistant', content: line.slice(11) }
|
||||
} else if (line.startsWith('-> ')) {
|
||||
if (cur) messages.push(cur)
|
||||
cur = { role: 'tool', content: line.slice(3) }
|
||||
} else if (line.startsWith(':: ')) {
|
||||
if (cur) messages.push(cur)
|
||||
cur = { role: 'info', content: line.slice(3) }
|
||||
cur = { role: m[1] as ChatRole, content: '' }
|
||||
if (m[2]) cur.name = m[2]
|
||||
} else if (cur) {
|
||||
cur.content += '\n' + line
|
||||
cur.content += (cur.content ? '\n' : '') + line
|
||||
}
|
||||
}
|
||||
if (cur) messages.push(cur)
|
||||
|
|
|
|||
|
|
@ -552,6 +552,16 @@ input, select, textarea { font-family: inherit; }
|
|||
.md th { background: var(--bg-soft); font-weight: 600; }
|
||||
|
||||
/* ---- Tool calls ---- */
|
||||
.message.call { justify-content: flex-start; }
|
||||
.message.call .msg-bubble {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
background: var(--tool-bubble-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.message.tool { justify-content: flex-start; }
|
||||
.message.tool .msg-bubble {
|
||||
max-width: 100%;
|
||||
|
|
@ -812,6 +822,22 @@ details[open] > .tool-header::before {
|
|||
padding: 0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
[data-theme="acme"] .message.call .msg-bubble {
|
||||
border-radius: var(--r-sm);
|
||||
margin: 4px 0;
|
||||
}
|
||||
[data-theme="acme"] .message.reasoning .msg-bubble {
|
||||
padding: 0;
|
||||
}
|
||||
[data-theme="acme"] .message.error .msg-bubble {
|
||||
border-radius: var(--r-sm);
|
||||
margin: 4px 0;
|
||||
}
|
||||
[data-theme="acme"] .user-interruption {
|
||||
background: rgba(147, 112, 219, 0.12);
|
||||
border-color: rgba(147, 112, 219, 0.4);
|
||||
color: #6b21a8;
|
||||
}
|
||||
[data-theme="acme"] .shell-block {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -1072,6 +1098,54 @@ details[open] > .tool-header::before {
|
|||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ---- Reasoning ---- */
|
||||
.message.reasoning { justify-content: flex-start; }
|
||||
.message.reasoning .msg-bubble {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.reasoning summary {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.reasoning pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* ---- Error messages ---- */
|
||||
.message.error { justify-content: flex-start; }
|
||||
.message.error .msg-bubble {
|
||||
background: rgba(251, 113, 133, 0.06);
|
||||
border: 1px solid rgba(251, 113, 133, 0.25);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.msg-error {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---- User interruption ---- */
|
||||
.user-interruption {
|
||||
display: block;
|
||||
margin: 6px 0 2px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: var(--r-sm);
|
||||
color: #b49aef;
|
||||
font-style: normal;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---- Info messages ---- */
|
||||
.message.info { justify-content: flex-start; }
|
||||
.message.info .msg-bubble {
|
||||
|
|
|
|||
Reference in New Issue