Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
isStreamStarting,
loading,
isHydratingTranscript,
isTranscriptCaughtUp,
hasOlderHistory,
loadingOlderHistory,
} = workspaceState;
Expand Down Expand Up @@ -1524,6 +1525,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
projectName={projectName}
workspaceName={workspaceName}
isStreamStarting={isStreamStarting}
isTranscriptCaughtUp={isTranscriptCaughtUp}
isHydratingTranscript={isHydratingTranscript}
runtimeConfig={runtimeConfig}
isQueuedAgentTask={isQueuedAgentTask}
Expand Down Expand Up @@ -1581,6 +1583,7 @@ interface ChatInputPaneProps {
isQueuedAgentTask: boolean;
isCompacting: boolean;
isStreamStarting: boolean;
isTranscriptCaughtUp: boolean;
isHydratingTranscript: boolean;
shouldShowPinnedTodoList: boolean;
shouldShowReviewsBanner: boolean;
Expand Down Expand Up @@ -1729,6 +1732,7 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ const ChatInputInner: React.FC<ChatInputProps> = (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(
Expand Down Expand Up @@ -1559,7 +1561,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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) {
Expand Down Expand Up @@ -1598,7 +1600,15 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return () => {
isMounted = false;
};
}, [api, variant, workspaceId, atMentionProjectPath, dynamicWorkflowsExperimentEnabled, store]);
}, [
api,
variant,
workspaceId,
atMentionProjectPath,
dynamicWorkflowsExperimentEnabled,
isTranscriptCaughtUp,
store,
]);

// Load agent skills for suggestions
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions src/browser/features/ChatInput/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface ChatInputWorkspaceVariant {
onResetContext: () => Promise<"reset" | "noop">;
onTruncateHistory: (percentage?: number) => Promise<void>;
onModelChange?: (model: string) => void;
isTranscriptCaughtUp?: boolean;
isCompacting?: boolean;
isStreamStarting?: boolean;
editingMessage?: EditingMessageState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface WorkspaceState {
isStreamStarting: boolean;
awaitingUserQuestion: boolean;
loading: boolean;
isTranscriptCaughtUp: boolean;
isHydratingTranscript: boolean;
hasOlderHistory: boolean;
loadingOlderHistory: boolean;
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/browser/utils/commands/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ function makeWorkspaceState(goal: WorkspaceState["goal"]): WorkspaceState {
isStreamStarting: false,
awaitingUserQuestion: false,
loading: false,
isTranscriptCaughtUp: true,
isHydratingTranscript: false,
hasOlderHistory: false,
loadingOlderHistory: false,
Expand Down
84 changes: 84 additions & 0 deletions src/browser/utils/workflowRunMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 52 additions & 2 deletions src/browser/utils/workflowRunMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
Expand Down
Loading