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) { +
+ Tool result image +
+ }
- - - @for (item of getResultImages(call); track $index) { -
- Tool result image -
- } }
} 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); }