From a08992834d45d0211336f4fc32c3421646ca47c5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 18 Sep 2026 14:38:46 +0200 Subject: [PATCH] feat: render an AI agent result as its answer, not as raw JSON (#11051) * feat: render an AI agent result as its answer, not as raw JSON Co-Authored-By: Claude Opus 5 (1M context) * fix: sanitize agent markdown through the shared plugin chain Co-Authored-By: Claude Opus 5 (1M context) * refactor: fold agent stream events incrementally per poll Co-Authored-By: Claude Opus 5 (1M context) * fix: separate the agent meta line from the result toggle group Co-Authored-By: Claude Opus 5 (1M context) * feat: add a transcript view of an agent run's conversation Co-Authored-By: Claude Opus 5 (1M context) * refactor: retire AIAgentLogViewer in favour of the transcript Co-Authored-By: Claude Opus 5 (1M context) * feat: show the partial transcript a max-iterations failure carries Co-Authored-By: Claude Opus 5 (1M context) * fix: move the agent meta line and system prompt below the conversation Co-Authored-By: Claude Opus 5 (1M context) * refactor: show an agent run as what it did, not as a conversation Co-Authored-By: Claude Opus 5 (1M context) * refactor: name the agent run breakdown a trace Co-Authored-By: Claude Opus 5 (1M context) * fix(ai-agent): label the save-as-agent form fields per the guidelines Co-Authored-By: Claude Opus 5 (1M context) * fix(ai-agent): reuse the resource form's path and description fields Co-Authored-By: Claude Opus 5 (1M context) * fix(ai-agent): keep the action tags on a max-iterations failure Co-Authored-By: Claude Opus 5 (1M context) * fix(ai-agent): keep offline replay inert and the streamed answer to one turn Co-Authored-By: Claude Opus 5 (1M context) * fix(ai-agent): reset the streamed answer on providers that skip tool_call Co-Authored-By: Claude Opus 5 (1M context) * fix: carry the event type narrowing through the stream parser Co-Authored-By: Claude Opus 5 (1M context) * fix: survive a malformed message rather than take the viewer down Co-Authored-By: Claude Opus 5 (1M context) * refactor: coerce agent messages once at the parse boundary Co-Authored-By: Claude Opus 5 (1M context) * feat: show an agent run as one scroll ending in its output Co-Authored-By: Claude Opus 5 (1M context) * fix: keep every streamed turn instead of dropping the narration Co-Authored-By: Claude Opus 5 (1M context) * fix: share the chat divider and drop the unsafe run auto-scroll Co-Authored-By: Claude Opus 5 (1M context) * fix: give the labelled divider a border colour and the standard pretty icon Co-Authored-By: Claude Opus 5 (1M context) * fix: pad the agent run below its badges as well as above Co-Authored-By: Claude Opus 5 (1M context) * feat: follow a streaming run's pane without moving the page Co-Authored-By: Claude Opus 5 (1M context) * refactor: share the chat's stick-to-bottom mechanics with the agent run Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the answer's citations and end a turn's reasoning with the turn Co-Authored-By: Claude Opus 5 (1M context) * refactor: read the agent stream through the windmill-chat sdk parser Co-Authored-By: Claude Opus 5 (1M context) * fix: show an agent run's thinking instead of falling back to raw json Co-Authored-By: Claude Opus 5 (1M context) * fix: stop a stream the fold cannot use from claiming the run pane Co-Authored-By: Claude Opus 5 (1M context) * fix: identify an agent run by more than the job id the replay withholds Co-Authored-By: Claude Opus 5 (1M context) * refactor: drop the tests and comment lines that were not earning their place Co-Authored-By: Claude Opus 5 (1M context) * fix: stop a streamed turn's text shifting when a tool call closes it Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-worker/src/ai_executor.rs | 50 ++- .../lib/components/AIAgentLogViewer.svelte | 281 --------------- .../lib/components/AgentResultDisplay.svelte | 116 ++++++ .../lib/components/AgentStreamDisplay.svelte | 111 ++++++ frontend/src/lib/components/AgentTrace.svelte | 135 +++++++ .../src/lib/components/DisplayResult.svelte | 78 +++- .../src/lib/components/FlowJobResult.svelte | 49 ++- .../src/lib/components/FlowLogViewer.svelte | 48 +-- .../components/FlowLogViewerWrapper.svelte | 5 +- .../components/FlowStatusViewerInner.svelte | 36 +- .../src/lib/components/LabeledDivider.svelte | 19 + frontend/src/lib/components/Login.svelte | 7 +- .../ModulePreviewResultViewer.svelte | 17 +- .../ResourceDescriptionField.svelte | 59 +++ .../src/lib/components/ResourceForm.svelte | 37 +- .../lib/components/ResourcePathHint.svelte | 5 +- frontend/src/lib/components/agentScroll.ts | 17 + .../src/lib/components/agentTrace.test.ts | 182 ++++++++++ frontend/src/lib/components/agentTrace.ts | 134 +++++++ .../src/lib/components/aiAgentResult.test.ts | 257 +++++++++++++ frontend/src/lib/components/aiAgentResult.ts | 341 ++++++++++++++++++ .../copilot/chat/AIChatDisplay.svelte | 30 +- .../copilot/chat/CompactionBoundary.svelte | 7 +- .../chat/WebSearchSourcesDisplay.svelte | 9 +- .../flows/content/AgentResourceBar.svelte | 43 ++- .../flows/content/FlowModuleComponent.svelte | 6 - frontend/src/lib/components/stickToBottom.ts | 47 +++ 27 files changed, 1633 insertions(+), 493 deletions(-) delete mode 100644 frontend/src/lib/components/AIAgentLogViewer.svelte create mode 100644 frontend/src/lib/components/AgentResultDisplay.svelte create mode 100644 frontend/src/lib/components/AgentStreamDisplay.svelte create mode 100644 frontend/src/lib/components/AgentTrace.svelte create mode 100644 frontend/src/lib/components/LabeledDivider.svelte create mode 100644 frontend/src/lib/components/ResourceDescriptionField.svelte create mode 100644 frontend/src/lib/components/agentScroll.ts create mode 100644 frontend/src/lib/components/agentTrace.test.ts create mode 100644 frontend/src/lib/components/agentTrace.ts create mode 100644 frontend/src/lib/components/aiAgentResult.test.ts create mode 100644 frontend/src/lib/components/aiAgentResult.ts create mode 100644 frontend/src/lib/components/stickToBottom.ts diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index efc7ffa1b3..3c3a26875b 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -79,6 +79,15 @@ lazy_static::lazy_static! { const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; const HARD_MAX_AGENT_ITERATIONS: usize = 1000; +/// What a run stopped by `max_iterations` reports back. `Message` rather than +/// `OpenAIMessage` is load-bearing: `agent_action` is `skip_serializing` on the +/// latter and reaches JSON only through this wrapper, so serializing these raw +/// drops every tool name and job id and leaves the partial run unreadable. +#[derive(serde::Serialize)] +struct MaxIterPartialResult<'a> { + messages: Vec>, +} + fn strip_system_messages(messages: &[OpenAIMessage]) -> Vec { messages .iter() @@ -1785,10 +1794,6 @@ pub async fn run_agent( step_id: Option<&'a str>, result: MaxIterPartialResult<'a>, } - #[derive(serde::Serialize)] - struct MaxIterPartialResult<'a> { - messages: &'a [OpenAIMessage], - } return Err(Error::ExecutionRawError( serde_json::value::to_raw_value(&MaxIterError { message: format!( @@ -1797,7 +1802,15 @@ pub async fn run_agent( ), name: "ExecutionErr", step_id: effective_flow_step_id, - result: MaxIterPartialResult { messages: &messages }, + result: MaxIterPartialResult { + messages: messages + .iter() + .map(|m| Message { + message: m, + agent_action: m.agent_action.as_ref(), + }) + .collect(), + }, })?, )); } @@ -2322,6 +2335,33 @@ mod tests { assert!(!streaming_requested(Some(false))); } + #[test] + fn max_iterations_partial_result_keeps_the_action_tags() { + let messages = vec![OpenAIMessage { + role: "tool".to_string(), + content: Some(OpenAIContent::Text("{\"rows\":2}".to_string())), + tool_call_id: Some("call_1".to_string()), + agent_action: Some(AgentAction::ToolCall { + job_id: uuid::Uuid::nil(), + function_name: "list_payouts".to_string(), + module_id: "b".to_string(), + }), + ..Default::default() + }]; + + let partial = MaxIterPartialResult { + messages: messages + .iter() + .map(|m| Message { message: m, agent_action: m.agent_action.as_ref() }) + .collect(), + }; + let json = serde_json::to_value(&partial).unwrap(); + + let action = &json["messages"][0]["agent_action"]; + assert_eq!(action["type"], "tool_call"); + assert_eq!(action["function_name"], "list_payouts"); + } + /// Over 64 characters OpenAI rejects the key outright, which costs a wasted round /// trip per run and silently leaves that step with no prompt caching at all. #[test] diff --git a/frontend/src/lib/components/AIAgentLogViewer.svelte b/frontend/src/lib/components/AIAgentLogViewer.svelte deleted file mode 100644 index 2549fbb29f..0000000000 --- a/frontend/src/lib/components/AIAgentLogViewer.svelte +++ /dev/null @@ -1,281 +0,0 @@ - - -{#if job} -
- {}} - mode="aiagent" - /> -
-{/if} diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte new file mode 100644 index 0000000000..0388ab0845 --- /dev/null +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -0,0 +1,116 @@ + + +
+ {#if trace.length > 0} + + {/if} + {#if reasoning} + + (reasoningExpanded = !reasoningExpanded)} + contentClass="font-main" + > + + + {/if} + {#if trace.length > 0 || reasoning} + + Output + + {/if} +
+ {#if textOutput !== undefined} + {#if textOutput === ''} + The agent returned no answer + {:else} + + + {/if} + {:else} + {@render structuredOutput(result.output)} + {/if} + {#if answer.sources} +
+ +
+ {/if} +
+ + +
+ {#if summary.toolCalls > 0} + + {summary.toolCalls} + {summary.toolCalls === 1 ? 'tool call' : 'tool calls'} + + {/if} + {#if summary.webSearches > 0} + + {summary.webSearches} + {summary.webSearches === 1 ? 'web search' : 'web searches'} + + {/if} + {#if summary.tokens !== undefined} + {formatTokenCount(summary.tokens)} tokens + {/if} + {#if summary.cachedTokens} + {formatTokenCount(summary.cachedTokens)} cached + {/if} +
+
diff --git a/frontend/src/lib/components/AgentStreamDisplay.svelte b/frontend/src/lib/components/AgentStreamDisplay.svelte new file mode 100644 index 0000000000..cf1646a614 --- /dev/null +++ b/frontend/src/lib/components/AgentStreamDisplay.svelte @@ -0,0 +1,111 @@ + + + +
+ {#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)} + {#if entry.kind === 'tool'} + {}} + labelClass={entry.success === false ? 'text-red-500' : ''} + /> + {:else} +
+ +
+ {/if} + {/each} + + {#if stream.current !== ''} +
+ + +
+ {:else if stream.reasoning !== ''} + +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte new file mode 100644 index 0000000000..8c151e192c --- /dev/null +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -0,0 +1,135 @@ + + +
+ {#each entries as entry, index (index)} + {#if entry.kind === 'assistant'} +
+ + {#if entry.sources} +
+ +
+ {/if} +
+ {:else if entry.kind === 'search'} + + {}} + /> + {:else} + {@const job = jobOf(entry.jobId)} + toggle(index, entry)} + contentClass="space-y-3" + > + {#if entry.args} + + {/if} + {#if job?.logs} + + {/if} + + {#if entry.resourcePath} +
+ + {entry.resourcePath} +
+ {:else if entry.jobId} + + + Open job + + {/if} +
+ {/if} + {/each} +
diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 47bafa4d5a..d0de2026cf 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -54,6 +54,11 @@ import DOMPurify from 'dompurify' import MarkupApprovalGate from './MarkupApprovalGate.svelte' import type { MarkupTrust } from './apps/markupTrust' + import AgentResultDisplay from './AgentResultDisplay.svelte' + import AgentStreamDisplay from './AgentStreamDisplay.svelte' + import AgentTrace from './AgentTrace.svelte' + import { isAgentStream, parseAgentErrorMessages, parseAgentResult } from './aiAgentResult' + import { buildAgentTrace } from './agentTrace' const TABLE_MAX_SIZE = 5000000 const DISPLAY_MAX_SIZE = 100000 @@ -85,6 +90,7 @@ | 'map' | 'nondisplayable' | 'pdf' + | 'aiagent' | undefined let resultKind: ResultKind = $state() /** Kinds whose renderer leaves the page: S3/ducklake previews fetch the file or @@ -94,7 +100,10 @@ const REPLAY_INERT_KINDS: ResultKind[] = ['s3object', 's3object-list', 'materialized', 'approval'] /** Kinds whose markup pulls subresources: DOMPurify stops scripting but keeps * `` and SVG ``, and `map` tiles are requests by - * construction. Kinds absent here carry their bytes as `data:` and reach nothing. + * construction. Kinds absent here carry their bytes as `data:` and reach nothing, + * or render through a component that is itself inert on the public page — + * `aiagent` is the second case, via `GfmMarkdown`, which is why it renders + * markdown yet is not listed while `markdown` still is. * Inert only on the public page, which promises to issue no requests. */ const OFFLINE_INERT_KINDS: ResultKind[] = ['markdown', 'html', 'svg', 'map'] let length = $state(1) @@ -110,6 +119,12 @@ filename?: string | undefined disableExpand?: boolean jobId?: string | undefined + /** + * Which run this result belongs to. Separate from `jobId`, which the replay + * page withholds so nothing fetches: the agent views still have to tell one + * run from the next, or a second one continues the first one's fold. + */ + runKey?: string | undefined workspaceId?: string | undefined hideAsJson?: boolean noControls?: boolean @@ -135,6 +150,7 @@ filename = undefined, disableExpand = false, jobId = undefined, + runKey = undefined, workspaceId = undefined, hideAsJson = false, noControls = false, @@ -154,6 +170,16 @@ growVertical = false }: Props = $props() let s3FileDisplayRawMode = $state(false) + /** What a max-iterations failure got through before it gave up, if this is one. + * Empty for a run that failed before the worker tagged anything, and for one + * that predates the tags reaching this payload at all — in which case the + * section is not rendered rather than heading an empty box. */ + let agentErrorTrace = $derived.by(() => { + const messages = parseAgentErrorMessages(result) + if (!messages) return undefined + const entries = buildAgentTrace(messages) + return entries.length > 0 ? entries : undefined + }) // Build the image/PDF source URL for an S3 object. When `appPath` is set // (deployed app view) the read is authorized on-behalf of the app author via @@ -293,6 +319,17 @@ return 'materialized' } + // Classified before the size caps below: an agent's answer stays small + // however long its conversation grows, so a run with a long trace + // must not fall back to the JSON tree that hides the answer inside it. + // `largeObject` is still set honestly, so switching to JSON gets the + // same too-big handling as any other oversized result. + if (parseAgentResult(result)) { + is_render_all = false + largeObject = roughSizeOfObject(result) > DISPLAY_MAX_SIZE + return 'aiagent' + } + is_render_all = keys.length == 1 && keys.includes('render_all') && Array.isArray(result['render_all']) @@ -731,7 +768,13 @@
Streaming result
- + {#if isAgentStream(result_stream)} + + + {:else} + + {/if} {:else if is_render_all}
@@ -973,6 +1016,16 @@ {/if} {@render children?.()}
+ {#if agentErrorTrace} + +
+ Trace + +
+ {/if} {#if !isTest && language === 'bun'}
@@ -1229,6 +1282,27 @@ {/each} + {:else if !forceJson && resultKind === 'aiagent'} + {@const agentResult = parseAgentResult(result)} + {#if agentResult} + + {#snippet structuredOutput(output)} + + {/snippet} + + {/if} {:else if !forceJson && resultKind === 'markdown'}
diff --git a/frontend/src/lib/components/FlowJobResult.svelte b/frontend/src/lib/components/FlowJobResult.svelte index eea95a6ecf..66e30aefec 100644 --- a/frontend/src/lib/components/FlowJobResult.svelte +++ b/frontend/src/lib/components/FlowJobResult.svelte @@ -2,10 +2,7 @@ import { Loader2 } from 'lucide-svelte' import DisplayResult from './DisplayResult.svelte' import LogViewer from './LogViewer.svelte' - import type { CompletedJob, Job } from '$lib/gen' - import AiAgentLogViewer from './AIAgentLogViewer.svelte' import { twMerge } from 'tailwind-merge' - import type { AgentTool } from './flows/agentToolUtils' interface Props { waitingForExecutor?: boolean @@ -16,17 +13,13 @@ loading: boolean filename?: string | undefined jobId?: string | undefined + /** Identifies the run, which `jobId` cannot on the replay page. */ + runKey?: string | undefined tag?: string | undefined workspaceId?: string | undefined refreshLog?: boolean downloadLogs?: boolean tagLabel?: string | undefined - aiAgentStatus?: { - tools: AgentTool[] - agentJob: Partial & Pick & { type: 'CompletedJob' } - storedToolCallJobs?: Record - onToolJobLoaded?: (job: Job, idx: number) => void - } } let { @@ -38,11 +31,11 @@ loading, filename = undefined, jobId = undefined, + runKey = undefined, tag = undefined, workspaceId = undefined, downloadLogs = true, - tagLabel = undefined, - aiAgentStatus = undefined + tagLabel = undefined }: Props = $props() @@ -60,7 +53,15 @@ : 'max-h-80'} overflow-auto rounded-md grow min-h-0 border bg-surface-tertiary p-2" > {#if result !== undefined || result_stream !== undefined} - + {:else if loading} {:else} @@ -70,19 +71,15 @@
Logs - {#if aiAgentStatus} - - {:else} -
- -
- {/if} +
+ +
diff --git a/frontend/src/lib/components/FlowLogViewer.svelte b/frontend/src/lib/components/FlowLogViewer.svelte index 385d028e7e..2f466041d7 100644 --- a/frontend/src/lib/components/FlowLogViewer.svelte +++ b/frontend/src/lib/components/FlowLogViewer.svelte @@ -49,7 +49,6 @@ ) => Promise getSelectedIteration: (stepId: string) => number flowSummary?: string - mode?: 'flow' | 'aiagent' currentId?: string | null navigationChain?: NavigationChain select: (id: string) => void @@ -81,7 +80,6 @@ onSelectedIteration, getSelectedIteration, flowSummary, - mode = 'flow', currentId, navigationChain = $bindable(), select, @@ -127,18 +125,16 @@ function getStepProgress(job: RootJobData | undefined, totalSteps: number): string { if (!job || totalSteps === 0) return '' - const stepWord = mode === 'aiagent' ? 'action' : 'step' - // If flow is completed, show total steps if (job.type === 'CompletedJob') { - return ` (${totalSteps} ${stepWord}${totalSteps === 1 ? '' : 's'})` + return ` (${totalSteps} step${totalSteps === 1 ? '' : 's'})` } // If flow is running, use flow_status.step if available (like JobStatus.svelte) if (job.type === 'QueuedJob') { if (job.flow_status?.step !== undefined) { const currentStep = (job.flow_status.step ?? 0) + 1 - return ` (${stepWord} ${currentStep} of ${totalSteps})` + return ` (step ${currentStep} of ${totalSteps})` } return '' @@ -558,7 +554,7 @@ {@render flowIcon(getFlowStatus(rootJob), flowInfo?.hasErrors)}
- {mode === 'aiagent' ? 'AI Agent' : level == 0 ? 'Flow' : 'Subflow'} + {level == 0 ? 'Flow' : 'Subflow'} {#if flowInfo?.label} : {flowInfo.label} {/if} @@ -703,32 +699,22 @@
- {#if mode === 'aiagent'} - {#if module.summary} - Tool call: {module.summary} - {:else} - Message - {/if} - {:else} - {module.id} - {/if} + {module.id} - {#if mode === 'flow'} - {#if module.value.type === 'forloopflow'} - For loop - {:else if module.value.type === 'whileloopflow'} - While loop - {:else if module.value.type === 'branchall'} - Branch to all - {:else if module.value.type === 'branchone'} - Branch to one - {:else if module.value.type === 'flow'} - Subflow - {:else} - Step - {/if} + {#if module.value.type === 'forloopflow'} + For loop + {:else if module.value.type === 'whileloopflow'} + While loop + {:else if module.value.type === 'branchall'} + Branch to all + {:else if module.value.type === 'branchone'} + Branch to one + {:else if module.value.type === 'flow'} + Subflow + {:else} + Step {/if} - {#if module.summary && mode !== 'aiagent'} + {#if module.summary} : {module.summary} {/if} {#if hasEmptySubflowValue} diff --git a/frontend/src/lib/components/FlowLogViewerWrapper.svelte b/frontend/src/lib/components/FlowLogViewerWrapper.svelte index 648dbc9f06..12abe9bfb2 100644 --- a/frontend/src/lib/components/FlowLogViewerWrapper.svelte +++ b/frontend/src/lib/components/FlowLogViewerWrapper.svelte @@ -20,7 +20,6 @@ | { id: string; index: number; manuallySet: true; moduleId: string } | { manuallySet: false; moduleId: string } ) => Promise - mode?: 'flow' | 'aiagent' } let { @@ -29,8 +28,7 @@ localDurationStatuses, workspaceId, render, - onSelectedIteration, - mode = 'flow' + onSelectedIteration }: Props = $props() // State for tracking expanded rows - using Record to allow explicit control @@ -180,7 +178,6 @@ {render} {getSelectedIteration} flowId="root" - {mode} {currentId} bind:navigationChain {select} diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 3b860dc3a1..c082825e83 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -65,7 +65,6 @@ import { getActiveReplay } from './recording/replay.svelte' import { publishLinkedAgentTools } from './flows/flowState' import { - getLinkedAgentTools, linkedToolsScope, releaseLinkedToolsScope, retainLinkedToolsScope @@ -2121,6 +2120,7 @@ tagLabel={customUi?.tagLabel} workspaceId={isReplay ? undefined : job?.workspace_id} jobId={isReplay ? undefined : job?.id} + runKey={job?.id} filename={job.id} loading={job['running']} tag={job?.tag} @@ -2142,15 +2142,6 @@

No arguments

{/if} {:else if node} - {@const module = - stepDetail && typeof stepDetail !== 'string' ? stepDetail : undefined} - {@const agentTools = - module && module.value.type === 'aiagent' - ? module.value.agent - ? getLinkedAgentTools(linkedToolsViewScope, module.id) - : (module.value.tools ?? []) - : undefined} - {@const parentLoopsPrefix = getParentLoopsPrefix(module?.id ?? '')} {#if node.flow_jobs_results}
{ - if (module) { - const storeKey = parentLoopsPrefix + module.id + '-' + idx - toolCallStore?.setStoredToolCallJob(storeKey, job) - } - } - } - : undefined} />
diff --git a/frontend/src/lib/components/LabeledDivider.svelte b/frontend/src/lib/components/LabeledDivider.svelte new file mode 100644 index 0000000000..145abc111f --- /dev/null +++ b/frontend/src/lib/components/LabeledDivider.svelte @@ -0,0 +1,19 @@ + + + +
+
+ {@render children()} +
+
diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 66c13c4ea3..76f4a2b995 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -1,5 +1,6 @@ + +
+

{label} + + {#if canWrite} +

+ {#if canWrite && editing} +
+
GH Markdown
+ +
+ {:else if description == undefined || description == ''} +
No description provided
+ {:else} + + {/if} +
diff --git a/frontend/src/lib/components/ResourceForm.svelte b/frontend/src/lib/components/ResourceForm.svelte index df1aa5bd99..df878280a7 100644 --- a/frontend/src/lib/components/ResourceForm.svelte +++ b/frontend/src/lib/components/ResourceForm.svelte @@ -1,4 +1,5 @@
-
-
+ -
-
+ {#if expanded}
() + // The favicon is a request to a third party, and the public replay page promises + // to issue none — a recording comes from an arbitrary origin, so its cited + // hostnames must not leak from a viewer's browser either. Degrades to the same + // Globe the blocked/failed case already uses. + const noFavicons = $derived(isOfflineReplay()) + // Favicons come from Google's public favicon service, which discloses each // consulted hostname to a third party from the user's browser — an accepted // tradeoff for now (blocked/air-gapped environments degrade to the Globe @@ -62,7 +69,7 @@ title={source.url} class="flex items-center gap-2 py-1 px-1.5 rounded hover:bg-surface-hover min-w-0" > - {#if failedFavicons.has(hostname)} + {#if noFavicons || failedFavicons.has(hostname)} {:else} + import ResourceDescriptionField from '$lib/components/ResourceDescriptionField.svelte' import { Button, Drawer, DrawerContent } from '$lib/components/common' import Alert from '$lib/components/common/alert/Alert.svelte' import Badge from '$lib/components/common/badge/Badge.svelte' import Path from '$lib/components/Path.svelte' - import TextInput from '$lib/components/text_input/TextInput.svelte' + import Label from '$lib/components/Label.svelte' + import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import { ResourceService, type InputTransform, type Resource } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' @@ -700,27 +702,30 @@

Save this AI agent's configuration and tools as a reusable resource. Other flows can then - link to it, updates propagate automatically, and it gains a dataset of eval cases of its - own. + link to it, and updates propagate automatically.

- - + {#if providerSaveError ?? legacyMemorySaveError} -

+ +

{providerSaveError ?? legacyMemorySaveError}

{/if} diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index f51703dc22..6c2093caa6 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -1474,12 +1474,6 @@ {testJob} {scriptProgress} mod={flowModule} - linkedAgentTools={agentLinked - ? getLinkedAgentTools( - linkedToolsScope(opWs, $pathStore), - linkedToolsModuleId - ) - : undefined} {testIsLoading} disableMock={preprocessorModule || failureModule} disableHistory={failureModule} diff --git a/frontend/src/lib/components/stickToBottom.ts b/frontend/src/lib/components/stickToBottom.ts new file mode 100644 index 0000000000..f08d2cfe04 --- /dev/null +++ b/frontend/src/lib/components/stickToBottom.ts @@ -0,0 +1,47 @@ +/** + * Keeps a growing pane pinned to its end, for the chat transcript and the agent run + * viewer. Shared for one non-obvious guard: a programmatic scroll dispatches its + * `scroll` event asynchronously, so content landing in between widens the gap for a + * tick, which reads as the reader scrolling away and disengages the follow. + */ + +/** + * Distance from the end within which a reader counts as still following. Allows + * for sub-pixel rounding from `scrollTo` and the occasional overscroll bounce. + */ +const STICK_TO_BOTTOM_PX = 8 + +/** A scroll event this close after our own scroll is ours, not the reader's. */ +const OWN_SCROLL_WINDOW_MS = 120 + +export type BottomSticker = { + /** Jump to the end. Instant: smooth would animate every append and race the next. */ + scrollToEnd: (pane: HTMLElement | undefined | null) => void + /** Whether the pane is at its end, i.e. the reader wants to be carried along. */ + isAtEnd: (pane: HTMLElement | undefined | null) => boolean + /** Whether the scroll event being handled was one we caused. */ + isOwnScroll: () => boolean +} + +export function createBottomSticker(): BottomSticker { + let scrolledAt: number | undefined + + return { + scrollToEnd(pane) { + if (!pane) { + return + } + scrolledAt = Date.now() + pane.scrollTo({ top: pane.scrollHeight, behavior: 'auto' }) + }, + isAtEnd(pane) { + if (!pane) { + return false + } + return pane.scrollHeight - pane.scrollTop - pane.clientHeight <= STICK_TO_BOTTOM_PX + }, + isOwnScroll() { + return scrolledAt !== undefined && Date.now() - scrolledAt < OWN_SCROLL_WINDOW_MS + } + } +}