diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 9e18dcb34f..6e16a70c3c 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -410,6 +410,7 @@ const ChatPaneContent: React.FC = (props) => { isStreamStarting, loading, isHydratingTranscript, + isTranscriptCaughtUp, hasOlderHistory, loadingOlderHistory, } = workspaceState; @@ -1524,6 +1525,7 @@ const ChatPaneContent: React.FC = (props) => { projectName={projectName} workspaceName={workspaceName} isStreamStarting={isStreamStarting} + isTranscriptCaughtUp={isTranscriptCaughtUp} isHydratingTranscript={isHydratingTranscript} runtimeConfig={runtimeConfig} isQueuedAgentTask={isQueuedAgentTask} @@ -1581,6 +1583,7 @@ interface ChatInputPaneProps { isQueuedAgentTask: boolean; isCompacting: boolean; isStreamStarting: boolean; + isTranscriptCaughtUp: boolean; isHydratingTranscript: boolean; shouldShowPinnedTodoList: boolean; shouldShowReviewsBanner: boolean; @@ -1729,6 +1732,7 @@ const ChatInputPane: React.FC = (props) => { ? "Queued - waiting for an available parallel task slot. This will start automatically." : undefined } + isTranscriptCaughtUp={props.isTranscriptCaughtUp} isStreamStarting={props.isStreamStarting} isCompacting={props.isCompacting} editingMessage={props.editingMessage} diff --git a/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx b/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx index e0a88d9b24..915a883320 100644 --- a/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx +++ b/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx @@ -36,6 +36,7 @@ function buildWorkspaceState(workspaceId: string, state: MockWorkspaceState): Wo isStreamStarting: false, awaitingUserQuestion: false, loading: false, + isTranscriptCaughtUp: true, isHydratingTranscript: false, hasOlderHistory: false, loadingOlderHistory: false, diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 6757b0a763..e0213d2075 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -295,6 +295,8 @@ const ChatInputInner: React.FC = (props) => { ); const editingMessageForUi = editingMessage?.id === optimisticallyDismissedEditId ? undefined : editingMessage; + const isTranscriptCaughtUp = + variant === "workspace" ? (props.isTranscriptCaughtUp ?? false) : false; const isStreamStarting = variant === "workspace" ? (props.isStreamStarting ?? false) : false; const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false; const [isMobileTouch, setIsMobileTouch] = useState( @@ -1559,7 +1561,7 @@ const ChatInputInner: React.FC = (props) => { const workflows = await api.workflows.listDefinitions(discoveryInput); const discoveryWorkspaceId = variant === "workspace" && workspaceId ? workspaceId : null; const runs = - discoveryWorkspaceId != null + discoveryWorkspaceId != null && isTranscriptCaughtUp ? await api.workflows.listRuns({ workspaceId: discoveryWorkspaceId }) : []; if (!isMounted || workflowsRequestIdRef.current !== requestId) { @@ -1598,7 +1600,15 @@ const ChatInputInner: React.FC = (props) => { return () => { isMounted = false; }; - }, [api, variant, workspaceId, atMentionProjectPath, dynamicWorkflowsExperimentEnabled, store]); + }, [ + api, + variant, + workspaceId, + atMentionProjectPath, + dynamicWorkflowsExperimentEnabled, + isTranscriptCaughtUp, + store, + ]); // Load agent skills for suggestions useEffect(() => { diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index 96b9a13040..ad48d0b7a9 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -37,6 +37,7 @@ export interface ChatInputWorkspaceVariant { onResetContext: () => Promise<"reset" | "noop">; onTruncateHistory: (percentage?: number) => Promise; onModelChange?: (model: string) => void; + isTranscriptCaughtUp?: boolean; isCompacting?: boolean; isStreamStarting?: boolean; editingMessage?: EditingMessageState; diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx index 91e918e5b1..176bfb0618 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx @@ -46,6 +46,7 @@ function buildState(workspaceId: string, input: SeedInput): WorkspaceState { isStreamStarting: input.isStarting ?? false, awaitingUserQuestion: input.awaitingUserQuestion ?? false, loading: false, + isTranscriptCaughtUp: true, isHydratingTranscript: false, hasOlderHistory: false, loadingOlderHistory: false, diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 9261d175a4..57a533bfcd 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -108,6 +108,7 @@ export interface WorkspaceState { isStreamStarting: boolean; awaitingUserQuestion: boolean; loading: boolean; + isTranscriptCaughtUp: boolean; isHydratingTranscript: boolean; hasOlderHistory: boolean; loadingOlderHistory: boolean; @@ -1913,6 +1914,7 @@ export class WorkspaceStore { isStreamStarting, awaitingUserQuestion: aggregator.hasAwaitingUserQuestion(), loading: !hasMessages && !hasRunningInitMessage && !transient.caughtUp, + isTranscriptCaughtUp: transient.caughtUp, isHydratingTranscript, hasOlderHistory: historyPagination.hasOlder, loadingOlderHistory: historyPagination.loading, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index ea148c9a72..a5dc8f10c8 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -524,6 +524,7 @@ function makeWorkspaceState(goal: WorkspaceState["goal"]): WorkspaceState { isStreamStarting: false, awaitingUserQuestion: false, loading: false, + isTranscriptCaughtUp: true, isHydratingTranscript: false, hasOlderHistory: false, loadingOlderHistory: false, diff --git a/src/browser/utils/workflowRunMessages.test.ts b/src/browser/utils/workflowRunMessages.test.ts index 95b6593a8b..2dfe845e1f 100644 --- a/src/browser/utils/workflowRunMessages.test.ts +++ b/src/browser/utils/workflowRunMessages.test.ts @@ -118,6 +118,90 @@ describe("buildWorkflowRunCardMessage", () => { ); }); + test("does not project workflow runs that are no longer anchored in the transcript", () => { + const run = { + id: "wfr_discarded", + definition: { + name: "deep-research", + description: "Deep research", + scope: "built-in" as const, + executable: true, + }, + args: { topic: "discarded" }, + status: "completed" as const, + }; + + expect(getWorkflowRunCardProjection([], run)).toEqual({ + shouldProject: false, + existingMessage: null, + }); + }); + + test("uses workflow card metadata as a repairable transcript anchor", () => { + const run = { + id: "wfr_metadata_anchor", + definition: { + name: "deep-research", + description: "Deep research", + scope: "built-in" as const, + executable: true, + }, + args: { topic: "metadata" }, + status: "completed" as const, + }; + const malformedCard = buildWorkflowRunCardMessage( + { name: run.definition.name, args: run.args }, + { runId: run.id, status: "running", result: null }, + 123 + ); + malformedCard.metadata = { + historySequence: 7, + muxMetadata: { type: "workflow-run-card-display", runId: run.id }, + }; + const part = malformedCard.parts[0]; + if (part?.type !== "dynamic-tool" || part.state !== "output-available") { + throw new Error("Expected workflow card dynamic tool part"); + } + part.output = { status: "running" }; + + expect(getWorkflowRunCardProjection([malformedCard], run)).toEqual({ + shouldProject: true, + existingMessage: malformedCard, + }); + }); + + test("uses workflow trigger rows as anchors to repair missing cards", () => { + const run = { + id: "wfr_trigger_anchor", + definition: { + name: "deep-research", + description: "Deep research", + scope: "built-in" as const, + executable: true, + }, + args: { topic: "trigger" }, + status: "completed" as const, + }; + const trigger: MuxMessage = { + id: "workflow-run-command-wfr_trigger_anchor", + role: "user", + parts: [{ type: "text", text: "/deep-research trigger" }], + metadata: { + historySequence: 6, + muxMetadata: { + type: "workflow-trigger-display", + rawCommand: "/deep-research trigger", + runId: run.id, + }, + }, + }; + + expect(getWorkflowRunCardProjection([trigger], run)).toEqual({ + shouldProject: true, + existingMessage: trigger, + }); + }); + test("projects updated terminal workflow cards while preserving the existing card slot", () => { const run = { id: "wfr_refresh", diff --git a/src/browser/utils/workflowRunMessages.ts b/src/browser/utils/workflowRunMessages.ts index 854c21ef6e..c97be31a94 100644 --- a/src/browser/utils/workflowRunMessages.ts +++ b/src/browser/utils/workflowRunMessages.ts @@ -3,6 +3,8 @@ import type { MuxMessage } from "@/common/types/message"; import type { WorkflowRunRecord } from "@/common/types/workflow"; import assert from "@/common/utils/assert"; import { + WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE, + WORKFLOW_TRIGGER_DISPLAY_METADATA_TYPE, buildWorkflowRunCardMessage, filterWorkflowDisplayOnlyMessages, type WorkflowRunCardInput, @@ -49,11 +51,51 @@ function getProjectedWorkflowRunCardMessageId(runId: string): string { return `workflow-run-${runId}`; } +function hasWorkflowRunCardMetadata(message: MuxMessage, runId: string): boolean { + return ( + message.id === getProjectedWorkflowRunCardMessageId(runId) && + message.role === "assistant" && + message.metadata?.muxMetadata?.type === WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE && + message.metadata.muxMetadata.runId === runId + ); +} + +function findWorkflowTriggerDisplayMessage( + messages: readonly MuxMessage[], + runId: string +): MuxMessage | null { + return ( + messages.find( + (message) => + message.metadata?.muxMetadata?.type === WORKFLOW_TRIGGER_DISPLAY_METADATA_TYPE && + message.metadata.muxMetadata.runId === runId + ) ?? null + ); +} + +function getWorkflowRunCardMetadata( + metadata: MuxMessage["metadata"] | undefined, + runId: string +): MuxMessage["metadata"] { + return { + ...metadata, + muxMetadata: { + type: WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE, + runId, + }, + }; +} + export function findProjectedWorkflowRunCardMessage( messages: readonly MuxMessage[], runId: string ): MuxMessage | null { assert(runId.length > 0, "findProjectedWorkflowRunCardMessage: run id is required"); + const metadataMatch = messages.find((message) => hasWorkflowRunCardMetadata(message, runId)); + if (metadataMatch != null) { + return metadataMatch; + } + const messageId = getProjectedWorkflowRunCardMessageId(runId); return ( messages.find( @@ -105,7 +147,15 @@ export function getWorkflowRunCardProjection( return { shouldProject: false, existingMessage: null }; } - return { shouldProject: true, existingMessage: null }; + const triggerMessage = findWorkflowTriggerDisplayMessage(messages, run.id); + if (triggerMessage != null) { + return { shouldProject: true, existingMessage: triggerMessage }; + } + + // A durable workflow run can outlive the chat row that launched it (for example after editing a + // prior message truncates history). Without a current transcript anchor, projecting it would + // resurrect discarded workflow cards at the bottom of the chat. + return { shouldProject: false, existingMessage: null }; } export function addWorkflowRunCardMessage( @@ -117,7 +167,7 @@ export function addWorkflowRunCardMessage( assert(workspaceId.length > 0, "addWorkflowRunCardMessage: workspaceId is required"); const message = buildWorkflowRunCardMessage(input, result); if (options?.existingMessage?.metadata != null) { - message.metadata = options.existingMessage.metadata; + message.metadata = getWorkflowRunCardMetadata(options.existingMessage.metadata, result.runId); } addEphemeralMessage(workspaceId, message); }