-
- {#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}
-
+
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'}`,