diff --git a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.css b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.css index 829efb75d..b5a6ef2e7 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.css +++ b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.css @@ -121,6 +121,63 @@ -webkit-text-fill-color: inherit; } +/* Detail labels sit above their value, not in a gutter beside it. */ +.detail-label { + margin-bottom: 0.25rem; + font-size: 11px; + font-weight: 500; + line-height: 1rem; +} + +/* The bounded box every input/output/result value renders in. Long payloads + scroll rather than being truncated; text wraps anywhere, so a long URL or + token cannot force a horizontal scrollbar. + + Styled here rather than with utilities because two of these boxes are +
, and the message block's global prose rules (styles.css, unlayered)
+ restyle every — padding, size, background — beating any Tailwind
+ utility. The encapsulated selectors below out-rank those rules in both
+ themes. */
+.detail-scroll {
+ overflow: auto;
+ margin: 0;
+ max-width: 100%;
+ border: 1px solid var(--color-gray-100);
+ border-radius: 0.125rem;
+ background-color: var(--color-gray-50);
+ padding: 0.375rem 0.5rem;
+ font-size: 0.75rem;
+ line-height: 1rem;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+:host-context(.dark) .detail-scroll {
+ border-color: color-mix(in oklab, var(--color-gray-700) 30%, transparent);
+ background-color: color-mix(in oklab, var(--color-gray-800) 60%, transparent);
+}
+
+.detail-scroll:focus-visible {
+ outline: 2px solid var(--color-primary-500);
+ outline-offset: 1px;
+}
+
+/* Result items are for whitespace fidelity; inside the box they are
+ plain flow, with a little air between consecutive items. */
+.result-block pre {
+ margin: 0;
+ padding: 0;
+ border-radius: 0;
+ background: transparent;
+ font-size: inherit;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.result-block pre + pre {
+ margin-top: 0.5rem;
+}
+
/* Muted JSON syntax highlighting for result blocks */
.result-block .json-key {
color: #6b7280; /* gray-500 */
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.html b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.html
index 723ac4a19..b5a879ded 100644
--- a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.html
+++ b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.html
@@ -126,81 +126,83 @@
reachable by screen readers on rows nobody opened. The enter
animation supplies the accordion feel without that cost. -->
@if (isCallDetailOpen(call)) {
-
+
+
@if (call.input && (call.input | keyvalue)?.length) {
-
- input:
-
- {{ formatInput(call.input) }}
-
+
+ Input
+ {{ formatInput(call.input) }}
}
@if (isGenerating(call)) {
-
- output:
-
-
-
- Generating output…
-
- {{ call.streamingContent }}
+
+
+ Output
+
+ Generating…
+ {{ call.streamingContent }}
}
@if (call.result) {
-
-
+ result:
-
- @if (isResultExpanded(call.id)) {
-
+ >{{ call.result.status === 'error' ? 'Error' : 'Result' }}
+ @if (hasResultBody(call)) {
+
@for (item of call.result.content; track $index) {
@if (item.json) {
-
+
} @else if (item.text) {
- {{ item.text }}
+ {{ item.text }}
}
}
- } @else {
-
- {{ truncateResult(getResultText(call)) }}
- }
+
+ }
- @if (getResultText(call).length > 200) {
-
- }
-
+
+ @for (item of getResultImages(call); track $index) {
+
+
+
+ }
-
-
- @for (item of getResultImages(call); track $index) {
-
-
-
- }
}
}
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.spec.ts
index a3a69aa43..aabacb94e 100644
--- a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.spec.ts
+++ b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.spec.ts
@@ -72,17 +72,12 @@ describe('ToolRailComponent', () => {
}
@if (call.result) {
- @if (isResultExpanded(call.id)) {
- @for (item of call.result.content; track $index) {
- @if (item.text) { {{ item.text }} }
- }
- } @else {
- {{ truncateResult(getResultText(call)) }}
- }
- @if (getResultText(call).length > 200) {
-
+ @if (hasResultBody(call)) {
+
+ @for (item of call.result.content; track $index) {
+ @if (item.text) { {{ item.text }} }
+ }
+
}
@for (item of getResultImages(call); track $index) {
@@ -402,29 +397,29 @@ describe('ToolRailComponent', () => {
expect(fixture.nativeElement.querySelector('.call-input')).toBeNull();
});
- // Split into two cases on purpose: the detail is now a real `@if`, so a
- // second `toggle*` call in one case folds it back up and the assertion
- // silently measures the wrong state.
- it('offers no "show full result" for a short result', () => {
+ it('renders a long result in full, with no "show full result" toggle', () => {
+ // Long results scroll inside a bounded box instead of being cut off
+ // behind a toggle — the whole payload is always there to check.
+ const long = 'A'.repeat(300);
render(
makeGroup({
- calls: [makeCall({ result: { status: 'success', content: [{ text: 'short' }] } })],
+ calls: [makeCall({ result: { status: 'success', content: [{ text: long }] } })],
}),
);
component.toggleExpanded();
component.toggleCallDetail('tool-1');
fixture.detectChanges();
- expect(fixture.nativeElement.querySelector('.call-result')).toBeTruthy();
- expect(fixture.nativeElement.querySelector('.toggle-result')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.result-body').textContent).toContain(long);
+ expect(fixture.nativeElement.textContent).not.toContain('Show full result');
});
- it('offers "show full result" for a truncated one', () => {
+ it('renders no result body for an image-only result', () => {
render(
makeGroup({
calls: [
makeCall({
- result: { status: 'success', content: [{ text: 'A'.repeat(300) }] },
+ result: { status: 'success', content: [{ image: { format: 'png', data: 'x' } }] },
}),
],
}),
@@ -433,19 +428,8 @@ describe('ToolRailComponent', () => {
component.toggleCallDetail('tool-1');
fixture.detectChanges();
- expect(fixture.nativeElement.querySelector('.toggle-result')).toBeTruthy();
- });
-
- it('tracks result expansion per call', () => {
- render(
- makeGroup({
- calls: [makeCall({ id: 'a' }), makeCall({ id: 'b' })],
- }),
- );
-
- component.toggleFullResult('a');
- expect(component.isResultExpanded('a')).toBe(true);
- expect(component.isResultExpanded('b')).toBe(false);
+ expect(fixture.nativeElement.querySelector('.call-result')).toBeTruthy();
+ expect(fixture.nativeElement.querySelector('.result-body')).toBeNull();
});
it('renders images from result content', () => {
@@ -634,20 +618,10 @@ describe('ToolRailComponent', () => {
expect(component.formatDuration(250)).toBe('250ms');
});
- it('formats input as key-value pairs', () => {
- const result = component.formatInput({ query: 'test', limit: 5 });
- expect(result).toContain('query: "test"');
- expect(result).toContain('limit: 5');
- });
-
- it('truncates long text', () => {
- const truncated = component.truncateResult('A'.repeat(300), 200);
- expect(truncated.length).toBe(203);
- expect(truncated.endsWith('...')).toBe(true);
- });
-
- it('leaves short text alone', () => {
- expect(component.truncateResult('Hello')).toBe('Hello');
+ it('formats input as one key-value pair per line', () => {
+ expect(component.formatInput({ query: 'test', limit: 5 })).toBe(
+ 'query: "test"\nlimit: 5',
+ );
});
it('builds an image data URL', () => {
@@ -660,27 +634,25 @@ describe('ToolRailComponent', () => {
expect(component.getImageDataUrl({ text: 'hello' })).toBe('');
});
- it('combines text and json result items', () => {
- const text = component.getResultText(
- makeCall({
- result: {
- status: 'success',
- content: [{ text: 'hello' }, { json: { key: 'value' } }],
- },
- }),
- );
- expect(text).toContain('hello');
- expect(text).toContain('"key"');
+ it('has a result body for text or json items', () => {
+ expect(
+ component.hasResultBody(
+ makeCall({ result: { status: 'success', content: [{ json: { key: 'value' } }] } }),
+ ),
+ ).toBe(true);
});
- it('represents an image item as [image]', () => {
+ it('has no result body for an image-only or empty result', () => {
expect(
- component.getResultText(
+ component.hasResultBody(
makeCall({
result: { status: 'success', content: [{ image: { format: 'png', data: 'x' } }] },
}),
),
- ).toBe('[image]');
+ ).toBe(false);
+ expect(component.hasResultBody(makeCall({ result: { status: 'success', content: [] } }))).toBe(
+ false,
+ );
});
});
});
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.ts
index 3ffc4293d..8a3a68c4c 100644
--- a/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.ts
+++ b/frontend/ai.client/src/app/session/components/message-list/components/tool-rail/tool-rail.component.ts
@@ -38,9 +38,6 @@ export class ToolRailComponent {
*/
expandedCallIds = signal>(new Set());
- /** Track which individual tool results are fully expanded (for long results in fallback mode) */
- expandedResultIds = signal>(new Set());
-
/**
* The group's calls segmented by backend batch.
*
@@ -146,24 +143,6 @@ export class ToolRailComponent {
return this.isGenerating(call) || this.expandedCallIds().has(call.id);
}
- /** Toggle full result display for a specific tool call */
- toggleFullResult(callId: string): void {
- this.expandedResultIds.update(ids => {
- const next = new Set(ids);
- if (next.has(callId)) {
- next.delete(callId);
- } else {
- next.add(callId);
- }
- return next;
- });
- }
-
- /** Check if a tool call's result is fully expanded */
- isResultExpanded(callId: string): boolean {
- return this.expandedResultIds().has(callId);
- }
-
/** CSS class for status dot */
statusDotClass(call: ToolCallDisplay): string {
switch (call.status) {
@@ -188,31 +167,16 @@ export class ToolRailComponent {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
}
- /** Compact one-line display of tool input params */
+ /** Tool input params, one `key: value` per line */
formatInput(inputObj: Record): string {
return Object.entries(inputObj)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
- .join(', ');
- }
-
- /** Get combined text from result content array, for truncation */
- getResultText(call: ToolCallDisplay): string {
- if (!call.result?.content) return '';
- return call.result.content
- .map(item => {
- if (item.text) return item.text;
- if (item.json) return JSON.stringify(item.json, null, 2);
- if (item.image) return '[image]';
- return '';
- })
- .filter(Boolean)
.join('\n');
}
- /** Truncate result text for collapsed display */
- truncateResult(text: string, maxLen = 200): string {
- if (text.length <= maxLen) return text;
- return text.substring(0, maxLen) + '...';
+ /** Whether the result carries any text or JSON to show (images render separately). */
+ hasResultBody(call: ToolCallDisplay): boolean {
+ return !!call.result?.content?.some(item => item.text || item.json);
}
/** Get image items from result content */
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/tool-use/json-syntax-highlight.pipe.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/tool-use/json-syntax-highlight.pipe.spec.ts
new file mode 100644
index 000000000..d35319df5
--- /dev/null
+++ b/frontend/ai.client/src/app/session/components/message-list/components/tool-use/json-syntax-highlight.pipe.spec.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { DomSanitizer } from '@angular/platform-browser';
+import { JsonSyntaxHighlightPipe } from './json-syntax-highlight.pipe';
+
+const passthrough = { bypassSecurityTrustHtml: (html: string) => html } as unknown as DomSanitizer;
+
+function highlight(value: unknown): string {
+ return new JsonSyntaxHighlightPipe(passthrough).transform(
+ JSON.stringify(value, null, 2),
+ ) as string;
+}
+
+function text(html: string): string {
+ return html.replace(/<[^>]+>/g, '');
+}
+
+describe('JsonSyntaxHighlightPipe', () => {
+ it('leaves the text of string values untouched', () => {
+ // The chained-replace version rewrote "09:30:17" as "09: 30: 17".
+ const value = { current_time: '2026-09-30T09:30:17-06:00', note: 'a: true, b: null {x}' };
+ expect(text(highlight(value))).toBe(JSON.stringify(value, null, 2));
+ });
+
+ it('classifies keys, strings, numbers, booleans, null and brackets', () => {
+ const html = highlight({ k: 'v', n: -1.5, t: true, z: null, a: [1] });
+ expect(html).toContain('"k":');
+ expect(html).toContain('"v"');
+ expect(html).toContain('-1.5');
+ expect(html).toContain('true');
+ expect(html).toContain('null');
+ expect(html).toContain('[');
+ });
+
+ it('escapes HTML inside values', () => {
+ expect(highlight({ x: '
' })).toContain('<img src=x>');
+ });
+
+ it('keeps escaped quotes inside a string', () => {
+ const value = { q: 'say "hi": 5' };
+ expect(text(highlight(value))).toBe(JSON.stringify(value, null, 2));
+ });
+});
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/tool-use/json-syntax-highlight.pipe.ts b/frontend/ai.client/src/app/session/components/message-list/components/tool-use/json-syntax-highlight.pipe.ts
index b27411516..12a9862ad 100644
--- a/frontend/ai.client/src/app/session/components/message-list/components/tool-use/json-syntax-highlight.pipe.ts
+++ b/frontend/ai.client/src/app/session/components/message-list/components/tool-use/json-syntax-highlight.pipe.ts
@@ -18,38 +18,26 @@ export class JsonSyntaxHighlightPipe implements PipeTransform {
.replace(//g, '>');
- // Apply syntax highlighting
- const highlighted = escaped
- // Highlight keys (property names)
- .replace(
- /"([^"]+)"(?=\s*:)/g,
- '"$1"'
- )
- // Highlight string values
- .replace(
- /:\s*"([^"]*)"/g,
- ': "$1"'
- )
- // Highlight numbers
- .replace(
- /:\s*(\d+\.?\d*)/g,
- ': $1'
- )
- // Highlight booleans
- .replace(
- /:\s*(true|false)/g,
- ': $1'
- )
- // Highlight null
- .replace(
- /:\s*(null)/g,
- ': $1'
- )
- // Highlight brackets
- .replace(
- /([{}\[\]])/g,
- '$1'
- );
+ // One pass over the whole document, so each token is classified exactly
+ // once. Chained per-kind replaces re-scanned text already inside a string
+ // and highlighted it again — a timestamp value like "09:30:17" came out as
+ // "09: 30: 17", because the number rule matched the `:30` inside it.
+ const highlighted = escaped.replace(
+ /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}[\]]/g,
+ (match: string, str?: string, colon?: string) => {
+ if (str) {
+ return colon
+ ? `${str}${colon}`
+ : `${str}`;
+ }
+ if (match === 'true' || match === 'false') {
+ return `${match}`;
+ }
+ if (match === 'null') return `${match}`;
+ if (/[{}[\]]/.test(match)) return `${match}`;
+ return `${match}`;
+ },
+ );
return this.sanitizer.bypassSecurityTrustHtml(highlighted);
}