diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.accountChoice.test.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.accountChoice.test.tsx
index 8c452a251..1cf4bba76 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.accountChoice.test.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.accountChoice.test.tsx
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Status } from "@/api/types";
import { FeatureFlagContext } from "@/hooks/useFeatureFlag";
+import { useCopilotHeaderStore } from "@/store/useCopilotHeaderStore";
type StreamBody = {
message: string;
@@ -346,7 +347,9 @@ describe("WorkflowCopilotChat connected account choices", () => {
}).disabled,
).toBe(true);
if (accountQuestion) {
- expect(screen.getByText("Choose a Google account")).toBeTruthy();
+ expect(
+ screen.getByRole("group", { name: "Connected Google accounts" }),
+ ).toBeTruthy();
expect(
screen.getByRole("button", {
name: /Connection …goac_1/,
@@ -400,6 +403,28 @@ describe("WorkflowCopilotChat connected account choices", () => {
},
);
+ it("docks an actionable choice above the composer and picks an active row by number", async () => {
+ await renderChat();
+ await finishChoiceAsk();
+ const tray = screen.getByRole("group", {
+ name: "Connected Google accounts",
+ });
+ expect(
+ Boolean(
+ screen.getByText(/choose below/).compareDocumentPosition(tray) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ),
+ ).toBe(true);
+ expect(useCopilotHeaderStore.getState().attention).toBe("account");
+
+ // The first number is the first active row; the reconnect-only row above it has no key.
+ await act(async () => {
+ fireEvent.keyDown(tray, { key: "1" });
+ });
+ await waitFor(() => expect(postStreaming).toHaveBeenCalledTimes(2));
+ expect(streamCalls[1]?.body.selected_connected_account_id).toBe("goac_1");
+ });
+
it("renders canonical rows and sends one exact active id despite a same-tick double click", async () => {
await renderChat();
await finishChoiceAsk();
diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.credentialCard.test.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.credentialCard.test.tsx
index 3f8729bb4..7a2d22a3f 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.credentialCard.test.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.credentialCard.test.tsx
@@ -15,6 +15,7 @@ import {
canonicalRecoveriesByWorkflow,
} from "./WorkflowCopilotChat";
+import { useCopilotHeaderStore } from "@/store/useCopilotHeaderStore";
import { useRecordingRefinementEvidenceStore } from "@/store/RecordingRefinementEvidenceStore";
import {
beginYamlCommit,
@@ -580,6 +581,85 @@ describe("WorkflowCopilotChat — activity log", () => {
});
describe("WorkflowCopilotChat — credential receipt placement", () => {
+ it("releases the dock and header flag when a docked sign-in request times out", async () => {
+ await streamScoutTurn();
+ vi.useFakeTimers();
+ await act(async () => {
+ streamCalls[0]!.onMessage(
+ credentialFrame({ anchor_tool_call_id: "tc-1" }),
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(screen.getByRole("group", { name: "Sign-in request" })).toBeTruthy();
+ expect(useCopilotHeaderStore.getState().attention).toBe("credential");
+
+ // The server sends no frame on timeout, so the deadline alone has to release the dock.
+ await act(async () => vi.advanceTimersByTimeAsync(300_001));
+ expect(screen.queryByRole("group", { name: "Sign-in request" })).toBeNull();
+ expect(screen.queryByText(/continue below/)).toBeNull();
+ expect(screen.getAllByText("Timed out").length).toBeGreaterThan(0);
+ expect(useCopilotHeaderStore.getState().attention).toBeNull();
+ });
+
+ it("docks a live sign-in request above the composer and hands the receipt back to the transcript", async () => {
+ credentialsData.current = [
+ { credential_id: "cred-hn", name: "HN Login", tested_url: null },
+ ];
+ await streamScoutTurn();
+ await act(async () => {
+ streamCalls[0]!.onMessage(
+ credentialFrame({ anchor_tool_call_id: "tc-1" }),
+ );
+ });
+ const precedes = (a: Node, b: Node) =>
+ Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
+ const tray = await screen.findByRole("group", { name: "Sign-in request" });
+ const marker = screen.getByText(/continue below/);
+ expect(
+ precedes(
+ document.querySelector('[data-activity-row-id="tc-1"]')!,
+ marker,
+ ),
+ ).toBe(true);
+ expect(precedes(marker, tray)).toBe(true);
+ expect(
+ precedes(
+ tray,
+ screen.getByRole("group", { name: "Copilot message composer" }),
+ ),
+ ).toBe(true);
+ expect(screen.getAllByRole("button", { name: "Skip for now" })).toEqual([
+ within(tray).getByRole("button", { name: "Skip for now" }),
+ ]);
+ expect(useCopilotHeaderStore.getState().attention).toBe("credential");
+
+ // Minimized, the tray keeps its name on the control that restores it.
+ fireEvent.click(
+ within(tray).getByRole("button", { name: "Minimize sign-in request" }),
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Show Sign-in request" }),
+ );
+ expect(screen.getByRole("group", { name: "Sign-in request" })).toBeTruthy();
+
+ await act(async () => {
+ streamCalls[0]!.onMessage({
+ type: "credential_pause_resolved",
+ turn_id: "turn-1",
+ workflow_copilot_chat_id: "chat-1",
+ resume_token: "rt-abc",
+ outcome: "connected",
+ credential_id: "cred-hn",
+ name: "HN Login",
+ timestamp: new Date().toISOString(),
+ });
+ });
+ expect(await screen.findByText("Credential 'HN Login' added")).toBeTruthy();
+ expect(screen.queryByRole("group", { name: "Sign-in request" })).toBeNull();
+ expect(screen.queryByText(/continue below/)).toBeNull();
+ expect(useCopilotHeaderStore.getState().attention).toBeNull();
+ });
+
it("keeps a mid-turn credential card after the row it was raised on, live and once the turn ends", async () => {
credentialsData.current = [
{ credential_id: "cred-hn", name: "HN Login", tested_url: null },
@@ -2090,9 +2170,14 @@ describe("WorkflowCopilotChat — credential card wiring", () => {
await waitFor(() =>
expect(credentialsGets().length).toBeGreaterThanOrEqual(1),
);
+ // The notice sits in the tray, and a second copy of the ask in the transcript would add a button.
+ const tray = await screen.findByRole("group", { name: "Sign-in request" });
expect(
- await screen.findByText(/Couldn't load your saved logins/),
- ).toBeTruthy();
+ within(tray).getAllByText(/Couldn't load your saved logins/),
+ ).toHaveLength(1);
+ expect(
+ screen.getAllByRole("button", { name: "Connect credential" }),
+ ).toHaveLength(1);
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(
screen.getByRole("button", { name: "Connect credential" }),
@@ -2322,6 +2407,9 @@ describe("WorkflowCopilotChat — credential card wiring", () => {
});
expect(await screen.findByRole("combobox")).toBeTruthy();
expect(screen.queryByText("Continuing with 'HN login'…")).toBeNull();
+ // A stranded ask is no longer the tail turn, so it stays inline instead of holding the dock.
+ expect(screen.queryByRole("group", { name: "Sign-in request" })).toBeNull();
+ expect(useCopilotHeaderStore.getState().attention).toBeNull();
});
it("hides a stale terminal ask once it is no longer the last message", async () => {
diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.historyRace.test.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.historyRace.test.tsx
index 39781e142..4ae1192b4 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.historyRace.test.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.historyRace.test.tsx
@@ -2921,7 +2921,7 @@ describe("WorkflowCopilotChat — question transport", () => {
expect(tray.nextElementSibling).toBe(
screen.getByRole("group", { name: "Copilot message composer" }),
);
- expect(useCopilotHeaderStore.getState().awaitingAnswer).toBe(true);
+ expect(useCopilotHeaderStore.getState().attention).toBe("question");
cancelPost.mockResolvedValueOnce({
data: {
@@ -2962,7 +2962,7 @@ describe("WorkflowCopilotChat — question transport", () => {
screen.queryByRole("group", { name: "Question parts" }),
).toBeNull(),
);
- expect(useCopilotHeaderStore.getState().awaitingAnswer).toBe(false);
+ expect(useCopilotHeaderStore.getState().attention).toBeNull();
});
it("waits for an answer before the composer sends, and locks Cancel while it posts", async () => {
diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx
index 827c6a908..4c1c5e3a7 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx
@@ -25,6 +25,7 @@ import {
import { ArtifactDownloadLink } from "@/components/ArtifactDownloadLink";
import { Button } from "@/components/ui/button";
import {
+ Fragment,
useState,
useEffect,
useLayoutEffect,
@@ -69,7 +70,10 @@ import {
} from "@/store/WorkflowHasChangesStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { useCopilotActionStore } from "@/store/useCopilotActionStore";
-import { useCopilotHeaderStore } from "@/store/useCopilotHeaderStore";
+import {
+ type CopilotAttention,
+ useCopilotHeaderStore,
+} from "@/store/useCopilotHeaderStore";
import {
buildWorkflowCopilotContext,
buildWorkflowYamlDocument,
@@ -172,7 +176,10 @@ import {
import { useRunLifecycleAnnouncements } from "./useRunLifecycleAnnouncements";
import { useHistoryLoad } from "./useHistoryLoad";
import { ConfirmCard, shouldShowConfirmCard } from "./cards/ConfirmCard";
-import { ConnectedAccountChoiceCard } from "./cards/ConnectedAccountChoiceCard";
+import {
+ ConnectedAccountChoiceCard,
+ ConnectedAccountChoiceMarker,
+} from "./cards/ConnectedAccountChoiceCard";
import { QuestionReceipt } from "./cards/QuestionReceipt";
import { QUESTION_PROMPT_ID, QuestionTray } from "./cards/QuestionTray";
import { useQuestionStepper } from "./useQuestionStepper";
@@ -188,9 +195,11 @@ import { connectedAccountChoiceLabel } from "./cards/connectedAccountChoiceLabel
import { shouldShowDiffCard } from "./cards/DiffCard";
import { ReviewGateCard, getReviewGateVerdict } from "./cards/ReviewGateCard";
import { TURN_ROW_INSET } from "./cards/cardLayout";
+import { type AttentionTrayPresentation } from "./cards/AttentionTray";
import { TestRunOutputCard } from "./cards/TestRunOutputCard";
import { GoogleReconnectCard } from "./cards/GoogleReconnectCard";
import {
+ CredentialAskMarker,
CredentialCard,
type CredentialRequiredFrame,
type CredentialRequiredReason,
@@ -1036,6 +1045,63 @@ type CredentialResolution = CredentialPauseHistorical & {
continued?: boolean;
};
+type TerminalCredentialAsk = {
+ turnId: string;
+ frame: CredentialRequiredFrame;
+ localResolution: CredentialResolution | undefined;
+ resolvedOutcome: CredentialPauseHistorical | undefined;
+ canContinue: boolean;
+};
+
+// The actionable ask is only live on the tail message; a resolved receipt renders on any message so
+// a scrolled-back turn keeps its outcome. A stranded ask (its auto-continue failed) stays actionable off-tail.
+function terminalCredentialAskFor(
+ message: ChatMessage,
+ isLastMessage: boolean,
+ credentialResolutions: Record,
+ pauseCardResolutions: Record,
+ strandedTerminalContinuations: ReadonlySet,
+): TerminalCredentialAsk | null {
+ const turnId = message.narrative?.turnId ?? null;
+ if (!message.narrative || turnId === null) return null;
+ const frame = credentialCardFrameFor(message.narrative);
+ if (!frame) return null;
+ const localResolution = credentialResolutions[turnId];
+ // The persisted pause verdict outranks the optimistic click, so a pick the server did not admit
+ // never reads as connected; the click's name survives via pauseCardResolutions.
+ const resolvedOutcome =
+ historicalCredentialOutcome(message.narrative, pauseCardResolutions) ??
+ localResolution;
+ const canContinue =
+ isLastMessage || strandedTerminalContinuations.has(turnId);
+ if (!resolvedOutcome && !canContinue) return null;
+ return { turnId, frame, localResolution, resolvedOutcome, canContinue };
+}
+
+function connectedAccountChoiceStateFor(
+ messages: ChatMessage[],
+ index: number,
+) {
+ const choices = messages[index]?.narrative?.connectedAccountChoices ?? [];
+ const adjacentMessage = nextAnsweringMessage(messages, index);
+ const selectedConnectionId =
+ adjacentMessage?.sender === "user" &&
+ choices.some((choice) => choice.connection_id === adjacentMessage.content)
+ ? adjacentMessage.content
+ : null;
+ // Once any later conversation message exists, this account-choice turn is historical. Only an
+ // exact structured selection may render a receipt; prose must never make the old card actionable
+ // again between stream completion and the next assistant response.
+ const hasUnconsumedAdjacentMessage =
+ adjacentMessage !== undefined && selectedConnectionId === null;
+ return {
+ choices,
+ adjacentMessage,
+ selectedConnectionId,
+ hasUnconsumedAdjacentMessage,
+ };
+}
+
// Append a resolution under its key (a turn or a card), capping the map with oldest-eviction like
// the sibling per-turn maps (turnSnapshots/turnOwnedRunIds). delete-then-set
// re-inserts an existing key as newest so an active one isn't evicted.
@@ -6701,11 +6767,126 @@ export function WorkflowCopilotChat({
setInputValue(settled);
});
}, [inputValue, isSpeechListening, stopSpeech, trayQuestionId]);
+
+ // What Copilot is waiting on the user for, docked above the composer one at a time. A question
+ // goes first because the composer answers it; the others wait behind it as "Up next".
+ const visibleRecoveredPauseFrames = isLoadingHistory
+ ? []
+ : recoveredPauseFrames.filter(
+ (frame) =>
+ frame.workflow_copilot_chat_id === workflowCopilotChatId &&
+ frame.turn_id !== livePauseFrame?.turn_id,
+ );
+ // The server sends nothing when a pause times out, so expiry is read from the frame itself; a
+ // missing or malformed expires_at counts as expired, matching the card's countdown.
+ const openPauseFrames = isLoadingHistory
+ ? []
+ : [
+ ...(livePauseFrame &&
+ livePauseFrame.turn_id === narrative.turnId &&
+ narrative.terminal === null
+ ? [livePauseFrame]
+ : []),
+ ...visibleRecoveredPauseFrames,
+ ].filter(
+ (frame) =>
+ !pauseCardResolutions[frame.resume_token] &&
+ Date.parse(frame.expires_at ?? "") > Date.now(),
+ );
+ const trayPauseFrame = openPauseFrames[0] ?? null;
+ const nextPauseExpiry = openPauseFrames.length
+ ? Math.min(
+ ...openPauseFrames.map((frame) => Date.parse(frame.expires_at ?? "")),
+ )
+ : null;
+ const [, setPauseExpiryTick] = useState(0);
+ useEffect(() => {
+ if (nextPauseExpiry === null) return;
+ const timer = window.setTimeout(
+ () => setPauseExpiryTick((tick) => tick + 1),
+ Math.max(0, nextPauseExpiry - Date.now()) + 1,
+ );
+ return () => window.clearTimeout(timer);
+ }, [nextPauseExpiry]);
+ const lastTurnIndex = findLastTurnIndex(messages);
+ // Only the tail turn's ask docks; a stranded one further up stays actionable inline.
+ const tailTerminalAsk =
+ trayPauseFrame || isLoadingHistory || lastTurnIndex < 0
+ ? null
+ : terminalCredentialAskFor(
+ messages[lastTurnIndex]!,
+ true,
+ credentialResolutions,
+ pauseCardResolutions,
+ strandedTerminalContinuations,
+ );
+ const trayTerminalAsk =
+ tailTerminalAsk && !tailTerminalAsk.resolvedOutcome
+ ? tailTerminalAsk
+ : null;
+ const trayCredentialKey = trayPauseFrame
+ ? `pause:${trayPauseFrame.resume_token}`
+ : trayTerminalAsk
+ ? `terminal:${trayTerminalAsk.turnId}`
+ : null;
+ const trayAccountChoice = (() => {
+ const tail = messages[lastTurnIndex];
+ const turnId = tail?.narrative?.turnId ?? null;
+ // A pick waiting in the queue already answered it, so the dock steps aside for the inline card.
+ if (
+ tail?.sender !== "ai" ||
+ turnId === null ||
+ queuedPrompt?.selectedConnectedAccountId !== undefined
+ ) {
+ return null;
+ }
+ const { choices, adjacentMessage } = connectedAccountChoiceStateFor(
+ messages,
+ lastTurnIndex,
+ );
+ // A picker with only reconnect links has nothing to choose, so it stays inline.
+ return adjacentMessage === undefined &&
+ choices.some((choice) => choice.state === "active")
+ ? { turnId, choices }
+ : null;
+ })();
+ // The question always leads, so it never needs an "Up next" label.
+ const attentionQueue: { kind: CopilotAttention; upNext?: string }[] = [];
+ if (trayQuestion) {
+ attentionQueue.push({ kind: "question" });
+ }
+ if (trayCredentialKey) {
+ attentionQueue.push({
+ kind: "credential",
+ upNext: "Copilot needs to sign in",
+ });
+ }
+ if (trayAccountChoice) {
+ attentionQueue.push({ kind: "account", upNext: "Choose a Google account" });
+ }
+ const activeAttention = attentionQueue[0]?.kind ?? null;
+ const attentionUpNext = attentionQueue[1]?.upNext ?? null;
+ // Only the open tray replaces its transcript card; queued items stay actionable inline.
+ const dockedPauseFrame =
+ activeAttention === "credential" ? trayPauseFrame : null;
+ const dockedTerminalAsk =
+ activeAttention === "credential" ? trayTerminalAsk : null;
+ const dockedAccountChoice =
+ activeAttention === "account" ? trayAccountChoice : null;
+ const [collapsedAttentionKey, setCollapsedAttentionKey] = useState<
+ string | null
+ >(null);
+ const attentionTray = (key: string): AttentionTrayPresentation => ({
+ collapsed: collapsedAttentionKey === key,
+ onCollapsedChange: (collapsed) =>
+ setCollapsedAttentionKey(collapsed ? key : null),
+ upNext: attentionUpNext,
+ });
useEffect(() => {
const store = useCopilotHeaderStore.getState();
- store.setAwaitingAnswer(hasPendingQuestion);
- return () => store.setAwaitingAnswer(false);
- }, [hasPendingQuestion]);
+ store.setAttention(activeAttention);
+ return () => store.setAttention(null);
+ }, [activeAttention]);
const uploadDroppedAttachments = useCallback(
(files: FileList) => {
const droppedFiles = Array.from(files);
@@ -9128,6 +9309,68 @@ export function WorkflowCopilotChat({
return null;
};
+ const pauseCredentialCard = (
+ frame: WorkflowCopilotCredentialRequiredUpdate,
+ tray?: AttentionTrayPresentation,
+ ) => (
+
+ openCredentialModal(frame, frame.turn_id, false, credential)
+ }
+ // A picked credential (id + name from the fetched list) answers through the typed resume
+ // POST, which origin-binds; the Add-credential CTA (no id) opens the modal.
+ onConnect={(credentialId, name) =>
+ credentialId
+ ? void respondToCredentialPause(
+ frame,
+ "connected",
+ credentialId,
+ name,
+ )
+ : openCredentialModal(frame, frame.turn_id)
+ }
+ onSkip={() => void respondToCredentialPause(frame, "skip")}
+ tray={tray}
+ />
+ );
+ const terminalCredentialCard = (
+ ask: TerminalCredentialAsk,
+ tray?: AttentionTrayPresentation,
+ ) => (
+
+ credentialId
+ ? continueAfterTerminalConnect(
+ ask.turnId,
+ credentialId,
+ name ?? ask.localResolution?.name,
+ ask.canContinue,
+ )
+ : openCredentialModal(null, ask.turnId, ask.canContinue)
+ }
+ onSkip={() => resolveTerminalCredential(ask.turnId, "skip")}
+ tray={tray}
+ />
+ );
+ const accountChoiceBusy = (turnId: string) =>
+ isLoading ||
+ hasPendingQuestion ||
+ acceptUnresolved ||
+ connectedAccountChoicePendingTurnId === turnId;
+
// The live turn places its plan and its credential card exactly where the finished turn will, so
// nothing moves when the turn ends.
const liveAnchored: AnchoredTurnItem[] = [];
@@ -9154,36 +9397,15 @@ export function WorkflowCopilotChat({
narrative,
livePauseFrame.anchor_tool_call_id,
),
- node: (
-
- openCredentialModal(
- livePauseFrame,
- livePauseFrame.turn_id,
- false,
- credential,
- )
- }
- // A picked credential (id + name from the fetched list) answers through the typed
- // resume POST, which origin-binds; the Add-credential CTA (no id) opens the modal.
- onConnect={(credentialId, name) =>
- credentialId
- ? void respondToCredentialPause(
- livePauseFrame,
- "connected",
- credentialId,
- name,
- )
- : openCredentialModal(livePauseFrame, livePauseFrame.turn_id)
- }
- onSkip={() => void respondToCredentialPause(livePauseFrame, "skip")}
- />
- ),
+ node:
+ dockedPauseFrame?.resume_token === livePauseFrame.resume_token ? (
+
+ ) : (
+ pauseCredentialCard(livePauseFrame)
+ ),
});
}
@@ -9303,7 +9525,6 @@ export function WorkflowCopilotChat({
? `Listening… · ${browserStatusText}`
: "Listening…"
: browserStatusText;
- const lastTurnIndex = findLastTurnIndex(messages);
// The composer is the text field for the pending question, so while one is pending it says so
// rather than inviting a new request.
const latestTurnIsAsk = questionInteractions.some(
@@ -9817,73 +10038,29 @@ export function WorkflowCopilotChat({
if (message.sender === "ai" && message.narrative) {
questionsPlaced = true;
const turnId = message.narrative.turnId;
- const choices = message.narrative.connectedAccountChoices;
- const adjacentMessage = nextAnsweringMessage(messages, index);
- const selectedConnectionId =
- adjacentMessage?.sender === "user" &&
- choices.some(
- (choice) =>
- choice.connection_id === adjacentMessage.content,
- )
- ? adjacentMessage.content
- : null;
- // Once any later conversation message exists, this account-choice turn is
- // historical. Only an exact structured selection may render a receipt;
- // prose must never make the old card actionable again between stream
- // completion and the next assistant response.
- const hasUnconsumedAdjacentMessage =
- adjacentMessage !== undefined &&
- selectedConnectionId === null;
+ const {
+ choices,
+ adjacentMessage,
+ selectedConnectionId,
+ hasUnconsumedAdjacentMessage,
+ } = connectedAccountChoiceStateFor(messages, index);
const showReviewGate =
shouldShowDiffCard(message.narrative) ||
(turnId !== null && turnId === pendingProposalTurnId);
const credentialCard = (() => {
- if (isLoadingHistory || turnId === null) return null;
- const credFrame = credentialCardFrameFor(message.narrative);
- if (!credFrame) return null;
- const localResolution = credentialResolutions[turnId];
- // The persisted pause verdict outranks the optimistic click, so a pick the server
- // did not admit never reads as connected; the click's name survives via pauseCardResolutions.
- const resolvedOutcome =
- historicalCredentialOutcome(
- message.narrative,
- pauseCardResolutions,
- ) ?? localResolution;
- // The actionable ask is only live on the tail message; a resolved receipt still
- // renders on any message so a scrolled-back turn keeps its outcome. Without this,
- // picking on a stale card would show a receipt with no backend call or continue.
- // A stranded ask (its auto-continue failed) stays actionable off-tail for a retry.
- if (
- !resolvedOutcome &&
- !isLastMessage &&
- !strandedTerminalContinuations.has(turnId)
- )
- return null;
- return (
- {
- const canContinue =
- isLastMessage ||
- strandedTerminalContinuations.has(turnId);
- return credentialId
- ? continueAfterTerminalConnect(
- turnId,
- credentialId,
- name ?? localResolution?.name,
- canContinue,
- )
- : openCredentialModal(null, turnId, canContinue);
- }}
- onSkip={() => resolveTerminalCredential(turnId, "skip")}
- />
+ if (isLoadingHistory) return null;
+ const ask = terminalCredentialAskFor(
+ message,
+ isLastMessage,
+ credentialResolutions,
+ pauseCardResolutions,
+ strandedTerminalContinuations,
+ );
+ if (!ask) return null;
+ return dockedTerminalAsk?.turnId === ask.turnId ? (
+
+ ) : (
+ terminalCredentialCard(ask)
);
})();
const autoBoundCard = (() => {
@@ -10013,18 +10190,19 @@ export function WorkflowCopilotChat({
a Google account.
) : null}
- {turnId !== null && choices.length > 0 ? (
+ {turnId !== null &&
+ choices.length > 0 &&
+ dockedAccountChoice?.turnId === turnId ? (
+
+ ) : turnId !== null && choices.length > 0 ? (
handleConnectedAccountChoice(turnId, connectionId)
@@ -10278,41 +10456,16 @@ export function WorkflowCopilotChat({
RESPONSE has frozen the narrative into the latest AI message —
otherwise the same turn would render twice.
*/}
- {!isLoadingHistory &&
- recoveredPauseFrames
- .filter(
- (frame) =>
- frame.workflow_copilot_chat_id === workflowCopilotChatId &&
- frame.turn_id !== livePauseFrame?.turn_id,
- )
- .map((frame) => (
-
- credentialId
- ? void respondToCredentialPause(
- frame,
- "connected",
- credentialId,
- name,
- )
- : openCredentialModal(frame, frame.turn_id)
- }
- onUpdateCredential={(credential) =>
- openCredentialModal(
- frame,
- frame.turn_id,
- false,
- credential,
- )
- }
- onSkip={() => void respondToCredentialPause(frame, "skip")}
- />
- ))}
+ {visibleRecoveredPauseFrames.map((frame) =>
+ frame.resume_token === dockedPauseFrame?.resume_token ? (
+
+ ) : (
+ pauseCredentialCard(frame)
+ ),
+ )}
{narrative.turnId !== null && narrative.terminal === null && (
+ ) : null}
+ {trayCredentialKey && (dockedPauseFrame || dockedTerminalAsk) ? (
+ // Keyed so moving between asks never carries one ask's picker state into the next.
+
+ {dockedPauseFrame
+ ? pauseCredentialCard(
+ dockedPauseFrame,
+ attentionTray(trayCredentialKey),
+ )
+ : dockedTerminalAsk
+ ? terminalCredentialCard(
+ dockedTerminalAsk,
+ attentionTray(trayCredentialKey),
+ )
+ : null}
+
+ ) : null}
+ {dockedAccountChoice ? (
+
+ handleConnectedAccountChoice(
+ dockedAccountChoice.turnId,
+ connectionId,
+ )
+ }
+ tray={attentionTray(`account:${dockedAccountChoice.turnId}`)}
/>
) : null}
{showQueuedStrip && queuedPrompt ? (
@@ -10605,8 +10789,8 @@ export function WorkflowCopilotChat({
onDrop={handleComposerDrop}
className={cn(
"relative flex items-end gap-1.5 rounded-lg border border-input bg-slate-elevation2 py-1.5 pl-3 pr-2 transition-colors focus-within:border-ring",
- (showQueuedStrip || trayQuestion) && "rounded-t-none",
- trayQuestion && "border-amber-500/50",
+ (showQueuedStrip || activeAttention) && "rounded-t-none",
+ activeAttention && "border-amber-500/50",
)}
>
{isFileDragging ? (
diff --git a/skyvern-frontend/src/routes/workflows/copilot/cards/AttentionTray.tsx b/skyvern-frontend/src/routes/workflows/copilot/cards/AttentionTray.tsx
new file mode 100644
index 000000000..9e384ee66
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/copilot/cards/AttentionTray.tsx
@@ -0,0 +1,161 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/util/utils";
+
+const TRAY_FRAME =
+ "rounded-t-lg border border-b-0 border-amber-500/50 bg-amber-500/[0.06]";
+const TRAY_TITLE = "font-semibold text-amber-700 dark:text-yellow-400";
+
+// How the chat asks a request card to render itself as the docked tray.
+export interface AttentionTrayPresentation {
+ collapsed: boolean;
+ onCollapsedChange: (collapsed: boolean) => void;
+ upNext?: string | null;
+}
+
+type AttentionTrayProps = Omit, "title"> & {
+ title: ReactNode;
+ titleId?: string;
+ // For a request whose title names what it needs, so an ellipsis cannot drop the site or account.
+ wrapTitle?: boolean;
+ meta?: ReactNode;
+ collapsedTitle: ReactNode;
+ collapsedMeta?: ReactNode;
+ collapsed: boolean;
+ onCollapsedChange: (collapsed: boolean) => void;
+ minimizeLabel: string;
+ // Another request waiting behind this one; the chat shows one at a time.
+ upNext?: string | null;
+ children: ReactNode;
+};
+
+// The docked frame above the composer for anything Copilot is waiting on the user for.
+export function AttentionTray({
+ title,
+ titleId,
+ wrapTitle = false,
+ meta,
+ collapsedTitle,
+ collapsedMeta,
+ collapsed,
+ onCollapsedChange,
+ minimizeLabel,
+ upNext,
+ children,
+ className,
+ ...groupProps
+}: AttentionTrayProps) {
+ if (collapsed) {
+ return (
+
+
+
+ {collapsedTitle}
+
+ {collapsedMeta}
+
+
+ );
+ }
+
+ return (
+
+ {upNext ? (
+
+
+ Up next ·
+
+ {upNext}
+
+
+ ) : null}
+
+
+
+ {title}
+
+
+ {meta}
+
+
+
+ {children}
+
+ );
+}
+
+// Where a docked request was raised in the transcript. While it is pending the answer happens in
+// the tray, so this row only points there.
+export function AttentionMarker({
+ icon,
+ title,
+ hint,
+}: {
+ icon: ReactNode;
+ title: string;
+ hint: string;
+}) {
+ return (
+
+ {icon}
+
+ {title} · {hint}
+
+
+ );
+}
diff --git a/skyvern-frontend/src/routes/workflows/copilot/cards/ConnectedAccountChoiceCard.tsx b/skyvern-frontend/src/routes/workflows/copilot/cards/ConnectedAccountChoiceCard.tsx
index f37909811..4dee73ae1 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/cards/ConnectedAccountChoiceCard.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/cards/ConnectedAccountChoiceCard.tsx
@@ -1,6 +1,13 @@
+import type { KeyboardEvent } from "react";
import { CheckIcon, Link2Icon } from "@radix-ui/react-icons";
import type { ConnectedAccountChoice } from "../workflowCopilotTypes";
+import {
+ AttentionMarker,
+ AttentionTray,
+ type AttentionTrayPresentation,
+} from "./AttentionTray";
+import { keyedChoiceFor, MAX_KEYED_CHOICES } from "./keyedChoice";
import { connectedAccountChoiceLabel } from "./connectedAccountChoiceLabel";
type ConnectedAccountChoiceCardProps = {
@@ -8,6 +15,8 @@ type ConnectedAccountChoiceCardProps = {
selectedConnectionId: string | null;
disabled: boolean;
onSelect: (connectionId: string) => void;
+ // Set when the chat docks this picker above the composer instead of in the transcript.
+ tray?: AttentionTrayPresentation;
};
export function ConnectedAccountChoiceCard({
@@ -15,7 +24,108 @@ export function ConnectedAccountChoiceCard({
selectedConnectionId,
disabled,
onSelect,
+ tray,
}: ConnectedAccountChoiceCardProps) {
+ // Only active rows send a choice; an inactive one is a link to reconnect it.
+ const keyed = tray
+ ? choices
+ .filter((choice) => choice.state === "active")
+ .slice(0, MAX_KEYED_CHOICES)
+ : [];
+ const rows = (
+
+ {rows}
);
}
+
+export function ConnectedAccountChoiceMarker() {
+ return (
+
+ }
+ title="Copilot needs a Google account"
+ hint="choose below"
+ />
+ );
+}
diff --git a/skyvern-frontend/src/routes/workflows/copilot/cards/CredentialCard.tsx b/skyvern-frontend/src/routes/workflows/copilot/cards/CredentialCard.tsx
index 61c4b4ea4..6546de725 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/cards/CredentialCard.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/cards/CredentialCard.tsx
@@ -4,6 +4,7 @@ import {
useRef,
useState,
type ReactNode,
+ type RefObject,
} from "react";
import { ChevronDownIcon, LockClosedIcon } from "@radix-ui/react-icons";
import { useDebounce } from "use-debounce";
@@ -38,6 +39,11 @@ import {
CopilotCard,
GutterRow,
} from "./cardChrome";
+import {
+ AttentionMarker,
+ AttentionTray,
+ type AttentionTrayPresentation,
+} from "./AttentionTray";
import { TURN_ROW_INSET } from "./cardLayout";
// Union of both a request-policy-time classifier's real reason tokens and a
@@ -119,6 +125,8 @@ export interface CredentialCardProps {
// "auto-bound" mode only: whether the Change affordance is live (the tail turn). A scrollback
// receipt stays read-only so a pick can't optimistically resolve without an actual continuation.
canChange?: boolean;
+ // Set when the chat docks this ask above the composer instead of in the transcript.
+ tray?: AttentionTrayPresentation;
}
const SIGN_IN_WHY_LINE =
@@ -473,11 +481,13 @@ function CredentialUpdateAsk({
credentialId,
onUpdateCredential,
onSkip,
+ tray,
}: {
frame: CredentialRequiredFrame;
credentialId: string;
onUpdateCredential?: (credential: CredentialApiResponse) => void;
onSkip: () => void;
+ tray?: AttentionTrayPresentation;
}) {
const { remainingMs, expired } = useCountdown(frame.expires_at ?? "", true);
const [rootRef, insideLiveRegion] = useInsideLiveRegion();
@@ -495,29 +505,28 @@ function CredentialUpdateAsk({
const status = credential ? "" : (loadFailure ?? "Loading saved login…");
return (
-
-
-
- }
- wrapTitle
- title={
- rejected
- ? `Update ${credential ? `'${credential.name}'` : "your saved login"} to sign in to ${site}`
- : credential
- ? `Add 2FA to '${credential.name}' to sign in to ${site}`
- : `Add 2FA to your saved login for ${site}`
- }
- right={}
- />
-
-
-
- {CREDENTIAL_WHY_LINE_BY_REASON[frame.reason]}
-
-
-
-
+ }
+ lines={[
+
+ {CREDENTIAL_WHY_LINE_BY_REASON[frame.reason]}
+ ,
+ ]}
+ footer={
+ <>
- {/* Mounted for the card's lifetime so only its text changes; inside an outer live region
- the visible status is already announced. */}
-
- {insideLiveRegion ? "" : status}
-
+ >
+ }
+ announcer={
+ // Mounted for the card's lifetime so only its text changes; inside an outer live region
+ // the visible status is already announced.
+
+ {insideLiveRegion ? "" : status}
+
+ }
+ />
+ );
+}
+
+// One unresolved ask, drawn either as a transcript card or as the tray docked above the composer.
+// The tray leaves the assistant's words to the transcript marker, which sits where the ask was raised.
+function AskChrome({
+ tray,
+ rootRef,
+ message,
+ title,
+ countdown,
+ lines,
+ footer,
+ announcer,
+}: {
+ tray?: AttentionTrayPresentation;
+ rootRef: RefObject;
+ message?: string;
+ title: string;
+ countdown: ReactNode;
+ lines: ReactNode[];
+ footer: ReactNode;
+ // Outside the collapsible body, so a minimized tray still announces a failure.
+ announcer: ReactNode;
+}) {
+ if (tray) {
+ return (
+
+ );
+}
+
+// Where a docked ask was raised: the assistant's words for it, then a pointer to the tray.
+export function CredentialAskMarker({ message }: { message?: string }) {
+ return (
+
);
}
@@ -631,6 +735,7 @@ export function CredentialCard(props: Readonly) {
credentialId={updateTargetId}
onUpdateCredential={props.onUpdateCredential}
onSkip={props.onSkip}
+ tray={props.tray}
/>
);
}
@@ -647,6 +752,7 @@ function CredentialAskCard({
reloadKey,
autoBound,
canChange = false,
+ tray,
}: Readonly) {
// Terminal mode never expires by design: its signal carries no timeout/expiry
// semantics at all, so there is nothing to compare "now" against. Only a
@@ -907,34 +1013,36 @@ function CredentialAskCard({
const site = siteFromLoginPageUrls(frame.login_page_urls);
return (
-
- {/* Mounted for the card's lifetime so only its text changes: a live region that appears
- already holding its text is read as ordinary new content and never announced. Inside
- an outer live region it takes no role and repeats nothing that region already
- contains, only the search failure, whose visible text sits in a portaled popover. */}
-
- {searchFailed
- ? SEARCH_FAILED_ANNOUNCEMENT
- : insideLiveRegion
- ? ""
- : orgCredentials.status === "loading"
- ? "Loading saved logins…"
- : orgCredentials.status === "error"
- ? "Couldn't load your saved logins."
- : ""}
-
-
-
-
+ >
+ }
+ announcer={
+ // Mounted for the card's lifetime so only its text changes: a live region that appears
+ // already holding its text is read as ordinary new content and never announced. Inside an
+ // outer live region it takes no role and repeats nothing that region already contains,
+ // only the search failure, whose visible text sits in a portaled popover.
+
+ {searchFailed
+ ? SEARCH_FAILED_ANNOUNCEMENT
+ : insideLiveRegion
+ ? ""
+ : orgCredentials.status === "loading"
+ ? "Loading saved logins…"
+ : orgCredentials.status === "error"
+ ? "Couldn't load your saved logins."
+ : ""}
+
+ }
+ />
);
}
diff --git a/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionPartsCard.tsx b/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionPartsCard.tsx
index 92b636746..8ae5a9cf5 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionPartsCard.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionPartsCard.tsx
@@ -2,7 +2,7 @@ import { CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@/util/utils";
import type { QuestionInteraction } from "../workflowCopilotTypes";
import { hasAnswer } from "./questionAnswers";
-import { MAX_KEYED_CHOICES } from "./QuestionTray";
+import { MAX_KEYED_CHOICES } from "./keyedChoice";
const TYPED_BUBBLE =
"max-w-full self-start whitespace-pre-wrap break-words rounded-[10px] border border-border bg-slate-elevation4 dark:border-white/5 px-2.5 py-1.5 text-[12.5px] leading-[1.45] text-foreground";
diff --git a/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionTray.tsx b/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionTray.tsx
index 92fa2770d..38a6fb200 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionTray.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/cards/QuestionTray.tsx
@@ -1,15 +1,11 @@
import { useEffect, useId, useRef, type KeyboardEvent } from "react";
-import {
- CheckIcon,
- ChevronDownIcon,
- ChevronUpIcon,
-} from "@radix-ui/react-icons";
+import { CheckIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/util/utils";
import type { QuestionStepper } from "../useQuestionStepper";
import type { QuestionInteraction } from "../workflowCopilotTypes";
-
-export const MAX_KEYED_CHOICES = 9;
+import { AttentionTray } from "./AttentionTray";
+import { keyedChoiceFor, MAX_KEYED_CHOICES } from "./keyedChoice";
// One tray renders at a time, so the composer can name the prompt it is answering.
export const QUESTION_PROMPT_ID = "copilot-question-prompt";
@@ -26,6 +22,7 @@ export function QuestionTray({
onCancel,
cancelDisabled,
cancelTitle,
+ upNext,
}: {
interaction: QuestionInteraction;
stepper: QuestionStepper;
@@ -40,6 +37,7 @@ export function QuestionTray({
onCancel?: () => void;
cancelDisabled?: boolean;
cancelTitle?: string;
+ upNext?: string | null;
}) {
const titleId = useId();
const advanceRef = useRef(null);
@@ -55,77 +53,35 @@ export function QuestionTray({
const part = interaction.parts[stepper.index];
const noun = total === 1 ? "question" : "questions";
- if (collapsed) {
- return (
-
{/* Stays mounted across steps so Back and Next read the new question to a screen reader
whose focus is still in the composer. */}
@@ -271,6 +227,6 @@ export function QuestionTray({
)}
-
+
);
}
diff --git a/skyvern-frontend/src/routes/workflows/copilot/cards/keyedChoice.ts b/skyvern-frontend/src/routes/workflows/copilot/cards/keyedChoice.ts
new file mode 100644
index 000000000..5121eb0e9
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/copilot/cards/keyedChoice.ts
@@ -0,0 +1,13 @@
+import type { KeyboardEvent } from "react";
+
+export const MAX_KEYED_CHOICES = 9;
+
+// The item a bare digit key picks from a tray's keyed list, or null for any other key.
+export function keyedChoiceFor(
+ event: KeyboardEvent,
+ keyed: readonly T[],
+): T | null {
+ if (event.metaKey || event.ctrlKey || event.altKey) return null;
+ if (!/^[1-9]$/.test(event.key)) return null;
+ return keyed[Number(event.key) - 1] ?? null;
+}
diff --git a/skyvern-frontend/src/routes/workflows/studio/CopilotPaneHeader.tsx b/skyvern-frontend/src/routes/workflows/studio/CopilotPaneHeader.tsx
index 42fd7ac99..467832b36 100644
--- a/skyvern-frontend/src/routes/workflows/studio/CopilotPaneHeader.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/CopilotPaneHeader.tsx
@@ -1,6 +1,9 @@
import { PlusIcon } from "@radix-ui/react-icons";
-import { useCopilotHeaderStore } from "@/store/useCopilotHeaderStore";
+import {
+ COPILOT_ATTENTION_CHIP,
+ useCopilotHeaderStore,
+} from "@/store/useCopilotHeaderStore";
import { useRecordingStore } from "@/store/useRecordingStore";
import { cn } from "@/util/utils";
@@ -85,13 +88,20 @@ export function CopilotActiveDot() {
);
}
-export function CopilotRecordingStatus() {
+export function CopilotPaneStatus() {
const recording = useRecordingStore((state) => state.isRecording);
const finishing = useRecordingStore(
(state) => state.finishRequested || state.isCommitting,
);
+ const attention = useCopilotHeaderStore((state) => state.attention);
if (!recording && !finishing) {
- return null;
+ // The tray it points at can sit below the fold of a short pane, or under a collapsed line.
+ return attention ? (
+
+
+ {COPILOT_ATTENTION_CHIP[attention]}
+
+ ) : null;
}
return (
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.test.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.test.tsx
index 43efc5a88..1c0bd87af 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.test.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.test.tsx
@@ -526,12 +526,12 @@ describe("StudioPaneToggles browser activity", () => {
});
describe("StudioPaneToggles Copilot question", () => {
- afterEach(() => useCopilotHeaderStore.getState().setAwaitingAnswer(false));
+ afterEach(() => useCopilotHeaderStore.getState().setAttention(null));
test.each(["copilot", "browser"])(
"flags the Copilot tab while a question waits (panes=%s)",
(panes) => {
- useCopilotHeaderStore.getState().setAwaitingAnswer(true);
+ useCopilotHeaderStore.getState().setAttention("question");
renderAt(`/workflows/wpid_abc/studio?panes=${panes}`);
expect(
screen.getByRole("button", {
@@ -541,10 +541,18 @@ describe("StudioPaneToggles Copilot question", () => {
},
);
+ test("names a sign-in request instead of a question", () => {
+ useCopilotHeaderStore.getState().setAttention("credential");
+ renderAt("/workflows/wpid_abc/studio?panes=browser");
+ expect(
+ screen.getByRole("button", { name: "Copilot, needs to sign in" }),
+ ).toBeTruthy();
+ });
+
test("drops the flag once the question is answered", () => {
- useCopilotHeaderStore.getState().setAwaitingAnswer(true);
+ useCopilotHeaderStore.getState().setAttention("question");
renderAt("/workflows/wpid_abc/studio?panes=copilot");
- act(() => useCopilotHeaderStore.getState().setAwaitingAnswer(false));
+ act(() => useCopilotHeaderStore.getState().setAttention(null));
expect(
screen.queryByRole("button", {
name: "Copilot, waiting for your answer",
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.tsx
index 5e4244f8b..b02f28b4f 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioPaneToggles.tsx
@@ -18,7 +18,10 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { useCopilotHeaderStore } from "@/store/useCopilotHeaderStore";
+import {
+ COPILOT_ATTENTION_LABEL,
+ useCopilotHeaderStore,
+} from "@/store/useCopilotHeaderStore";
import { useStudioBrowserStore } from "@/store/useStudioBrowserStore";
import { cn } from "@/util/utils";
@@ -93,7 +96,7 @@ export function StudioPaneToggles() {
(s) => s.hasUnseenActivity,
);
const clearBrowserActivity = useStudioBrowserStore((s) => s.clearActivity);
- const copilotAwaitingAnswer = useCopilotHeaderStore((s) => s.awaitingAnswer);
+ const copilotAttention = useCopilotHeaderStore((s) => s.attention);
const { runId, runStatus } = useStudioRunSignals();
const labelsCollapsed = useLabelsCollapsed();
@@ -176,13 +179,13 @@ export function StudioPaneToggles() {
const disabled = blockedByDeletion;
const showActivityDot =
id === "browser" && hasUnseenBrowserActivity && !open;
- // Shown even with the pane open: the question can still be off-screen behind other panes.
- const showAwaitingDot = id === "copilot" && copilotAwaitingAnswer;
+ // Shown even with the pane open: the request can still be off-screen behind other panes.
+ const awaiting = id === "copilot" ? copilotAttention : null;
const showRunStatusDot = isRunControl && Boolean(runStatus);
const ariaLabel = showActivityDot
? "Browser, new activity"
- : showAwaitingDot
- ? "Copilot, waiting for your answer"
+ : awaiting
+ ? `Copilot, ${COPILOT_ATTENTION_LABEL[awaiting]}`
: isRunControl && runStatus
? `${label}, ${runStatusLabel(runStatus)}`
: label;
@@ -199,10 +202,10 @@ export function StudioPaneToggles() {
- ) : showAwaitingDot ? (
+ ) : awaiting ? (
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx
index b7a2ccebe..df51bc38c 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx
@@ -38,7 +38,7 @@ import { BrowserPaneActions, BrowserPaneViewPills } from "./BrowserPaneHeader";
import {
CopilotActiveDot,
CopilotPaneControls,
- CopilotRecordingStatus,
+ CopilotPaneStatus,
} from "./CopilotPaneHeader";
import {
EditorPaneBlockSearch,
@@ -1082,7 +1082,7 @@ function StudioStage(props: StudioWorkspaceProps) {
>
}
+ headerExtras={}
headerActions={}
iconBadge={}
>
diff --git a/skyvern-frontend/src/store/useCopilotHeaderStore.ts b/skyvern-frontend/src/store/useCopilotHeaderStore.ts
index a092e32a3..7df1eecb9 100644
--- a/skyvern-frontend/src/store/useCopilotHeaderStore.ts
+++ b/skyvern-frontend/src/store/useCopilotHeaderStore.ts
@@ -13,6 +13,21 @@ export type CopilotHeaderControls = {
navigationLockedReason: string | null;
};
+// What the docked Copilot chat is waiting on the user for.
+export type CopilotAttention = "question" | "credential" | "account";
+
+export const COPILOT_ATTENTION_LABEL: Record = {
+ question: "waiting for your answer",
+ credential: "needs to sign in",
+ account: "needs a Google account",
+};
+
+export const COPILOT_ATTENTION_CHIP: Record = {
+ question: "Needs your answer",
+ credential: "Needs sign-in",
+ account: "Choose an account",
+};
+
/**
* Bridges the docked copilot chat and the studio's Copilot pane header: the
* chat registers its History/New-chat controls here so the header (rendered
@@ -22,15 +37,14 @@ export type CopilotHeaderControls = {
type CopilotHeaderState = {
controls: CopilotHeaderControls | null;
setControls: (controls: CopilotHeaderControls | null) => void;
- // A Copilot question is waiting on the user. The chat stays mounted while its pane is closed,
- // so the studio's pane toggle can flag it.
- awaitingAnswer: boolean;
- setAwaitingAnswer: (awaitingAnswer: boolean) => void;
+ // The chat stays mounted while its pane is closed, so the studio's pane toggle can flag it.
+ attention: CopilotAttention | null;
+ setAttention: (attention: CopilotAttention | null) => void;
};
export const useCopilotHeaderStore = create((set) => ({
controls: null,
setControls: (controls) => set({ controls }),
- awaitingAnswer: false,
- setAwaitingAnswer: (awaitingAnswer) => set({ awaitingAnswer }),
+ attention: null,
+ setAttention: (attention) => set({ attention }),
}));
diff --git a/skyvern/forge/agent.py b/skyvern/forge/agent.py
index f464a8277..8a2b85c1e 100644
--- a/skyvern/forge/agent.py
+++ b/skyvern/forge/agent.py
@@ -2270,45 +2270,31 @@ class ForgeAgent:
from skyvern.forge.taskv3.workflow_position import PreviousBlockHandoff, select_previous_block
from skyvern.utils.token_counter import approx_count_tokens
- # Workflow-block tasks re-resolve the live working page on every tool call, so a click that
- # opens a new tab/popup is followed (mirrors the step engine's get_working_page re-fetch).
- # Bare tasks keep today's exact semantics: one page grabbed up front, for the run's duration.
+ # Every task re-resolves the live working page on every tool call, so a click that opens a
+ # new tab/popup is followed (mirrors the step engine's get_working_page re-fetch).
+ # Fail fast (with the recovery attempt must_get makes) when the page is already gone at the
+ # start; mid-run losses surface through the per-call provider instead.
+ await browser_state.must_get_working_page()
# A block reuses the browser across blocks, so neither the landing status nor its URL belongs
# to this block's start; only a bare task names a starting page in the dead-end verdict.
initial_navigation_url: str | None = None
- follows_working_page = task_block is not None or workflow_owned_recovery
- if follows_working_page:
- # Fail fast (with the same recovery attempt bare tasks get) when the page is already
- # gone at block start; mid-run losses surface through the per-call provider instead.
- await browser_state.must_get_working_page()
-
- async def _page_provider() -> Any:
- # must_get (not get): recovers a crashed/closed page via _reopen_lost_working_page,
- # matching the step engine's per-action re-acquisition; its raise on unrecoverable
- # loss is contained by the loop's per-tool-call error handling.
- return await browser_state.must_get_working_page()
-
- async def _fingerprint_page() -> Any:
- # get (not must_get): recovery at finish time could navigate and induce duplicate
- # actions, so a lost page yields None and the verdict is accepted as-is.
- return await browser_state.get_working_page()
-
- else:
- initial_page = await browser_state.must_get_working_page()
+ if task_block is None and not workflow_owned_recovery:
# The pair setup recorded, not the page's URL now: the status belongs to the landed response
# (page.goto returns the last redirect hop), and the settle and challenge-solver waits that
# follow it give a client-side redirect time to move the page somewhere the status was never
# about. Reading the page here would name that destination as the page that 404ed.
initial_navigation_url = browser_state.last_navigation_url
- async def _page_provider() -> Any:
- return initial_page
+ async def _page_provider() -> Any:
+ # must_get (not get): recovers a crashed/closed page via _reopen_lost_working_page,
+ # matching the step engine's per-action re-acquisition; its raise on unrecoverable
+ # loss is contained by the loop's per-tool-call error handling.
+ return await browser_state.must_get_working_page()
- async def _fingerprint_page() -> Any:
- # The fingerprint MUST sample the page the tools act on. Bare tasks pin one page for
- # the run, and browser_state.get_working_page() would both return the newest tab
- # (wrong page after any popup) and repoint the working page as a side effect.
- return None if initial_page.is_closed() else initial_page
+ async def _fingerprint_page() -> Any:
+ # get (not must_get): recovery at finish time could navigate and induce duplicate
+ # actions, so a lost page yields None and the verdict is accepted as-is.
+ return await browser_state.get_working_page()
llm_caller = LLMCaller(llm_key=await _resolve_task_v3_llm_key(task))
workflow_run_context = (
@@ -2991,8 +2977,7 @@ class ForgeAgent:
async def _reload_page() -> None:
# Observed by the action policy like the legacy internal refresh; the loop records it in
- # the action round. A bare task pins one page, so it is passed explicitly.
- pinned = None if task_block is not None else await _page_provider()
+ # the action round.
preflight_action(
ReloadPageAction(
reasoning="a page-level handler requested a refresh",
@@ -3001,10 +2986,10 @@ class ForgeAgent:
task_id=task.task_id,
step_id=step.step_id,
),
- pinned if pinned is not None else await _fingerprint_page(),
+ await _fingerprint_page(),
site="internal_refresh",
)
- await browser_state.reload_page(page=pinned)
+ await browser_state.reload_page()
async def _restore_page_url(page: Any, url: str) -> None:
# Not `reload_page`: the tab is blanked in place, so reloading it reloads `about:blank`.
@@ -3289,8 +3274,7 @@ class ForgeAgent:
elif not page_free_validation and not download_finalized:
blank_page = None
try:
- # A block's tools act on the working page the gate just read; a bare task's on its pinned page.
- first_sample = gate_page if follows_working_page else await _fingerprint_page()
+ first_sample = gate_page
async def _settle_should_stop() -> bool:
if time.monotonic() >= loop_deadline_at:
diff --git a/tests/unit/test_agent_task_v3.py b/tests/unit/test_agent_task_v3.py
index 121385eaa..043daa0d1 100644
--- a/tests/unit/test_agent_task_v3.py
+++ b/tests/unit/test_agent_task_v3.py
@@ -119,6 +119,10 @@ async def _run_execute_task_v3(
provider_probe_calls: int = 0,
get_working_page_side_effect: list[Any] | None = None,
must_get_working_page_side_effect: BaseException | list[Any] | None = None,
+ # Both page accessors return this page, for tests that probe what the run's closures read.
+ working_page: Any = None,
+ # A real browser state whose accessors stand in for the mocked ones.
+ real_browser_state: Any = None,
loop_raises: BaseException | None = None,
update_task_side_effect: BaseException | None = None,
completion_gate_vetoes: bool = False,
@@ -132,14 +136,12 @@ async def _run_execute_task_v3(
page_url_after_settle: str | None = None,
# Where the page is once the loop returns -- the loop itself can leave the tab somewhere else.
page_url_after_loop: str | None = None,
- # A tab the run opened that is newer than the page the tools act on, at this URL once the loop returns.
- newest_tab_url_after_loop: str | None = None,
- pinned_page_open: bool = False,
# The block's own (url, navigation_goal) as the AUTHOR typed them, pinned through the real block
# seam before the render that produced the task fields above.
unrendered_block_fields: tuple[str | None, str | None] | None = None,
# Called with the loop's kwargs before the loop returns, to drive state the loop's tools own.
on_loop: Callable[[dict[str, Any]], None] | None = None,
+ on_loop_async: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
# A prompt the fake loop hands the goal judge, the way the finish gate would, when one was built.
goal_judge_prompt: str | None = None,
# Leave the credential-TOTP candidate gate reading the real workflow-run context.
@@ -153,8 +155,6 @@ async def _run_execute_task_v3(
step = make_step(now, task, step_id="step-v3", status=StepStatus.created, order=0, output=None)
browser_state, _, page = make_browser_state()
- if pinned_page_open:
- page.is_closed = MagicMock(return_value=False)
# What setup's navigation RECORDED: the response's own URL beside the status it came back with,
# which after a redirect is not the URL it asked for.
browser_state.last_navigation_status = navigation_status
@@ -167,6 +167,12 @@ async def _run_execute_task_v3(
browser_state.get_working_page = AsyncMock(side_effect=get_working_page_side_effect)
else:
browser_state.get_working_page = AsyncMock(return_value=page)
+ if working_page is not None:
+ browser_state.must_get_working_page = AsyncMock(return_value=working_page)
+ browser_state.get_working_page = AsyncMock(return_value=working_page)
+ if real_browser_state is not None:
+ browser_state.must_get_working_page = AsyncMock(side_effect=real_browser_state.must_get_working_page)
+ browser_state.get_working_page = AsyncMock(side_effect=real_browser_state.get_working_page)
browser_state.take_post_action_screenshot = AsyncMock(
return_value=b"png-bytes",
side_effect=RuntimeError("screenshot boom") if screenshot_raises else None,
@@ -187,6 +193,8 @@ async def _run_execute_task_v3(
await cb(round_actions, turn_text)
if on_loop is not None:
on_loop(kwargs)
+ if on_loop_async is not None:
+ await on_loop_async(kwargs)
if goal_judge_prompt is not None and kwargs.get("goal_judge") is not None:
page.is_closed = MagicMock(return_value=False)
loop_mock.goal_judge_response = await kwargs["goal_judge"](goal_judge_prompt)
@@ -197,10 +205,6 @@ async def _run_execute_task_v3(
raise loop_raises
if page_url_after_loop is not None:
page.url = page_url_after_loop
- if newest_tab_url_after_loop is not None:
- newest_tab = MagicMock(url=newest_tab_url_after_loop)
- newest_tab.main_frame.child_frames = []
- browser_state.get_working_page = AsyncMock(return_value=newest_tab)
return outcome
loop_mock = AsyncMock(side_effect=_loop)
@@ -3137,29 +3141,10 @@ async def test_execute_task_v3_should_cancel_skips_workflow_read_for_bare_task(
# ---------------------------------------------------------------------------
-# P4: the page provider (live re-resolution for workflow blocks, once for bare tasks)
+# P4: the page provider (live re-resolution on every tool call)
# ---------------------------------------------------------------------------
-@pytest.mark.asyncio
-async def test_execute_task_v3_bare_task_provider_resolves_page_once(monkeypatch: pytest.MonkeyPatch) -> None:
- # A bare task must preserve today's exact semantics: must_get_working_page grabs the page once
- # up front, and every later provider call returns that same object, not a re-resolved one.
- outcome = LoopOutcome(status="completed", reason="done", billable_actions=[])
- _step, _task, loop_mock, _post = await _run_execute_task_v3(
- monkeypatch,
- outcome,
- provider_probe_calls=3,
- data_extraction_goal=None,
- extracted_information_schema=None,
- )
- assert loop_mock.resolved_pages == [loop_mock.resolved_pages[0]] * 3
- loop_mock.browser_state.must_get_working_page.assert_awaited_once()
- # The completion gate reads the page once on a completed outcome; the PROVIDER itself never
- # consults get_working_page for a bare task.
- assert loop_mock.browser_state.get_working_page.await_count <= 1
-
-
@pytest.mark.asyncio
async def test_a_workflow_owned_recovery_keeps_its_caller_s_step_cap(monkeypatch: pytest.MonkeyPatch) -> None:
# The code block AI fallback sizes its own budget from the org's cap, so the v3 floor must not
@@ -3946,36 +3931,6 @@ async def test_execute_task_v3_completed_on_a_page_still_loading_completes(monke
assert task.status == TaskStatus.completed
-@pytest.mark.asyncio
-async def test_execute_task_v3_bare_task_judges_its_pinned_page_not_the_newest_tab(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- # A bare task's tools act on one pinned page; a blank popup it opened along the way is not that page.
- monkeypatch.setattr(agent_module, "asyncio", ScopedAsyncio(sleep=AsyncMock()))
- outcome = LoopOutcome(status="completed", reason="done", billable_actions=["click"])
- _step, task, _loop_mock, _post = await _run_execute_task_v3(
- monkeypatch,
- outcome,
- pinned_page_open=True,
- newest_tab_url_after_loop="about:blank",
- data_extraction_goal=None,
- extracted_information_schema=None,
- )
- assert task.status == TaskStatus.completed
-
- _step, task, _loop_mock, _post = await _run_execute_task_v3(
- monkeypatch,
- outcome,
- pinned_page_open=True,
- page_url_after_loop="about:blank",
- newest_tab_url_after_loop="https://example.com/results",
- data_extraction_goal=None,
- extracted_information_schema=None,
- )
- assert task.status == TaskStatus.failed
- assert "blank page" in (task.failure_reason or "")
-
-
@pytest.mark.asyncio
async def test_v3_blank_page_settle_stops_at_the_deadline_and_judges_the_last_sample(
monkeypatch: pytest.MonkeyPatch,
@@ -5091,44 +5046,6 @@ async def test_execute_task_v3_settle_completion_fenced_to_block_tasks(
assert bare_loop_mock.await_args.kwargs["max_settle_deferrals"] == 0
-@pytest.mark.asyncio
-async def test_execute_task_v3_bare_task_fingerprint_samples_the_pinned_page(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- # A bare task pins one page for the run, so its fingerprint must sample THAT page. Going through
- # browser_state.get_working_page() would return the newest tab after any popup — sampling a page
- # the model never acted on — and would repoint the working page as a side effect, which is a
- # behaviour change to the live bare-task arm rather than the scoped one this gate intends.
- outcome = LoopOutcome(status="completed", reason="done", billable_actions=[])
- pinned = MagicMock()
- pinned.is_closed = MagicMock(return_value=False)
- pinned.evaluate = AsyncMock(return_value="pinned-hash:100:10")
- popup = MagicMock()
- popup.is_closed = MagicMock(return_value=False)
- popup.evaluate = AsyncMock(return_value="popup-hash:1:1")
-
- _step, _task, loop_mock, _post = await _run_execute_task_v3(
- monkeypatch,
- outcome,
- must_get_working_page_side_effect=[pinned],
- get_working_page_side_effect=[popup, popup, popup],
- data_extraction_goal=None,
- extracted_information_schema=None,
- )
- fingerprint = loop_mock.await_args.kwargs["page_fingerprint"]
- before = loop_mock.browser_state.get_working_page.await_count
- assert await fingerprint() == "pinned-hash:100:10"
- # The sampler probed the pinned page and never the popup, and did not consult (or repoint) the
- # browser's working page to do it. Counting the delta rather than asserting never-awaited: the
- # post-loop completion-veto gate legitimately calls get_working_page once, before this point.
- popup.evaluate.assert_not_awaited()
- assert loop_mock.browser_state.get_working_page.await_count == before
-
- # A closed pinned page yields None rather than silently falling back to another tab.
- pinned.is_closed = MagicMock(return_value=True)
- assert await fingerprint() is None
-
-
@pytest.mark.asyncio
async def test_execute_task_v3_page_fingerprint_samples_child_frames(monkeypatch: pytest.MonkeyPatch) -> None:
# The settle deferral is a live gate: a main-frame-only fingerprint reads a page whose child frame
@@ -5152,7 +5069,7 @@ async def test_execute_task_v3_page_fingerprint_samples_child_frames(monkeypatch
_step, _task, loop_mock, _post = await _run_execute_task_v3(
monkeypatch,
outcome,
- must_get_working_page_side_effect=[pinned],
+ working_page=pinned,
data_extraction_goal=None,
extracted_information_schema=None,
)
@@ -5200,7 +5117,7 @@ async def test_execute_task_v3_document_identity_changes_with_an_acted_in_frame_
_step, _task, loop_mock, _post = await _run_execute_task_v3(
monkeypatch,
outcome,
- must_get_working_page_side_effect=[pinned],
+ working_page=pinned,
data_extraction_goal=None,
extracted_information_schema=None,
)
@@ -5279,7 +5196,7 @@ async def test_execute_task_v3_document_identity_raises_when_a_realm_is_unidenti
_step, _task, loop_mock, _post = await _run_execute_task_v3(
monkeypatch,
outcome,
- must_get_working_page_side_effect=[pinned],
+ working_page=pinned,
data_extraction_goal=None,
extracted_information_schema=None,
)
@@ -5302,7 +5219,7 @@ async def test_execute_task_v3_document_identity_raises_when_a_realm_is_unidenti
_step, _task, loop_mock2, _post = await _run_execute_task_v3(
monkeypatch,
outcome,
- must_get_working_page_side_effect=[pinned2],
+ working_page=pinned2,
data_extraction_goal=None,
extracted_information_schema=None,
)