diff --git a/src/components/PipelineRun/components/InspectPipelineButton.tsx b/src/components/PipelineRun/components/InspectPipelineButton.tsx index 3e6f96637e..e37b1d20d1 100644 --- a/src/components/PipelineRun/components/InspectPipelineButton.tsx +++ b/src/components/PipelineRun/components/InspectPipelineButton.tsx @@ -50,7 +50,7 @@ export const InspectPipelineButton = ({ data-testid="inspect-pipeline-button" {...rest} > - + {displayLabel ?? (showLabel ? "Inspect" : null)} ); diff --git a/src/components/PipelineRun/components/SharePipelineButton.test.tsx b/src/components/PipelineRun/components/SharePipelineButton.test.tsx new file mode 100644 index 0000000000..12a83f370f --- /dev/null +++ b/src/components/PipelineRun/components/SharePipelineButton.test.tsx @@ -0,0 +1,30 @@ +import { screen } from "@testing-library/dom"; +import { act, fireEvent, render } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { SharePipelineButton } from "./SharePipelineButton"; + +const mockNotify = vi.fn(); + +vi.mock("@/hooks/useToastNotification", () => ({ + default: () => mockNotify, +})); + +describe("", () => { + test("copies the current URL to the clipboard on click", () => { + const writeText = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + act(() => fireEvent.click(screen.getByTestId("share-pipeline-button"))); + + expect(writeText).toHaveBeenCalledWith(window.location.href); + expect(mockNotify).toHaveBeenCalledWith( + "Run URL copied to clipboard", + "success", + ); + }); +}); diff --git a/src/components/PipelineRun/components/SharePipelineButton.tsx b/src/components/PipelineRun/components/SharePipelineButton.tsx new file mode 100644 index 0000000000..34ce86696e --- /dev/null +++ b/src/components/PipelineRun/components/SharePipelineButton.tsx @@ -0,0 +1,42 @@ +import { type ComponentPropsWithoutRef, useCallback } from "react"; + +import TooltipButton from "@/components/shared/Buttons/TooltipButton"; +import { Icon } from "@/components/ui/icon"; +import useToastNotification from "@/hooks/useToastNotification"; +import { copyToClipboard } from "@/utils/string"; + +type SharePipelineButtonProps = { + showLabel?: boolean; + displayLabel?: string; + showTooltip?: boolean; +} & Omit< + ComponentPropsWithoutRef, + "onClick" | "tooltip" | "variant" | "children" +>; + +export const SharePipelineButton = ({ + showLabel, + displayLabel, + showTooltip = true, + ...rest +}: SharePipelineButtonProps) => { + const notify = useToastNotification(); + + const handleShare = useCallback(() => { + copyToClipboard(window.location.href); + notify("Run URL copied to clipboard", "success"); + }, [notify]); + + return ( + + + {displayLabel ?? (showLabel ? "Share" : null)} + + ); +}; diff --git a/src/components/shared/CodeViewer/CodeViewer.tsx b/src/components/shared/CodeViewer/CodeViewer.tsx index 52ddd35673..3eba1c4ae4 100644 --- a/src/components/shared/CodeViewer/CodeViewer.tsx +++ b/src/components/shared/CodeViewer/CodeViewer.tsx @@ -13,6 +13,7 @@ interface CodeViewerProps { filename?: string; fullscreen?: boolean; scrollToBottom?: boolean; + allowFullscreen?: boolean; onClose?: () => void; } @@ -24,6 +25,7 @@ const CodeViewer = ({ filename = "", fullscreen = false, scrollToBottom = false, + allowFullscreen = true, onClose, }: CodeViewerProps) => { const [isFullscreen, setIsFullscreen] = useState(fullscreen); @@ -65,17 +67,19 @@ const CodeViewer = ({ (Read Only) - + {allowFullscreen && ( + + )}
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} diff --git a/src/components/shared/InfoBox.tsx b/src/components/shared/InfoBox.tsx index 4466b82811..9b0e11be48 100644 --- a/src/components/shared/InfoBox.tsx +++ b/src/components/shared/InfoBox.tsx @@ -66,12 +66,12 @@ export const InfoBox = ({ data-testid={`info-box-${variant}`} className={cn("border rounded-md p-2", styles.container, widthClass)} > - + {title} @@ -87,7 +87,11 @@ export const InfoBox = ({ )} -
{children}
+
+ {children} +
); }; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCollapsibleSection.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCollapsibleSection.tsx new file mode 100644 index 0000000000..1a2df78b5b --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCollapsibleSection.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; +import { useState } from "react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; + +interface IOCollapsibleSectionProps { + title: string; + count: number; + children: ReactNode; +} + +const IOCollapsibleSection = ({ + title, + count, + children, +}: IOCollapsibleSectionProps) => { + const [open, setOpen] = useState(true); + + return ( + + + + + {title} + + {count > 0 && ( + + {count} + + )} + + + + + {children} + + + + ); +}; + +export default IOCollapsibleSection; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOExtras.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOExtras.tsx index e50fabd467..795e9c92ab 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOExtras.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOExtras.tsx @@ -1,9 +1,8 @@ import type { GetExecutionArtifactsResponse } from "@/api/types.gen"; -import { BlockStack } from "@/components/ui/layout"; -import { Heading } from "@/components/ui/typography"; import type { InputSpec, OutputSpec } from "@/utils/componentSpec"; import IOCell from "./IOCell/IOCell"; +import IOCollapsibleSection from "./IOCollapsibleSection"; interface IOExtrasProps { inputs?: InputSpec[]; @@ -27,21 +26,25 @@ const IOExtras = ({ inputs, outputs, artifacts }: IOExtrasProps) => { return ( <> {additionalInputs.length > 0 && ( - - Additional Input Artifacts + {additionalInputs.map(([key, artifact]) => ( ))} - + )} {additionalOutputs.length > 0 && ( - - Additional Output Artifacts + {additionalOutputs.map(([key, artifact]) => ( ))} - + )} ); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOInputs.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOInputs.tsx index a64a6c7f72..fa98c88a8e 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOInputs.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOInputs.tsx @@ -1,9 +1,9 @@ import type { GetExecutionArtifactsResponse } from "@/api/types.gen"; -import { BlockStack } from "@/components/ui/layout"; -import { Heading, Paragraph } from "@/components/ui/typography"; +import { Paragraph } from "@/components/ui/typography"; import type { InputSpec } from "@/utils/componentSpec"; import IOCell from "./IOCell/IOCell"; +import IOCollapsibleSection from "./IOCollapsibleSection"; interface IOInputsProps { inputs?: InputSpec[]; @@ -12,9 +12,7 @@ interface IOInputsProps { const IOInputs = ({ inputs, artifacts }: IOInputsProps) => { return ( - - Inputs - + {(!inputs || inputs.length === 0) && ( No inputs defined @@ -33,7 +31,7 @@ const IOInputs = ({ inputs, artifacts }: IOInputsProps) => { /> ); })} - + ); }; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOOutputs.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOOutputs.tsx index 0e9ebba797..8f0d13d310 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOOutputs.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOOutputs.tsx @@ -1,9 +1,9 @@ import type { GetExecutionArtifactsResponse } from "@/api/types.gen"; -import { BlockStack } from "@/components/ui/layout"; -import { Heading, Paragraph } from "@/components/ui/typography"; +import { Paragraph } from "@/components/ui/typography"; import type { OutputSpec } from "@/utils/componentSpec"; import IOCell from "./IOCell/IOCell"; +import IOCollapsibleSection from "./IOCollapsibleSection"; interface IOOutputsProps { outputs?: OutputSpec[]; @@ -12,9 +12,7 @@ interface IOOutputsProps { const IOOutputs = ({ outputs, artifacts }: IOOutputsProps) => { return ( - - Outputs - + {(!outputs || outputs.length === 0) && ( No outputs defined @@ -33,7 +31,7 @@ const IOOutputs = ({ outputs, artifacts }: IOOutputsProps) => { /> ); })} - + ); }; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx index ac5a987256..673816b2b7 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx @@ -11,11 +11,13 @@ import { shouldStatusHaveLogs } from "@/utils/executionStatus"; const LogDisplay = ({ logs, + allowFullscreen, }: { logs: { log_text?: string; system_error_exception_full?: string; }; + allowFullscreen?: boolean; }) => { if (!logs.log_text && !logs.system_error_exception_full) { return
No logs available
; @@ -36,6 +38,7 @@ const LogDisplay = ({ language="text" filename="Execution Logs" scrollToBottom + allowFullscreen={allowFullscreen} />
)} @@ -46,6 +49,7 @@ const LogDisplay = ({ language="text" filename="System Error Logs" scrollToBottom + allowFullscreen={allowFullscreen} /> )} @@ -56,9 +60,11 @@ const LogDisplay = ({ const Logs = ({ executionId, status, + allowFullscreen = true, }: { executionId?: string | number; status?: string; + allowFullscreen?: boolean; }) => { const { backendUrl, configured, available } = useBackend(); @@ -132,7 +138,7 @@ const Logs = ({ return (
- {logs && } + {logs && }
); diff --git a/src/components/shared/RunSource.tsx b/src/components/shared/RunSource.tsx index 20013e1de3..af9904ffbb 100644 --- a/src/components/shared/RunSource.tsx +++ b/src/components/shared/RunSource.tsx @@ -11,27 +11,31 @@ interface SourceConfig { icon: IconName; label: string; tooltip: string; + message: string; } const SOURCE_BUCKETS: Record = { "web-app": { icon: "AppWindow", label: "Web app", - tooltip: "Submitted via Tangle web app", + tooltip: "Submitted via the Tangle web app", + message: "Submitted via the Tangle web app", }, programmatic: { icon: "Bot", label: "Programmatic", - tooltip: "Submitted by AI or via CLI", + tooltip: "Submitted via AI or CLI", + message: "Submitted via AI or CLI", }, unknown: { icon: "CircleQuestionMark", label: "Unknown", tooltip: "Source unknown", + message: "Source unknown", }, }; -const getRunSourceBucket = (source?: string | null): RunSourceBucket => { +export const getRunSourceBucket = (source?: string | null): RunSourceBucket => { if (!source) return "unknown"; if (source === "web-app") return "web-app"; return "programmatic"; @@ -40,6 +44,10 @@ const getRunSourceBucket = (source?: string | null): RunSourceBucket => { const getRunSourceConfig = (source?: string | null): SourceConfig => SOURCE_BUCKETS[getRunSourceBucket(source)]; +/** Human-readable message describing how a run was submitted. */ +export const getRunSourceMessage = (source?: string | null): string => + getRunSourceConfig(source).message; + interface RunSourceIconProps { source?: string | null; size?: "xs" | "sm" | "md"; diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/QuickRunButton.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/QuickRunButton.tsx index 49a8aafa13..4f04b69d9b 100644 --- a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/QuickRunButton.tsx +++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/QuickRunButton.tsx @@ -66,6 +66,7 @@ export const QuickRunButton = observer(function QuickRunButton({ const errorCount = allIssues.filter((i) => i.severity === "error").length; const hasErrors = errorCount > 0; const onlyWarnings = allIssues.length > 0 && errorCount === 0; + const hasConfigurableInputs = (rootSpec?.inputs?.length ?? 0) > 0; let serializedPipelineSpec: ReturnType | undefined; @@ -85,25 +86,54 @@ export const QuickRunButton = observer(function QuickRunButton({ triggerSubmitRun(); }; + const handleSubmitWithArgumentsClick = ( + event: MouseEvent, + ) => { + event.stopPropagation(); + triggerSubmitWithArguments(); + }; + + const showMiniArgumentsButton = isMini && hasConfigurableInputs && !hasErrors; + return ( <> - - - + {!showMiniArgumentsButton && ( + + + + )} + {showMiniArgumentsButton && ( + + + + )} {renderSubmitter && serializedPipelineSpec && isAuthorized && (
fetchRunAnnotations(runId, backendUrl), + enabled: !!runId, + refetchOnWindowFocus: false, + staleTime: TWENTY_FOUR_HOURS_IN_MS, + }); + + const runSource = getAnnotationValue(runAnnotations, RUN_SOURCE_ANNOTATION); + const hasKnownSource = getRunSourceBucket(runSource) !== "unknown"; + return ( - + + + + {hasKnownSource && ( + + + + {getRunSourceMessage(runSource)} + + + )} + ); } diff --git a/src/routes/v2/pages/RunView/components/RunToolsContent.tsx b/src/routes/v2/pages/RunView/components/RunToolsContent.tsx index d6ffa8b101..5adab32681 100644 --- a/src/routes/v2/pages/RunView/components/RunToolsContent.tsx +++ b/src/routes/v2/pages/RunView/components/RunToolsContent.tsx @@ -4,6 +4,7 @@ import { CancelPipelineRunButton } from "@/components/PipelineRun/components/Can import { ClonePipelineButton } from "@/components/PipelineRun/components/ClonePipelineButton"; import { InspectPipelineButton } from "@/components/PipelineRun/components/InspectPipelineButton"; import { RerunPipelineButton } from "@/components/PipelineRun/components/RerunPipelineButton"; +import { SharePipelineButton } from "@/components/PipelineRun/components/SharePipelineButton"; import { ViewYamlButton } from "@/components/shared/Buttons/ViewYamlButton"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { useRunViewActions } from "@/routes/v2/pages/RunView/hooks/useRunViewActions"; @@ -11,14 +12,25 @@ import { useOptionalWindowContext } from "@/routes/v2/shared/windows/ContentWind import { tracking } from "@/utils/tracking"; const RUN_TOOL_CLASS_NAME = - "h-10 w-full justify-start gap-3 border-transparent bg-transparent px-3 shadow-none hover:border-border hover:bg-muted"; -const RUN_TOOL_WRAPPER_CLASS_NAME = "w-full"; + "h-10 w-48 justify-start gap-3 border-transparent bg-transparent px-3 shadow-none hover:border-border hover:bg-muted"; +const RUN_TOOL_WRAPPER_CLASS_NAME = "w-fit"; + +const CANCEL_TOOL_CLASS_NAME = + "text-destructive hover:text-destructive dark:text-red-400 dark:hover:text-red-400 border-destructive/50 dark:border-red-400/30 hover:bg-destructive/10 dark:hover:bg-red-400/10"; const ROW_TOOL_CLASS_NAME = "h-10 justify-start gap-3 px-3 shadow-none hover:border-border hover:bg-muted border"; const ROW_TOOL_WRAPPER_CLASS_NAME = "shrink-0"; -export const RunToolsContent = observer(function RunToolsContent() { +const RAIL_TOOL_CLASS_NAME = "size-8 shrink-0 p-0"; + +interface RunToolsContentProps { + layout?: "rail"; +} + +export const RunToolsContent = observer(function RunToolsContent({ + layout, +}: RunToolsContentProps) { const actions = useRunViewActions(); const windowContext = useOptionalWindowContext(); const isFloatingPanel = @@ -37,6 +49,66 @@ export const RunToolsContent = observer(function RunToolsContent() { pipelineName, } = actions; + if (layout === "rail") { + return ( + + + + + + {canAccessEditorSpec && pipelineName && ( + + )} + + + + {isInProgress && isRunCreator && ( + + )} + + {isComplete && ( + + )} + + ); + } + const toolClassName = isFloatingPanel ? ROW_TOOL_CLASS_NAME : RUN_TOOL_CLASS_NAME; @@ -55,6 +127,14 @@ export const RunToolsContent = observer(function RunToolsContent() { {...tracking("v2.run_view.tools.view_yaml")} /> + + {canAccessEditorSpec && pipelineName && ( diff --git a/src/routes/v2/pages/RunView/components/RunViewMenuBar/RunViewMenuBar.tsx b/src/routes/v2/pages/RunView/components/RunViewMenuBar/RunViewMenuBar.tsx index 66b1f7a65d..5a6ac6e4db 100644 --- a/src/routes/v2/pages/RunView/components/RunViewMenuBar/RunViewMenuBar.tsx +++ b/src/routes/v2/pages/RunView/components/RunViewMenuBar/RunViewMenuBar.tsx @@ -55,7 +55,7 @@ export const RunViewMenuBar = observer(function RunViewMenuBar() { as="span" size="sm" weight="semibold" - className="text-white truncate max-w-64 lg:max-w-md leading-tight ml-1" + className="text-white truncate max-w-64 lg:max-w-md leading-tight ml-1 select-text cursor-text" > {pipelineName} diff --git a/src/routes/v2/pages/RunView/hooks/useAiChatWindow.test.ts b/src/routes/v2/pages/RunView/hooks/useAiChatWindow.test.ts new file mode 100644 index 0000000000..f0167fc824 --- /dev/null +++ b/src/routes/v2/pages/RunView/hooks/useAiChatWindow.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { getRunSuggestedPrompts } from "./useAiChatWindow"; + +const labelsFor = (status: string | undefined) => + getRunSuggestedPrompts(status).map((prompt) => prompt.label); + +describe("getRunSuggestedPrompts", () => { + it("always offers the summarize prompt first", () => { + const statuses = [ + undefined, + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED", + "SKIPPED", + ]; + + for (const status of statuses) { + expect(labelsFor(status)[0]).toBe("Summarize this run"); + } + }); + + it.each(["FAILED", "SYSTEM_ERROR", "INVALID"])( + "offers failure prompts for %s", + (status) => { + expect(labelsFor(status)).toEqual([ + "Summarize this run", + "Why did this run fail?", + "Which tasks failed and why?", + ]); + }, + ); + + it.each([ + "RUNNING", + "PENDING", + "QUEUED", + "WAITING_FOR_UPSTREAM", + "CANCELLING", + "UNINITIALIZED", + ])("offers in-progress prompts for %s", (status) => { + expect(labelsFor(status)).toEqual([ + "Summarize this run", + "What's happening in this run right now?", + "Which tasks are still running?", + ]); + }); + + it.each(["CANCELLED", "SKIPPED"])( + "offers halted-run prompts for %s", + (status) => { + expect(labelsFor(status)).toEqual([ + "Summarize this run", + "What completed before this run stopped?", + "Which tasks did not run?", + ]); + }, + ); + + it("falls back to outcome prompts for succeeded and unknown statuses", () => { + const expected = [ + "Summarize this run", + "Explain the outputs of this run", + "Did anything unexpected happen?", + ]; + + expect(labelsFor("SUCCEEDED")).toEqual(expected); + expect(labelsFor(undefined)).toEqual(expected); + }); +}); diff --git a/src/routes/v2/pages/RunView/hooks/useAiChatWindow.tsx b/src/routes/v2/pages/RunView/hooks/useAiChatWindow.tsx index 184ddf7050..0adaabd491 100644 --- a/src/routes/v2/pages/RunView/hooks/useAiChatWindow.tsx +++ b/src/routes/v2/pages/RunView/hooks/useAiChatWindow.tsx @@ -1,18 +1,78 @@ +import { observer } from "mobx-react-lite"; import { useEffect } from "react"; +import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider"; import { RUN_AI_ASSISTANT_WINDOW_ID } from "@/routes/v2/pages/RunView/runViewWindowPresets"; import { createRunViewToolBridge } from "@/routes/v2/pages/RunView/toolBridge/runViewToolBridge"; import { AiChatContent } from "@/routes/v2/shared/components/AiChat/AiChatContent"; import type { SuggestedPrompt } from "@/routes/v2/shared/components/AiChat/types"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { WindowMiniButton } from "@/routes/v2/shared/windows/WindowMiniButton"; +import { + flattenExecutionStatusStats, + getOverallExecutionStatusFromStats, + IN_PROGRESS_STATUSES, +} from "@/utils/executionStatus"; -const SUGGESTED_PROMPTS_RUN: SuggestedPrompt[] = [ - { label: "Summarize this run", icon: "FileText" }, - { label: "Why did this run fail?", icon: "CircleAlert" }, - { label: "Which tasks failed and why?", icon: "ListChecks" }, - { label: "Explain the outputs of this run", icon: "ArrowUpFromLine" }, -]; +const SUMMARIZE_PROMPT: SuggestedPrompt = { + label: "Summarize this run", + icon: "FileText", +}; + +const FAILED_STATUSES = new Set(["FAILED", "SYSTEM_ERROR", "INVALID"]); +const HALTED_STATUSES = new Set(["CANCELLED", "SKIPPED"]); + +export function getRunSuggestedPrompts( + status: string | undefined, +): SuggestedPrompt[] { + if (status && FAILED_STATUSES.has(status)) { + return [ + SUMMARIZE_PROMPT, + { label: "Why did this run fail?", icon: "CircleAlert" }, + { label: "Which tasks failed and why?", icon: "ListChecks" }, + ]; + } + + if (status && IN_PROGRESS_STATUSES.has(status)) { + return [ + SUMMARIZE_PROMPT, + { label: "What's happening in this run right now?", icon: "Activity" }, + { label: "Which tasks are still running?", icon: "LoaderCircle" }, + ]; + } + + if (status && HALTED_STATUSES.has(status)) { + return [ + SUMMARIZE_PROMPT, + { label: "What completed before this run stopped?", icon: "ListChecks" }, + { label: "Which tasks did not run?", icon: "CircleSlash" }, + ]; + } + + return [ + SUMMARIZE_PROMPT, + { label: "Explain the outputs of this run", icon: "ArrowUpFromLine" }, + { label: "Did anything unexpected happen?", icon: "Search" }, + ]; +} + +const RunAiChatContent = observer(function RunAiChatContent() { + const executionData = useExecutionDataOptional(); + + const stats = + executionData?.metadata?.execution_status_stats ?? + flattenExecutionStatusStats( + executionData?.rootState?.child_execution_status_stats, + ); + const overallStatus = getOverallExecutionStatusFromStats(stats); + + return ( + + ); +}); export function useAiChatWindow(enabled: boolean) { const { windows } = useSharedStores(); @@ -24,28 +84,22 @@ export function useAiChatWindow(enabled: boolean) { } if (windows.getWindowById(RUN_AI_ASSISTANT_WINDOW_ID)) return; - windows.openWindow( - , - { - id: RUN_AI_ASSISTANT_WINDOW_ID, - title: "AI Assistant", - position: { x: 100, y: 80 }, - size: { width: 380, height: 520 }, - disabledActions: ["close"], - defaultVisible: true, - defaultDockState: "left", - persisted: true, - miniContent: ( - - ), - }, - ); + windows.openWindow(, { + id: RUN_AI_ASSISTANT_WINDOW_ID, + title: "AI Assistant", + position: { x: 100, y: 80 }, + size: { width: 380, height: 520 }, + disabledActions: ["close"], + defaultVisible: true, + defaultDockState: "left", + persisted: true, + miniContent: ( + + ), + }); }, [enabled, windows]); } diff --git a/src/routes/v2/pages/RunView/hooks/useRunViewSelectionSync.tsx b/src/routes/v2/pages/RunView/hooks/useRunViewSelectionSync.tsx index a8edc202c3..09cdb44f34 100644 --- a/src/routes/v2/pages/RunView/hooks/useRunViewSelectionSync.tsx +++ b/src/routes/v2/pages/RunView/hooks/useRunViewSelectionSync.tsx @@ -33,6 +33,7 @@ export function useRunViewSelectionSync() { persisted: true, fillDockHeight: true, defaultDockState: "right", + onClose: () => editor.clearSelection(), miniContent: ( - ), + renderMiniInline: true, + miniContent: , }); } diff --git a/src/routes/v2/pages/RunView/nodes/TaskNode/RunViewTaskNode.tsx b/src/routes/v2/pages/RunView/nodes/TaskNode/RunViewTaskNode.tsx index 4d9ddfc75e..ebda2af401 100644 --- a/src/routes/v2/pages/RunView/nodes/TaskNode/RunViewTaskNode.tsx +++ b/src/routes/v2/pages/RunView/nodes/TaskNode/RunViewTaskNode.tsx @@ -2,7 +2,6 @@ import { type Node, type NodeProps } from "@xyflow/react"; import { observer } from "mobx-react-lite"; import { StatusIndicator } from "@/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator"; -import Logs from "@/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { TaskNode } from "@/routes/v2/shared/nodes/TaskNode/TaskNode"; @@ -18,23 +17,19 @@ export const RunViewTaskNode = observer(function RunViewTaskNode( props: NodeProps, ) { const { entityId } = props.data; - const { windows } = useSharedStores(); + const { editor } = useSharedStores(); const { task, status, disabledCache, - executionId, showLogsButton, subgraphExecutionStats, } = useTaskRunStatus(entityId); const handleOpenLogs = () => { - if (!task || !executionId) return; - windows.openWindow(, { - id: `task-logs-${task.name}`, - title: `Logs: ${task.name}`, - size: { width: 500, height: 400 }, - }); + if (!task || task.subgraphSpec) return; + editor.selectNode(entityId, "task"); + editor.setPendingTaskDetailTab("logs"); }; return ( diff --git a/src/routes/v2/pages/RunView/nodes/TaskNode/context/RunViewTaskDetails.tsx b/src/routes/v2/pages/RunView/nodes/TaskNode/context/RunViewTaskDetails.tsx index 537397edba..44a3d704d2 100644 --- a/src/routes/v2/pages/RunView/nodes/TaskNode/context/RunViewTaskDetails.tsx +++ b/src/routes/v2/pages/RunView/nodes/TaskNode/context/RunViewTaskDetails.tsx @@ -1,6 +1,7 @@ import { useParams } from "@tanstack/react-router"; import { AmphoraIcon, InfoIcon, LogsIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; +import { useEffect, useState } from "react"; import type { ContainerExecutionStatus } from "@/api/types.gen"; import IOSection from "@/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOSection"; @@ -11,6 +12,7 @@ import { LogsEventsOverlaySection } from "@/components/shared/ReactFlow/FlowCanv import { RemoteTroubleshootButton } from "@/components/shared/RemoteTroubleshootAction/RemoteTroubleshootButton"; import { StatusIcon } from "@/components/shared/Status"; import TaskDetails from "@/components/shared/TaskDetails/Details"; +import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -18,12 +20,17 @@ import { Text } from "@/components/ui/typography"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider"; import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import type { TaskSpec } from "@/utils/componentSpec"; import { tracking } from "@/utils/tracking"; import { RunViewTaskActions } from "./RunViewTaskActions"; import { getTaskAnnotationSections } from "./RunViewTaskAnnotations"; +const DEFAULT_TAB = "artifacts"; + +const LOGS_MIN_DOCKED_HEIGHT = 280; + interface RunViewTaskDetailsProps { entityId: string; } @@ -34,11 +41,29 @@ export const RunViewTaskDetails = observer(function RunViewTaskDetails({ const { track } = useAnalytics(); const spec = useSpec(); const executionData = useExecutionDataOptional(); + const { editor, windows } = useSharedStores(); const params = useParams({ strict: false }); const runId = "id" in params && typeof params.id === "string" ? params.id : undefined; const task = spec?.tasks.find((t) => t.$id === entityId); + const isSubgraphTask = task?.subgraphSpec !== undefined; + const hasLogsTab = !!task && !isSubgraphTask; + + const [activeTab, setActiveTab] = useState(DEFAULT_TAB); + + useEffect(() => { + setActiveTab(DEFAULT_TAB); + }, [entityId]); + + const pendingTab = editor.pendingTaskDetailTab; + useEffect(() => { + if (!pendingTab) return; + if (pendingTab !== "logs" || hasLogsTab) { + setActiveTab(pendingTab); + } + editor.setPendingTaskDetailTab(null); + }, [pendingTab, hasLogsTab, editor]); if (!task) { return ( @@ -55,10 +80,26 @@ export const RunViewTaskDetails = observer(function RunViewTaskDetails({ executionData?.details?.child_task_execution_ids?.[task.name]; const componentRef = task.resolvedComponentRef; - const isSubgraphTask = task.subgraphSpec !== undefined; const taskSpecForIO = { componentRef } as TaskSpec; + const handlePopOutLogs = () => { + if (!executionId) return; + windows.openWindow( + , + { + id: `task-logs-${task.name}`, + title: `Logs: ${task.name}`, + size: { width: 500, height: 400 }, + minDockedHeight: LOGS_MIN_DOCKED_HEIGHT, + }, + ); + }; + return ( + onValueChange={(nextTab) => { + setActiveTab(nextTab); track("v2.run_view.context_panel.task_detail_tab.select", { - active_tab: activeTab, - }) - } + active_tab: nextTab, + }); + }} > @@ -133,19 +175,37 @@ export const RunViewTaskDetails = observer(function RunViewTaskDetails({ {!isSubgraphTask && ( {!!executionId && ( -
+ + -
+ )} - +
)}
diff --git a/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.test.ts b/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.test.ts index da79c107e2..f364c1f531 100644 --- a/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.test.ts +++ b/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.test.ts @@ -73,4 +73,36 @@ describe("useTaskRunStatus", () => { expect(result.current.subgraphExecutionStats).toBeNull(); }); + + it("shows the logs button for a container task with logs", () => { + useSpecMock.mockReturnValue({ + tasks: [ + { + $id: "task-1", + name: "subgraph-task", + subgraphSpec: undefined, + }, + ], + }); + + const { result } = renderHook(() => useTaskRunStatus("task-1")); + + expect(result.current.showLogsButton).toBe(true); + }); + + it("hides the logs button for a subgraph task", () => { + useSpecMock.mockReturnValue({ + tasks: [ + { + $id: "task-1", + name: "subgraph-task", + subgraphSpec: {}, + }, + ], + }); + + const { result } = renderHook(() => useTaskRunStatus("task-1")); + + expect(result.current.showLogsButton).toBe(false); + }); }); diff --git a/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.ts b/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.ts index c170a76636..40941228b3 100644 --- a/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.ts +++ b/src/routes/v2/pages/RunView/nodes/TaskNode/useTaskRunStatus.ts @@ -54,7 +54,8 @@ export function useTaskRunStatus(entityId: string): TaskRunStatus { ISO8601_DURATION_ZERO_DAYS; const executionId = resolveExecutionId(task, executionData); - const showLogsButton = !!executionId && shouldStatusHaveLogs(status); + const showLogsButton = + !!executionId && !task?.subgraphSpec && shouldStatusHaveLogs(status); const subgraphExecutionStats = resolveSubgraphExecutionStats( task, executionId, diff --git a/src/routes/v2/shared/store/editorStore.ts b/src/routes/v2/shared/store/editorStore.ts index e4dcd9b749..e25549dc81 100644 --- a/src/routes/v2/shared/store/editorStore.ts +++ b/src/routes/v2/shared/store/editorStore.ts @@ -19,6 +19,7 @@ export class EditorStore { @observable accessor focusedArgumentName: string | null = null; @observable accessor hoveredEntityId: string | null = null; @observable accessor pendingFocusNodeId: string | null = null; + @observable accessor pendingTaskDetailTab: string | null = null; @observable.ref accessor selectedValidationIssue: ValidationIssue | null = null; @@ -35,6 +36,7 @@ export class EditorStore { this.focusedArgumentName = null; this.hoveredEntityId = null; this.pendingFocusNodeId = null; + this.pendingTaskDetailTab = null; this.selectedValidationIssue = null; } @@ -70,9 +72,14 @@ export class EditorStore { this.multiSelection = []; this.focusedArgumentName = null; this.hoveredEntityId = null; + this.pendingTaskDetailTab = null; this.selectedValidationIssue = null; } + @action setPendingTaskDetailTab(tab: string | null) { + this.pendingTaskDetailTab = tab; + } + @computed get hasAnySelection(): boolean { return ( this.selectedNodeId !== null || diff --git a/src/routes/v2/shared/windows/DockArea.tsx b/src/routes/v2/shared/windows/DockArea.tsx index 352ba9d12c..231ec11f2c 100644 --- a/src/routes/v2/shared/windows/DockArea.tsx +++ b/src/routes/v2/shared/windows/DockArea.tsx @@ -138,13 +138,19 @@ export const DockArea = observer(function DockArea({ side }: DockAreaProps) { align="center" className="relative z-20 min-h-0 flex-1 overflow-y-auto overflow-x-hidden hide-scrollbar py-1 px-0.5" > - {visibleWindowsWithMini.map((windowId) => ( - - ))} + {visibleWindowsWithMini.map((windowId) => + windows.getWindowById(windowId)?.renderMiniInline ? ( +
+ {windows.getWindowMiniContent(windowId)} +
+ ) : ( + + ), + )}
{ const newHeight = Math.max( - MIN_DOCKED_HEIGHT, + model.minDockedHeight, startHeight + (moveE.clientY - startY), ); model.updateDockedHeight(newHeight); @@ -196,7 +195,7 @@ export const DockedWindow = observer(function DockedWindow() { isDragging && "opacity-50", )} style={{ - minHeight: MIN_DOCKED_HEIGHT, + minHeight: model.minDockedHeight, // fillDockHeight => grow to fill the dock column; otherwise an // undefined height fits content and a concrete value fixes the // height with the inner content area scrolling. diff --git a/src/routes/v2/shared/windows/types.ts b/src/routes/v2/shared/windows/types.ts index 67cf8ca05b..711d76bc81 100644 --- a/src/routes/v2/shared/windows/types.ts +++ b/src/routes/v2/shared/windows/types.ts @@ -62,10 +62,12 @@ export interface WindowOptions { defaultDockState?: "left" | "right"; variant?: "window" | "panel"; fillDockHeight?: boolean; + minDockedHeight?: number; onClose?: () => void; // Without miniContent a window is filtered out of the collapsed dock strip, so // it is unreachable until the user expands the dock area again. miniContent?: ReactNode; + renderMiniInline?: boolean; } // All dimensions below are CSS pixels. See src/routes/v2/WINDOWS.md for what each one drives. diff --git a/src/routes/v2/shared/windows/windowModel.ts b/src/routes/v2/shared/windows/windowModel.ts index 9725f9934c..d52c5ed2a0 100644 --- a/src/routes/v2/shared/windows/windowModel.ts +++ b/src/routes/v2/shared/windows/windowModel.ts @@ -44,6 +44,8 @@ export interface WindowModelInit { persisted: boolean; variant: "window" | "panel"; fillDockHeight?: boolean; + minDockedHeight?: number; + renderMiniInline?: boolean; onClose?: () => void; } @@ -73,6 +75,8 @@ export class WindowModel { /** Static config: when docked, fill remaining dock-area height. */ readonly fillDockHeight: boolean; + readonly minDockedHeight: number; + readonly renderMiniInline: boolean; readonly onClose: (() => void) | undefined; private readonly store: WindowStoreRef; @@ -95,6 +99,8 @@ export class WindowModel { this.persisted = init.persisted; this.variant = init.variant; this.fillDockHeight = init.fillDockHeight ?? false; + this.minDockedHeight = init.minDockedHeight ?? MIN_DOCKED_HEIGHT; + this.renderMiniInline = init.renderMiniInline ?? false; this.onClose = init.onClose; this.store = store; makeObservable(this); @@ -216,7 +222,7 @@ export class WindowModel { } @action updateDockedHeight(height: number): void { - this.dockedHeight = Math.max(MIN_DOCKED_HEIGHT, height); + this.dockedHeight = Math.max(this.minDockedHeight, height); } /** Clears the explicit height so the window returns to fit-to-content sizing. */ diff --git a/src/routes/v2/shared/windows/windowStore.utils.test.ts b/src/routes/v2/shared/windows/windowStore.utils.test.ts index 60136d4987..8d2b2e25b7 100644 --- a/src/routes/v2/shared/windows/windowStore.utils.test.ts +++ b/src/routes/v2/shared/windows/windowStore.utils.test.ts @@ -184,3 +184,25 @@ describe("resolveGeometry viewport clamping (via buildWindowModelInit)", () => { expect(init.position).toEqual(optionPosition); }); }); + +describe("minDockedHeight (via buildWindowModelInit)", () => { + it("passes the option through to the model init", () => { + const init = buildWindowModelInit( + "logs-window", + baseOptions({ minDockedHeight: 280 }), + DEFAULT_POSITION, + ); + + expect(init.minDockedHeight).toBe(280); + }); + + it("leaves the value unset when the option is omitted", () => { + const init = buildWindowModelInit( + "plain-window", + baseOptions(), + DEFAULT_POSITION, + ); + + expect(init.minDockedHeight).toBeUndefined(); + }); +}); diff --git a/src/routes/v2/shared/windows/windowStore.utils.ts b/src/routes/v2/shared/windows/windowStore.utils.ts index 3801ca69b3..d69e70d965 100644 --- a/src/routes/v2/shared/windows/windowStore.utils.ts +++ b/src/routes/v2/shared/windows/windowStore.utils.ts @@ -50,6 +50,8 @@ export function buildWindowModelInit( persisted: !!options.persisted, variant: options.variant ?? "window", fillDockHeight: options.fillDockHeight, + minDockedHeight: options.minDockedHeight, + renderMiniInline: options.renderMiniInline, onClose: options.onClose, }; } diff --git a/src/utils/executionStatus.ts b/src/utils/executionStatus.ts index eeb83f92bd..d580eea088 100644 --- a/src/utils/executionStatus.ts +++ b/src/utils/executionStatus.ts @@ -73,7 +73,7 @@ const CONTAINER_STATUSES_ACTIVELY_LOGGING = new Set([ /** * Statuses considered "in progress" (not terminal). */ -const IN_PROGRESS_STATUSES = new Set([ +export const IN_PROGRESS_STATUSES = new Set([ "RUNNING", "PENDING", "QUEUED",