Skip to content

Commit 535b6fd

Browse files
feat(dashboard): align chat bubbles to request/response sides and add collapsible function notes
User/request messages positioned left, agent/response messages right to reflect audit log request-response flow. Function calls (from AI) align right, function results (from client) align left. Function notes are now collapsible via <details> — showing one-line summary by default with expandable full content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3a81ada commit 535b6fd

3 files changed

Lines changed: 193 additions & 21 deletions

File tree

internal/admin/dashboard/static/css/dashboard.css

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,12 +1330,12 @@ td.col-price {
13301330
}
13311331

13321332
.chat-message.role-user {
1333-
align-self: flex-end;
1334-
background: color-mix(in srgb, var(--accent) 18%, var(--bg));
1333+
align-self: flex-start;
13351334
}
13361335

13371336
.chat-message.role-assistant {
1338-
align-self: flex-start;
1337+
align-self: flex-end;
1338+
background: color-mix(in srgb, var(--accent) 18%, var(--bg));
13391339
}
13401340

13411341
.chat-message.role-system {
@@ -1346,7 +1346,7 @@ td.col-price {
13461346
}
13471347

13481348
.chat-message.role-error {
1349-
align-self: flex-start;
1349+
align-self: flex-end;
13501350
border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
13511351
background: color-mix(in srgb, var(--danger) 10%, var(--bg));
13521352
}
@@ -1386,6 +1386,106 @@ td.col-price {
13861386
color: var(--text);
13871387
}
13881388

1389+
/* Tool call footer on assistant bubbles */
1390+
.chat-tool-calls {
1391+
margin-top: 8px;
1392+
padding-top: 6px;
1393+
border-top: 1px solid var(--border);
1394+
display: flex;
1395+
flex-direction: column;
1396+
gap: 3px;
1397+
}
1398+
1399+
.chat-tool-call {
1400+
font-size: 11px;
1401+
color: var(--text-muted);
1402+
font-family: 'SF Mono', Menlo, Consolas, monospace;
1403+
}
1404+
1405+
.chat-tool-call-name::before {
1406+
content: "\26A1 ";
1407+
}
1408+
1409+
/* Function call / result notes between bubbles */
1410+
.chat-function-note {
1411+
align-self: center;
1412+
max-width: 94%;
1413+
padding: 4px 12px;
1414+
border-radius: 12px;
1415+
font-size: 12px;
1416+
color: var(--text-muted);
1417+
background: color-mix(in srgb, var(--border) 40%, transparent);
1418+
border: 1px dashed var(--border);
1419+
}
1420+
1421+
.chat-function-note.is-anchor {
1422+
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
1423+
}
1424+
1425+
.chat-function-note-inner {
1426+
display: flex;
1427+
align-items: baseline;
1428+
gap: 6px;
1429+
overflow: hidden;
1430+
}
1431+
1432+
.chat-function-label {
1433+
font-weight: 600;
1434+
font-size: 11px;
1435+
text-transform: uppercase;
1436+
letter-spacing: 0.3px;
1437+
white-space: nowrap;
1438+
flex-shrink: 0;
1439+
}
1440+
1441+
.chat-function-detail {
1442+
font-family: 'SF Mono', Menlo, Consolas, monospace;
1443+
font-size: 11px;
1444+
white-space: nowrap;
1445+
overflow: hidden;
1446+
text-overflow: ellipsis;
1447+
}
1448+
1449+
.chat-function-note.role-function-call {
1450+
align-self: flex-end;
1451+
background: color-mix(in srgb, var(--accent) 8%, transparent);
1452+
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
1453+
}
1454+
1455+
.chat-function-note.role-function-result {
1456+
align-self: flex-start;
1457+
background: color-mix(in srgb, var(--success) 8%, transparent);
1458+
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
1459+
}
1460+
1461+
/* Collapsible function notes */
1462+
.chat-function-note-details {
1463+
width: 100%;
1464+
}
1465+
1466+
.chat-function-note-details > summary {
1467+
list-style: none;
1468+
cursor: pointer;
1469+
}
1470+
1471+
.chat-function-note-details > summary::-webkit-details-marker {
1472+
display: none;
1473+
}
1474+
1475+
.chat-function-expanded {
1476+
font-family: 'SF Mono', Menlo, Consolas, monospace;
1477+
font-size: 11px;
1478+
line-height: 1.45;
1479+
margin-top: 6px;
1480+
padding-top: 6px;
1481+
border-top: 1px solid var(--border);
1482+
white-space: pre-wrap;
1483+
overflow-wrap: anywhere;
1484+
color: var(--text);
1485+
max-height: 200px;
1486+
overflow: auto;
1487+
}
1488+
13891489
/* Caveat warning icon */
13901490
.caveat-icon {
13911491
color: var(--warning);

internal/admin/dashboard/static/js/modules/conversation-drawer.js

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
if (requestToken !== this.conversationRequestToken) return;
111111

112112
if (!this.handleFetchResponse(res, 'audit conversation')) {
113-
this.conversationError = 'Unable to load conversation.';
113+
this.conversationError = 'Unable to load interactions.';
114114
this.conversationEntries = [];
115115
this.conversationMessages = [];
116116
return;
@@ -125,7 +125,7 @@
125125
} catch (e) {
126126
if (requestToken !== this.conversationRequestToken) return;
127127
console.error('Failed to fetch audit conversation:', e);
128-
this.conversationError = 'Failed to load conversation.';
128+
this.conversationError = 'Failed to load interactions.';
129129
this.conversationEntries = [];
130130
this.conversationMessages = [];
131131
} finally {
@@ -162,6 +162,19 @@
162162
requestBody.messages.forEach((m) => {
163163
if (!m) return;
164164
const role = (m.role || 'user').toLowerCase();
165+
if (role === 'tool') {
166+
const text = extractText(m.content);
167+
if (text) messages.push(this._conversationMessage('function_result', text, ts, entry.id, isAnchor, ++idx, [], m.name || ''));
168+
return;
169+
}
170+
if (role === 'assistant') {
171+
const text = extractText(m.content);
172+
const toolCalls = this._extractToolCalls(m.tool_calls);
173+
if (text || toolCalls.length > 0) {
174+
messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx, toolCalls));
175+
}
176+
return;
177+
}
165178
const text = extractText(m.content);
166179
if (text) messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx));
167180
});
@@ -171,20 +184,34 @@
171184
extractResponsesInputMessages(requestBody.input).forEach((m) => {
172185
if (m.text) messages.push(this._conversationMessage(m.role, m.text, ts, entry.id, isAnchor, ++idx));
173186
});
187+
if (Array.isArray(requestBody.input)) {
188+
requestBody.input.forEach((item) => {
189+
if (!item || typeof item !== 'object' || item.type !== 'function_call_output') return;
190+
const text = typeof item.output === 'string' ? item.output : extractText(item.output);
191+
if (text) messages.push(this._conversationMessage('function_result', text, ts, entry.id, isAnchor, ++idx, [], item.call_id || ''));
192+
});
193+
}
174194
}
175195

176196
if (responseBody && Array.isArray(responseBody.choices)) {
177197
const first = responseBody.choices[0];
178198
if (first && first.message) {
179199
const role = (first.message.role || 'assistant').toLowerCase();
180200
const text = extractText(first.message.content);
181-
if (text) messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx));
201+
const toolCalls = this._extractToolCalls(first.message.tool_calls);
202+
if (text || toolCalls.length > 0) {
203+
messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx, toolCalls));
204+
}
182205
}
183206
}
184207

185208
if (responseBody && Array.isArray(responseBody.output)) {
186209
responseBody.output.forEach((item) => {
187210
if (!item) return;
211+
if (item.type === 'function_call') {
212+
messages.push(this._conversationMessage('function_call', '', ts, entry.id, isAnchor, ++idx, [{name: item.name || '', arguments: item.arguments || ''}]));
213+
return;
214+
}
188215
const role = (item.role || 'assistant').toLowerCase();
189216
const text = extractResponsesOutputText(item);
190217
if (text) messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx));
@@ -200,7 +227,19 @@
200227
return messages;
201228
},
202229

203-
_conversationMessage(role, text, timestamp, entryID, isAnchor, idx) {
230+
_extractToolCalls(toolCalls) {
231+
if (!Array.isArray(toolCalls)) return [];
232+
return toolCalls.map((tc) => {
233+
if (!tc) return null;
234+
const fn = tc.function || tc;
235+
return {
236+
name: fn.name || tc.name || '',
237+
arguments: fn.arguments || tc.arguments || ''
238+
};
239+
}).filter(Boolean);
240+
},
241+
242+
_conversationMessage(role, text, timestamp, entryID, isAnchor, idx, toolCalls, functionName) {
204243
const normalized = this._roleMeta(role);
205244
return {
206245
uid: entryID + '-' + idx,
@@ -210,10 +249,23 @@
210249
role: normalized.role,
211250
roleLabel: normalized.label,
212251
roleClass: normalized.className,
213-
isAnchor
252+
isAnchor,
253+
toolCalls: Array.isArray(toolCalls) && toolCalls.length > 0 ? toolCalls : null,
254+
functionName: functionName || ''
214255
};
215256
},
216257

258+
functionExpandedContent(msg) {
259+
if (msg.role === 'function_call') {
260+
return (msg.toolCalls || []).map(function(tc) {
261+
var args = tc.arguments || '';
262+
try { args = JSON.stringify(JSON.parse(args), null, 2); } catch(e) {}
263+
return tc.name + '(' + args + ')';
264+
}).join('\n\n');
265+
}
266+
return msg.text || '';
267+
},
268+
217269
_roleMeta(role) {
218270
const normalized = String(role || '').toLowerCase();
219271
if (normalized === 'system' || normalized === 'developer') {
@@ -225,6 +277,12 @@
225277
if (normalized === 'error') {
226278
return { role: 'error', label: 'Error', className: 'role-error' };
227279
}
280+
if (normalized === 'function_call') {
281+
return { role: 'function_call', label: 'Function Call', className: 'role-function-call' };
282+
}
283+
if (normalized === 'function_result') {
284+
return { role: 'function_result', label: 'Function Result', className: 'role-function-result' };
285+
}
228286
return { role: 'user', label: 'User', className: 'role-user' };
229287
}
230288
};

internal/admin/dashboard/templates/index.html

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -396,8 +396,8 @@ <h2>Audit Logs</h2>
396396
<span class="mono" x-text="formatDurationNs(entry.duration_ns)"></span>
397397
<button class="audit-conversation-trigger"
398398
x-show="canShowConversation(entry)"
399-
title="Open conversation"
400-
aria-label="Open conversation"
399+
title="Open interactions"
400+
aria-label="Open interactions"
401401
@click.stop.prevent="openConversation(entry, $el.closest('details'), true, $el)">
402402
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
403403
<path d="M9 6l6 6-6 6"/>
@@ -484,27 +484,41 @@ <h5>Body</h5>
484484
role="dialog"
485485
aria-modal="true"
486486
:aria-hidden="(!conversationOpen).toString()"
487-
aria-labelledby="conversation-drawer-title"
488-
aria-describedby="conversation-drawer-content"
487+
aria-labelledby="interactions-drawer-title"
488+
aria-describedby="interactions-drawer-content"
489489
@keydown.escape.window="conversationOpen && closeConversation()">
490490
<div class="conversation-drawer-header">
491-
<h3 id="conversation-drawer-title">Conversation</h3>
492-
<button class="pagination-btn" x-ref="conversationCloseBtn" aria-label="Close conversation" @click="closeConversation()">Close</button>
491+
<h3 id="interactions-drawer-title">Interactions</h3>
492+
<button class="pagination-btn" x-ref="conversationCloseBtn" aria-label="Close interactions" @click="closeConversation()">Close</button>
493493
</div>
494494

495-
<div id="conversation-drawer-content">
495+
<div id="interactions-drawer-content">
496496
<div class="alert alert-warning" x-show="conversationError" x-text="conversationError"></div>
497-
<p class="empty-state" x-show="conversationLoading">Loading conversation...</p>
498-
<p class="empty-state" x-show="!conversationLoading && !conversationError && conversationMessages.length === 0">No conversation data available for this entry.</p>
497+
<p class="empty-state" x-show="conversationLoading">Loading interactions...</p>
498+
<p class="empty-state" x-show="!conversationLoading && !conversationError && conversationMessages.length === 0">No interaction data available for this entry.</p>
499499

500500
<div class="conversation-thread" x-show="conversationMessages.length > 0">
501501
<template x-for="msg in conversationMessages" :key="msg.uid">
502-
<article class="chat-message" :class="[msg.roleClass, msg.isAnchor ? 'is-anchor' : '']">
503-
<header class="chat-message-meta">
502+
<article :class="[msg.role === 'function_call' || msg.role === 'function_result' ? 'chat-function-note' : 'chat-message', msg.roleClass, msg.isAnchor ? 'is-anchor' : '']">
503+
<details class="chat-function-note-details" x-show="msg.role === 'function_call' || msg.role === 'function_result'">
504+
<summary class="chat-function-note-inner">
505+
<span class="chat-function-label" x-text="msg.roleLabel"></span>
506+
<span class="chat-function-detail" x-text="msg.role === 'function_call' ? (msg.toolCalls || []).map(tc => tc.name + '()').join(', ') : (msg.functionName ? msg.functionName + ': ' : '') + msg.text"></span>
507+
</summary>
508+
<pre class="chat-function-expanded" x-text="functionExpandedContent(msg)"></pre>
509+
</details>
510+
<header class="chat-message-meta" x-show="msg.role !== 'function_call' && msg.role !== 'function_result'">
504511
<span class="chat-role" x-text="msg.roleLabel"></span>
505512
<span class="mono chat-time" x-text="formatTimestamp(msg.timestamp)"></span>
506513
</header>
507-
<pre class="chat-content" x-text="msg.text"></pre>
514+
<pre class="chat-content" x-show="msg.text && msg.role !== 'function_call' && msg.role !== 'function_result'" x-text="msg.text"></pre>
515+
<footer class="chat-tool-calls" x-show="msg.toolCalls && msg.role !== 'function_call'">
516+
<template x-for="tc in (msg.toolCalls || [])" :key="tc.name">
517+
<div class="chat-tool-call">
518+
<span class="chat-tool-call-name" x-text="tc.name + '()'"></span>
519+
</div>
520+
</template>
521+
</footer>
508522
</article>
509523
</template>
510524
</div>

0 commit comments

Comments
 (0)