diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index eb6cc9bc97..64ca912608 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -21,9 +21,18 @@ args: any argLabel?: string | undefined workspace?: string | undefined + /** Drop the header's expand-into-a-drawer button, for a caller that already offers a + * way to open the run in full. */ + disableExpand?: boolean } - let { id = undefined, args, argLabel = undefined, workspace = undefined }: Props = $props() + let { + id = undefined, + args, + argLabel = undefined, + workspace = undefined, + disableExpand = false + }: Props = $props() // Internal flag injected by "test this step" runs to suppress the asset // dispatcher. Not a real input: shown as a badge instead of a table row, @@ -125,18 +134,20 @@ ${Object.entries(displayArgs) Value {#snippet headerAction()} -
- -
+ {#if !disableExpand} +
+ +
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte index 03ab46397c..68026df96d 100644 --- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -1,6 +1,6 @@ - -
-
- - -
-

- Run {runForm.summary || runForm.path} -

- {#if runForm.summary} -

{runForm.path}

- {/if} -
-
- + +
-
- {#if hasArgs} - + {#if fades.top} +
+ {/if} +
+ {#if hasArgs} + - - {:else} -

This script takes no arguments.

+ + {:else} +

This script takes no arguments.

+ {/if} +
+ {#if fades.bottom} +
{/if} -
+
+
+ + +
+

+ Run {runForm.summary || runForm.path} +

+ {#if runForm.summary} +

{runForm.path}

+ {/if} +
+ {#if !pending} +
+ + {#if running} + + + {elapsed} + + {:else if failed} + + + Failed + + {:else if canceled && ran} + + + + {duration || 'Cancelled'} + + {:else if canceled} + + + + Not run + + {:else} + + + {duration || 'Done'} + + {/if} + +
+ {/if} +
+ + {#if pending} + + {:else} + +
+ {#if !jsonView} + (userTab = e.detail)} + class="border-t border-border-light px-3" + wrapperClass="shrink-0" + > + {#each tabs as tab (tab.value)} + + + {#snippet extra()} + {#if tab.value === 'logs' && logLineCount > 0} + {logLineCount} + {/if} + {/snippet} + + {/each} + + {/if} + + +
+ {#if jsonView} +
+ + + +
+ {:else if activeTab === 'input'} + + + {:else if activeTab === 'logs'} + {#if logs.trim()} + {#if logs.length >= MAX_LOG_LENGTH} +

+ Tail of the logs, the last {MAX_LOG_LENGTH} characters. +

+ {/if} +
{logs}
+ {:else} +

No logs yet.

+ {/if} + {#if running} +
+ + streaming +
+ {/if} + {:else if failed} +
{message.error}
+ {:else if resultValue !== undefined} + + + {:else if canceled} + +
+ +

{cancelReason}

+ {#if !ran} +

+ The parameters it would have run with are on the Parameters tab. +

+ {/if} +
+ {:else} +

This run returned no result.

+ {/if} +
+
+ + +
+ {footer} + {#if running && chatJob} + + + + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte index c4b2d0cc21..a1578cd815 100644 --- a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte @@ -1,6 +1,7 @@ {#if showWhileLoading || (!loading && hasContent) || streaming} @@ -136,15 +117,15 @@ {:else if hasContent}
-
{formatJson($state.snapshot(content))}
- {#if showFade && canScrollDown} + {#if showFade && fades.bottom}
diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index d240abb0df..f5ae22a332 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -24,7 +24,7 @@ import { getAiChatManager } from './aiChatManagerContext' const aiChatManager = getAiChatManager() - import { isActiveRunForm, isActiveUserQuestion, type ToolDisplayMessage } from './shared' + import { isActiveUserQuestion, type ToolDisplayMessage } from './shared' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -37,7 +37,7 @@ import ToolMessageActions from './ToolMessageActions.svelte' import ToolPreviewCard from './ToolPreviewCard.svelte' import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' - import RunArgsFormDisplay from './RunArgsFormDisplay.svelte' + import RunScriptCard from './RunScriptCard.svelte' import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' @@ -118,7 +118,9 @@ isActiveUserQuestion(message) ? message.userQuestion : undefined ) - const activeRunForm = $derived(isActiveRunForm(message) ? message.runForm : undefined) + // The run card owns this call from the form to whatever settled it, cancelling included: + // the card is the call, and a run the user stopped is not a different kind of thing. + const isRunCard = $derived(Boolean(message.runForm)) // The preview chip sits on the header row (to the right of the tool-call text); // shown once the tool settled, never while loading/erroring/awaiting confirmation. @@ -131,8 +133,6 @@ {#if activeUserQuestion} -{:else if activeRunForm} - {:else if message.blockedByPlanMode} @@ -145,6 +145,8 @@ {message.toolName} {/if}
+{:else if isRunCard} + {:else if planState} diff --git a/frontend/src/lib/components/copilot/chat/scrollFades.svelte.ts b/frontend/src/lib/components/copilot/chat/scrollFades.svelte.ts new file mode 100644 index 0000000000..15a8238c9f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/scrollFades.svelte.ts @@ -0,0 +1,60 @@ +/** + * Live "is there more past this edge" for one scroll region, so a fade is drawn only over + * content there actually is: a box short enough not to scroll, or scrolled to an end, shows + * its first and last line sharp. + * + * Measured rather than assumed, because the boxes it serves change height under a still + * scroll offset — a tool result streams in, a dynamic field fills its options. Put + * `container` on the scrolling element with `onscroll={measure}`, and `content` on the + * element inside it whose height moves. + */ +export function scrollFades() { + let node: HTMLElement | undefined = undefined + let top = $state(false) + let bottom = $state(false) + // Built on first attach, never at call time: this runs during component init, where + // ResizeObserver does not exist on the server. + let observer: ResizeObserver | undefined = undefined + + function measure() { + if (!node) { + top = false + bottom = false + return + } + top = node.scrollTop > 1 + bottom = node.scrollHeight - node.scrollTop - node.clientHeight > 1 + } + + function observe(el: HTMLElement) { + observer ??= new ResizeObserver(measure) + observer.observe(el) + return { + destroy() { + observer?.unobserve(el) + } + } + } + + return { + get top() { + return top + }, + get bottom() { + return bottom + }, + measure, + container(el: HTMLElement) { + node = el + measure() + const handle = observe(el) + return { + destroy() { + handle.destroy() + node = undefined + } + } + }, + content: observe + } +} diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index cb2915fc7f..bd197dceac 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -1560,7 +1560,7 @@ export async function buildSchemaForTool( // Constants for result formatting const MAX_RESULT_LENGTH = 12000 -const MAX_LOG_LENGTH = 4000 +export const MAX_LOG_LENGTH = 4000 export const MAX_RUNNABLE_CONTENT_LENGTH = 20000 /** How long a test run is awaited inline before it detaches into the background @@ -1660,6 +1660,13 @@ export async function pollJobCompletion( status: deriveChatJobStatus(fetchedJob), job: trimJob(fetchedJob) }) + // The tray's snapshot is trimmed of logs (it is persisted), so the card is the + // only place a running job's output can land. Cards that hide their logs while + // loading are unaffected; the run card follows them line by line. + const streamed = formatLogs(fetchedJob.logs) + if (streamed) { + toolCallbacks.setToolStatus(toolId, { logs: streamed }) + } } catch (error) { if (!detachEnabled && attempts >= maxAttempts) { throw error @@ -1769,9 +1776,14 @@ function backgroundedSummary(jobId: string, label: string): string { // fills its card the same way one that finished inline does. export function completedJobToolStatus(job: CompletedJob): Partial { // A canceled job isn't a `success`, but it isn't a failure either — the user - // stopped it — so don't dress the card as an error. + // stopped it — so don't dress the card as an error. It still has the result the run + // page shows for a canceled run, which names who stopped it, so keep that. if (job.canceled) { - return { content: 'Background job canceled', logs: formatLogs(job.logs) } + return { + content: 'Background job canceled', + result: formatResult(job.result), + logs: formatLogs(job.logs) + } } return { content: `Background job ${job.success ? 'completed successfully' : 'failed'}`,