Skip to content

Commit 4899d25

Browse files
feat(dashboard): align chat bubbles to request/response sides and add… (#135)
* 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> * fix(dashboard): address code review findings in conversation drawer - Fix CSS specificity so anchor border-color is not overridden by role-specific function note styles - Resolve tool result function names by matching tool_call_id against call records instead of relying on m.name - Add type="button" to Close button to prevent implicit form submission - Use indexed key in tool-calls x-for loop to avoid duplicate keys - Process requestBody.input in a single pass to preserve original ordering of messages and function_call_output items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): handle Responses API input items directly instead of delegating to helper extractResponsesInputMessages was designed for the full input, not individual items. Process function_call, function_call_output, and role-based message items explicitly in the single-pass loop, falling back to the helper only for non-array inputs (plain strings). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): lock body scroll when interactions drawer is open Add overflow:hidden to body via conversation-drawer-open class toggled on open/close. Make #interactions-drawer-content the scrollable region with flex:1 and overflow-y:auto so the drawer scrolls independently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 72866c0 commit 4899d25

3 files changed

Lines changed: 275 additions & 26 deletions

File tree

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

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,6 +1281,10 @@ td.col-price {
12811281
transform: translateX(0);
12821282
}
12831283

1284+
body.conversation-drawer-open {
1285+
overflow: hidden;
1286+
}
1287+
12841288
.conversation-drawer-header {
12851289
display: flex;
12861290
align-items: center;
@@ -1301,15 +1305,20 @@ td.col-price {
13011305
font-family: 'SF Mono', Menlo, Consolas, monospace;
13021306
}
13031307

1308+
#interactions-drawer-content {
1309+
flex: 1;
1310+
overflow-y: auto;
1311+
min-height: 0;
1312+
}
1313+
13041314
.conversation-drawer-footer {
1315+
flex-shrink: 0;
13051316
border-top: 1px solid var(--border);
13061317
padding: 10px 16px;
13071318
background: var(--bg-surface);
13081319
}
13091320

13101321
.conversation-thread {
1311-
flex: 1;
1312-
overflow: auto;
13131322
padding: 14px 16px 20px;
13141323
display: flex;
13151324
flex-direction: column;
@@ -1330,12 +1339,12 @@ td.col-price {
13301339
}
13311340

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

13371345
.chat-message.role-assistant {
1338-
align-self: flex-start;
1346+
align-self: flex-end;
1347+
background: color-mix(in srgb, var(--accent) 18%, var(--bg));
13391348
}
13401349

13411350
.chat-message.role-system {
@@ -1346,7 +1355,7 @@ td.col-price {
13461355
}
13471356

13481357
.chat-message.role-error {
1349-
align-self: flex-start;
1358+
align-self: flex-end;
13501359
border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
13511360
background: color-mix(in srgb, var(--danger) 10%, var(--bg));
13521361
}
@@ -1386,6 +1395,111 @@ td.col-price {
13861395
color: var(--text);
13871396
}
13881397

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

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

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,15 @@
7575
this.conversationAnchorID = entry.id;
7676
this.conversationEntries = [];
7777
this.conversationMessages = [];
78+
document.body.classList.add('conversation-drawer-open');
7879
requestAnimationFrame(() => this._focusConversationDrawer());
7980
await this.fetchConversation(entry.id, requestToken);
8081
},
8182

8283
closeConversation() {
8384
this.conversationOpen = false;
8485
this.conversationRequestToken++;
86+
document.body.classList.remove('conversation-drawer-open');
8587
const returnFocusEl = this.conversationReturnFocusEl;
8688
this.conversationReturnFocusEl = null;
8789
if (returnFocusEl && typeof returnFocusEl.focus === 'function' && document.contains(returnFocusEl)) {
@@ -110,7 +112,7 @@
110112
if (requestToken !== this.conversationRequestToken) return;
111113

112114
if (!this.handleFetchResponse(res, 'audit conversation')) {
113-
this.conversationError = 'Unable to load conversation.';
115+
this.conversationError = 'Unable to load interactions.';
114116
this.conversationEntries = [];
115117
this.conversationMessages = [];
116118
return;
@@ -125,7 +127,7 @@
125127
} catch (e) {
126128
if (requestToken !== this.conversationRequestToken) return;
127129
console.error('Failed to fetch audit conversation:', e);
128-
this.conversationError = 'Failed to load conversation.';
130+
this.conversationError = 'Failed to load interactions.';
129131
this.conversationEntries = [];
130132
this.conversationMessages = [];
131133
} finally {
@@ -145,6 +147,14 @@
145147
const extractConversationErrorMessage = typeof h.extractConversationErrorMessage === 'function' ? h.extractConversationErrorMessage : () => '';
146148

147149
const sorted = [...entries].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
150+
151+
const callIdMap = {};
152+
sorted.forEach((entry) => {
153+
const rb = entry.data && entry.data.request_body ? entry.data.request_body : null;
154+
const rsb = entry.data && entry.data.response_body ? entry.data.response_body : null;
155+
this._collectCallIds(callIdMap, rb, rsb);
156+
});
157+
148158
const messages = [];
149159
let idx = 0;
150160

@@ -162,29 +172,66 @@
162172
requestBody.messages.forEach((m) => {
163173
if (!m) return;
164174
const role = (m.role || 'user').toLowerCase();
175+
if (role === 'tool') {
176+
const text = extractText(m.content);
177+
const fnName = m.name || callIdMap[m.tool_call_id] || '';
178+
if (text) messages.push(this._conversationMessage('function_result', text, ts, entry.id, isAnchor, ++idx, [], fnName));
179+
return;
180+
}
181+
if (role === 'assistant') {
182+
const text = extractText(m.content);
183+
const toolCalls = this._extractToolCalls(m.tool_calls);
184+
if (text || toolCalls.length > 0) {
185+
messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx, toolCalls));
186+
}
187+
return;
188+
}
165189
const text = extractText(m.content);
166190
if (text) messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx));
167191
});
168192
}
169193

170194
if (requestBody && requestBody.input !== undefined) {
171-
extractResponsesInputMessages(requestBody.input).forEach((m) => {
172-
if (m.text) messages.push(this._conversationMessage(m.role, m.text, ts, entry.id, isAnchor, ++idx));
173-
});
195+
if (Array.isArray(requestBody.input)) {
196+
requestBody.input.forEach((item) => {
197+
if (!item || typeof item !== 'object') return;
198+
if (item.type === 'function_call_output') {
199+
const text = typeof item.output === 'string' ? item.output : extractText(item.output);
200+
if (text) messages.push(this._conversationMessage('function_result', text, ts, entry.id, isAnchor, ++idx, [], callIdMap[item.call_id] || ''));
201+
} else if (item.type === 'function_call') {
202+
messages.push(this._conversationMessage('function_call', '', ts, entry.id, isAnchor, ++idx, [{name: item.name || '', arguments: item.arguments || ''}]));
203+
} else if (item.role) {
204+
const role = String(item.role).toLowerCase();
205+
const text = extractText(item.content);
206+
if (text) messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx));
207+
}
208+
});
209+
} else {
210+
extractResponsesInputMessages(requestBody.input).forEach((m) => {
211+
if (m.text) messages.push(this._conversationMessage(m.role, m.text, ts, entry.id, isAnchor, ++idx));
212+
});
213+
}
174214
}
175215

176216
if (responseBody && Array.isArray(responseBody.choices)) {
177217
const first = responseBody.choices[0];
178218
if (first && first.message) {
179219
const role = (first.message.role || 'assistant').toLowerCase();
180220
const text = extractText(first.message.content);
181-
if (text) messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx));
221+
const toolCalls = this._extractToolCalls(first.message.tool_calls);
222+
if (text || toolCalls.length > 0) {
223+
messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx, toolCalls));
224+
}
182225
}
183226
}
184227

185228
if (responseBody && Array.isArray(responseBody.output)) {
186229
responseBody.output.forEach((item) => {
187230
if (!item) return;
231+
if (item.type === 'function_call') {
232+
messages.push(this._conversationMessage('function_call', '', ts, entry.id, isAnchor, ++idx, [{name: item.name || '', arguments: item.arguments || ''}]));
233+
return;
234+
}
188235
const role = (item.role || 'assistant').toLowerCase();
189236
const text = extractResponsesOutputText(item);
190237
if (text) messages.push(this._conversationMessage(role, text, ts, entry.id, isAnchor, ++idx));
@@ -200,7 +247,62 @@
200247
return messages;
201248
},
202249

203-
_conversationMessage(role, text, timestamp, entryID, isAnchor, idx) {
250+
_collectCallIds(map, requestBody, responseBody) {
251+
if (requestBody && Array.isArray(requestBody.messages)) {
252+
requestBody.messages.forEach((m) => {
253+
if (!m || !Array.isArray(m.tool_calls)) return;
254+
m.tool_calls.forEach((tc) => {
255+
if (!tc) return;
256+
const id = tc.id || '';
257+
const fn = tc.function || tc;
258+
const name = fn.name || tc.name || '';
259+
if (id && name) map[id] = name;
260+
});
261+
});
262+
}
263+
if (requestBody && Array.isArray(requestBody.input)) {
264+
requestBody.input.forEach((item) => {
265+
if (!item || typeof item !== 'object' || item.type !== 'function_call') return;
266+
const id = item.id || item.call_id || '';
267+
const name = item.name || '';
268+
if (id && name) map[id] = name;
269+
});
270+
}
271+
if (responseBody && Array.isArray(responseBody.choices)) {
272+
const first = responseBody.choices[0];
273+
if (first && first.message && Array.isArray(first.message.tool_calls)) {
274+
first.message.tool_calls.forEach((tc) => {
275+
if (!tc) return;
276+
const id = tc.id || '';
277+
const fn = tc.function || tc;
278+
const name = fn.name || tc.name || '';
279+
if (id && name) map[id] = name;
280+
});
281+
}
282+
}
283+
if (responseBody && Array.isArray(responseBody.output)) {
284+
responseBody.output.forEach((item) => {
285+
if (!item || item.type !== 'function_call') return;
286+
const id = item.id || item.call_id || '';
287+
const name = item.name || '';
288+
if (id && name) map[id] = name;
289+
});
290+
}
291+
},
292+
293+
_extractToolCalls(toolCalls) {
294+
if (!Array.isArray(toolCalls)) return [];
295+
return toolCalls.map((tc) => {
296+
if (!tc) return null;
297+
const fn = tc.function || tc;
298+
return {
299+
name: fn.name || tc.name || '',
300+
arguments: fn.arguments || tc.arguments || ''
301+
};
302+
}).filter(Boolean);
303+
},
304+
305+
_conversationMessage(role, text, timestamp, entryID, isAnchor, idx, toolCalls, functionName) {
204306
const normalized = this._roleMeta(role);
205307
return {
206308
uid: entryID + '-' + idx,
@@ -210,10 +312,23 @@
210312
role: normalized.role,
211313
roleLabel: normalized.label,
212314
roleClass: normalized.className,
213-
isAnchor
315+
isAnchor,
316+
toolCalls: Array.isArray(toolCalls) && toolCalls.length > 0 ? toolCalls : null,
317+
functionName: functionName || ''
214318
};
215319
},
216320

321+
functionExpandedContent(msg) {
322+
if (msg.role === 'function_call') {
323+
return (msg.toolCalls || []).map(function(tc) {
324+
var args = tc.arguments || '';
325+
try { args = JSON.stringify(JSON.parse(args), null, 2); } catch(e) {}
326+
return tc.name + '(' + args + ')';
327+
}).join('\n\n');
328+
}
329+
return msg.text || '';
330+
},
331+
217332
_roleMeta(role) {
218333
const normalized = String(role || '').toLowerCase();
219334
if (normalized === 'system' || normalized === 'developer') {
@@ -225,6 +340,12 @@
225340
if (normalized === 'error') {
226341
return { role: 'error', label: 'Error', className: 'role-error' };
227342
}
343+
if (normalized === 'function_call') {
344+
return { role: 'function_call', label: 'Function Call', className: 'role-function-call' };
345+
}
346+
if (normalized === 'function_result') {
347+
return { role: 'function_result', label: 'Function Result', className: 'role-function-result' };
348+
}
228349
return { role: 'user', label: 'User', className: 'role-user' };
229350
}
230351
};

0 commit comments

Comments
 (0)