Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
<pre>, and the message block's global prose rules (styles.css, unlayered)
restyle every <pre> — 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 <pre> 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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
<div class="detail-panel space-y-1 border-t border-gray-200/60 bg-gray-50/60 px-2.5 py-2 dark:border-white/5 dark:bg-black/20">
<!-- Labels sit ABOVE their values, not in a gutter beside them: a
gutter column spends width on every line of a payload that is
usually the widest thing in the rail. Long values scroll inside
a bounded box rather than being cut off behind a "show more" —
the whole payload is always there to check the summary against,
and the box keeps one big result from pushing the answer off
screen. Each box is focusable so a keyboard user can scroll it. -->
<div class="detail-panel space-y-2.5 border-t border-gray-200/60 bg-gray-50/60 px-2.5 py-2 dark:border-white/5 dark:bg-black/20">
<!-- Input -->
@if (call.input && (call.input | keyvalue)?.length) {
<div class="flex items-baseline gap-1.5">
<span class="text-xs text-gray-400 dark:text-gray-500 shrink-0">input:</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono truncate">
{{ formatInput(call.input) }}
</span>
<div class="call-input">
<div class="detail-label text-gray-500 dark:text-gray-400">Input</div>
<pre
class="detail-scroll max-h-40 font-mono text-gray-500 dark:text-gray-400"
tabindex="0"
role="group"
[attr.aria-label]="'Input for ' + call.toolName"
>{{ formatInput(call.input) }}</pre>
</div>
}

<!-- Live streaming output (e.g. artifact being generated) -->
@if (isGenerating(call)) {
<div class="flex items-start gap-1.5">
<span class="text-xs text-gray-400 dark:text-gray-500 shrink-0 mt-1">output:</span>
<div class="w-full">
<div class="flex items-center gap-1.5 mb-1 text-xs text-gray-400 dark:text-gray-500">
<span class="status-dot bg-state-warning-400 shimmer"></span>
<span>Generating output…</span>
</div>
<pre
class="max-h-56 overflow-auto whitespace-pre-wrap break-all font-mono text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/60 border border-gray-100 dark:border-gray-700/30 rounded-xs px-2 py-1.5"
aria-label="Tool output being generated"
[appPinScrollToBottom]="call.streamingContent ?? ''"
>{{ call.streamingContent }}</pre>
<div>
<div class="detail-label flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span>Output</span>
<span class="status-dot bg-state-warning-400 shimmer"></span>
<span class="font-normal">Generating…</span>
</div>
<pre
class="detail-scroll max-h-56 font-mono text-gray-500 dark:text-gray-400"
tabindex="0"
role="group"
aria-label="Tool output being generated"
[appPinScrollToBottom]="call.streamingContent ?? ''"
>{{ call.streamingContent }}</pre>
</div>
}

<!-- Result -->
@if (call.result) {
<div class="flex items-start gap-1.5">
<span class="text-xs shrink-0 mt-1"
[class.text-gray-400]="call.result.status !== 'error'"
[class.dark:text-gray-500]="call.result.status !== 'error'"
[class.text-state-danger-500]="call.result.status === 'error'"
<div class="call-result">
<div
class="detail-label"
[class.text-gray-500]="call.result.status !== 'error'"
[class.dark:text-gray-400]="call.result.status !== 'error'"
[class.text-state-danger-600]="call.result.status === 'error'"
[class.dark:text-state-danger-400]="call.result.status === 'error'"
>result:</span>
<div class="result-block text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/60 border border-gray-100 dark:border-gray-700/30 rounded-xs px-2 py-1.5 w-full overflow-hidden">
@if (isResultExpanded(call.id)) {
<!-- Full result with syntax highlighting -->
>{{ call.result.status === 'error' ? 'Error' : 'Result' }}</div>
@if (hasResultBody(call)) {
<div
class="result-block detail-scroll max-h-80 text-gray-500 dark:text-gray-400"
tabindex="0"
role="group"
[attr.aria-label]="'Result from ' + call.toolName"
>
@for (item of call.result.content; track $index) {
@if (item.json) {
<pre class="font-mono text-xs overflow-x-auto"><code [innerHTML]="formatResultContent(item) | jsonSyntaxHighlight"></code></pre>
<pre class="font-mono"><code [innerHTML]="formatResultContent(item) | jsonSyntaxHighlight"></code></pre>
} @else if (item.text) {
<div class="whitespace-pre-wrap break-all">{{ item.text }}</div>
<pre class="font-mono">{{ item.text }}</pre>
}
}
} @else {
<!-- Truncated result -->
<span class="font-mono break-all whitespace-pre-wrap text-gray-500 dark:text-gray-400">{{ truncateResult(getResultText(call)) }}</span>
}
</div>
}

@if (getResultText(call).length > 200) {
<button
type="button"
(click)="toggleFullResult(call.id)"
class="text-primary-accessible dark:text-primary-accessible-dark hover:underline mt-1 block text-xs cursor-pointer"
>
{{ isResultExpanded(call.id) ? 'Show less' : 'Show full result' }}
</button>
}
</div>
<!-- Images (if any) -->
@for (item of getResultImages(call); track $index) {
<div class="mt-1 rounded-xs overflow-hidden bg-gray-100 dark:bg-gray-800 p-1 border border-gray-200 dark:border-gray-700">
<img
[src]="getImageDataUrl(item)"
alt="Tool result image"
class="max-w-full h-auto rounded-xs"
/>
</div>
}
</div>

<!-- Images (if any) -->
@for (item of getResultImages(call); track $index) {
<div class="mt-1 rounded-xs overflow-hidden bg-gray-100 dark:bg-gray-800 p-1 border border-gray-200 dark:border-gray-700">
<img
[src]="getImageDataUrl(item)"
alt="Tool result image"
class="max-w-full h-auto rounded-xs"
/>
</div>
}
}
</div>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,12 @@ describe('ToolRailComponent', () => {
}
@if (call.result) {
<div class="call-result">
@if (isResultExpanded(call.id)) {
@for (item of call.result.content; track $index) {
@if (item.text) { <div>{{ item.text }}</div> }
}
} @else {
<span>{{ truncateResult(getResultText(call)) }}</span>
}
@if (getResultText(call).length > 200) {
<button type="button" class="toggle-result" (click)="toggleFullResult(call.id)">
{{ isResultExpanded(call.id) ? 'Show less' : 'Show full result' }}
</button>
@if (hasResultBody(call)) {
<div class="result-body">
@for (item of call.result.content; track $index) {
@if (item.text) { <pre>{{ item.text }}</pre> }
}
</div>
}
</div>
@for (item of getResultImages(call); track $index) {
Expand Down Expand Up @@ -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' } }] },
}),
],
}),
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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,
);
});
});
});
Loading