Follow a click-opened tab in bare Task V3 tasks, as workflow blocks already do (#8733)

This commit is contained in:
pedrohsdb 2026-09-30 00:18:15 -07:00 • committed by GitHub
parent a9bed62b14
commit 62bf33b98a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1048 additions and 509 deletions

View file

@ -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<HTMLButtonElement>("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();

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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<string, CredentialResolution>,
pauseCardResolutions: Record<string, CredentialResolution>,
strandedTerminalContinuations: ReadonlySet<string>,
): 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,
) => (
<CredentialCard
key={frame.resume_token}
frame={liveFrameToCardFrame(frame)}
mode="inline-pause"
reloadKey={credentialsReloadKey}
resolvedOutcome={pauseCardResolutions[frame.resume_token]}
onUpdateCredential={(credential) =>
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,
) => (
<CredentialCard
frame={ask.frame}
mode="terminal"
reloadKey={credentialsReloadKey}
resolvedOutcome={ask.resolvedOutcome}
continued={Boolean(ask.localResolution?.continued)}
// A picked credential (id + name from the fetched list) auto-continues by id; the
// Add-credential CTA (no id) opens the modal instead. A stranded ask (its prior continue
// failed) may continue too, though it is no longer the tail.
onConnect={(credentialId, name) =>
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: (
<CredentialCard
key={livePauseFrame.resume_token}
frame={liveFrameToCardFrame(livePauseFrame)}
mode="inline-pause"
reloadKey={credentialsReloadKey}
resolvedOutcome={pauseCardResolutions[livePauseFrame.resume_token]}
onUpdateCredential={(credential) =>
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 ? (
<CredentialAskMarker
key={livePauseFrame.resume_token}
message={livePauseFrame.message}
/>
) : (
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 (
<CredentialCard
frame={credFrame}
mode="terminal"
reloadKey={credentialsReloadKey}
resolvedOutcome={resolvedOutcome}
continued={Boolean(localResolution?.continued)}
// A picked credential (id + name from the fetched list) auto-continues by
// id; the Add-credential CTA (no id) opens the modal instead. A stranded ask
// (its prior continue failed) may continue too, though it is no longer the tail.
onConnect={(credentialId, name) => {
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 ? (
<CredentialAskMarker />
) : (
terminalCredentialCard(ask)
);
})();
const autoBoundCard = (() => {
@ -10013,18 +10190,19 @@ export function WorkflowCopilotChat({
a Google account.
</p>
) : null}
{turnId !== null && choices.length > 0 ? (
{turnId !== null &&
choices.length > 0 &&
dockedAccountChoice?.turnId === turnId ? (
<ConnectedAccountChoiceMarker />
) : turnId !== null && choices.length > 0 ? (
<ConnectedAccountChoiceCard
choices={choices}
selectedConnectionId={selectedConnectionId}
disabled={
!isLastMessage ||
isLoading ||
hasPendingQuestion ||
hasUnconsumedAdjacentMessage ||
selectedConnectionId !== null ||
acceptUnresolved ||
connectedAccountChoicePendingTurnId === turnId
accountChoiceBusy(turnId)
}
onSelect={(connectionId) =>
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) => (
<CredentialCard
key={frame.resume_token}
frame={liveFrameToCardFrame(frame)}
mode="inline-pause"
reloadKey={credentialsReloadKey}
resolvedOutcome={pauseCardResolutions[frame.resume_token]}
onConnect={(credentialId, name) =>
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 ? (
<CredentialAskMarker
key={frame.resume_token}
message={frame.message}
/>
) : (
pauseCredentialCard(frame)
),
)}
{narrative.turnId !== null && narrative.terminal === null && (
<div
className="flex flex-col gap-2"
@ -10582,6 +10735,37 @@ export function WorkflowCopilotChat({
isLoading || acceptUnresolved || isSubmittingQuestion
}
cancelTitle={acceptHoldReason ?? undefined}
upNext={attentionUpNext}
/>
) : null}
{trayCredentialKey && (dockedPauseFrame || dockedTerminalAsk) ? (
// Keyed so moving between asks never carries one ask's picker state into the next.
<Fragment key={trayCredentialKey}>
{dockedPauseFrame
? pauseCredentialCard(
dockedPauseFrame,
attentionTray(trayCredentialKey),
)
: dockedTerminalAsk
? terminalCredentialCard(
dockedTerminalAsk,
attentionTray(trayCredentialKey),
)
: null}
</Fragment>
) : null}
{dockedAccountChoice ? (
<ConnectedAccountChoiceCard
choices={dockedAccountChoice.choices}
selectedConnectionId={null}
disabled={accountChoiceBusy(dockedAccountChoice.turnId)}
onSelect={(connectionId) =>
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 ? (

View file

@ -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<HTMLAttributes<HTMLDivElement>, "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 (
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-xs",
TRAY_FRAME,
)}
>
<span
aria-hidden
className="size-2 shrink-0 rounded-full bg-amber-500"
/>
<span className={cn("min-w-0 flex-1 truncate", TRAY_TITLE)}>
{collapsedTitle}
</span>
{collapsedMeta}
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
aria-expanded={false}
// The collapsed line drops the group, so the button carries its name for screen readers.
aria-label={
groupProps["aria-label"]
? `Show ${groupProps["aria-label"]}`
: undefined
}
onClick={() => onCollapsedChange(false)}
>
Show
<ChevronUpIcon className="ml-1 size-3.5" />
</Button>
</div>
);
}
return (
<div
role="group"
{...groupProps}
className={cn(
"flex max-h-[50vh] min-w-0 flex-col overflow-hidden",
TRAY_FRAME,
className,
)}
>
{upNext ? (
<div className="flex min-w-0 items-center gap-2 border-b border-amber-500/25 px-3 py-1 text-xs text-muted-foreground">
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full bg-amber-500"
/>
<span className="shrink-0">Up next ·</span>
<span className="min-w-0 truncate font-medium text-amber-700 dark:text-yellow-400">
{upNext}
</span>
</div>
) : null}
<div className="flex items-center gap-2 px-3 pb-1 pt-2 text-xs">
<span
aria-hidden
className="size-2 shrink-0 rounded-full bg-amber-500 shadow-[0_0_0_3px_rgba(245,158,11,0.18)]"
/>
<span
id={titleId}
className={cn(
"min-w-0",
wrapTitle ? "break-words" : "truncate",
TRAY_TITLE,
)}
>
{title}
</span>
<div className="ml-auto flex shrink-0 items-center gap-1">
{meta}
<Button
size="icon"
variant="ghost"
className="size-6 text-muted-foreground"
aria-label={minimizeLabel}
aria-expanded
onClick={() => onCollapsedChange(true)}
>
<ChevronDownIcon className="size-3.5" />
</Button>
</div>
</div>
{children}
</div>
);
}
// 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 (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-amber-500/50 px-3 py-2 text-xs text-muted-foreground">
{icon}
<span className="min-w-0">
<span className={TRAY_TITLE}>{title}</span> · {hint}
</span>
</div>
);
}

View file

@ -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 = (
<div className="p-1">
{choices.map((choice) => {
const selected = selectedConnectionId === choice.connection_id;
const accountLabel = connectedAccountChoiceLabel(choice, choices);
const keyIndex = keyed.indexOf(choice);
const content = (
<>
{keyIndex >= 0 ? (
<kbd
aria-hidden
className="h-4 min-w-4 shrink-0 rounded border border-border px-1 text-center font-mono text-[10px] leading-[14px] text-muted-foreground"
>
{keyIndex + 1}
</kbd>
) : (
<Link2Icon className="size-4 flex-none text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{choice.name}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{accountLabel}
</span>
</span>
<span className="flex flex-none items-center gap-1.5 text-[11px] capitalize text-muted-foreground">
<span
className={`size-1.5 rounded-full ${choice.state === "active" ? "bg-success" : "bg-warning"}`}
aria-hidden="true"
/>
{choice.state === "active" ? "active" : "Reconnect"}
</span>
{selected ? (
<span className="flex-none text-success">
<CheckIcon className="size-4" aria-hidden="true" />
<span className="sr-only">Selected</span>
</span>
) : null}
</>
);
if (choice.state !== "active") {
return (
<a
key={choice.connection_id}
href="/integrations"
target="_blank"
rel="noreferrer"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{content}
</a>
);
}
return (
<button
key={choice.connection_id}
type="button"
disabled={disabled || selected}
aria-pressed={selected}
onClick={() => onSelect(choice.connection_id)}
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-default disabled:opacity-60"
>
{content}
</button>
);
})}
</div>
);
if (tray) {
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (disabled || selectedConnectionId !== null) return;
const choice = keyedChoiceFor(event, keyed);
if (!choice) return;
event.preventDefault();
onSelect(choice.connection_id);
};
return (
<AttentionTray
aria-label="Connected Google accounts"
onKeyDown={onKeyDown}
title="Choose a Google account"
collapsedTitle="Choose a Google account"
collapsed={tray.collapsed}
onCollapsedChange={tray.onCollapsedChange}
minimizeLabel="Minimize account choice"
upNext={tray.upNext}
>
<div className="min-h-0 overflow-y-auto">{rows}</div>
</AttentionTray>
);
}
return (
<div
className="overflow-hidden rounded-lg border border-border bg-slate-elevation2"
@ -30,63 +140,19 @@ export function ConnectedAccountChoiceCard({
Choose a Google account
</span>
</div>
<div className="p-1">
{choices.map((choice) => {
const selected = selectedConnectionId === choice.connection_id;
const accountLabel = connectedAccountChoiceLabel(choice, choices);
const content = (
<>
<Link2Icon className="size-4 flex-none text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{choice.name}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{accountLabel}
</span>
</span>
<span className="flex flex-none items-center gap-1.5 text-[11px] capitalize text-muted-foreground">
<span
className={`size-1.5 rounded-full ${choice.state === "active" ? "bg-success" : "bg-warning"}`}
aria-hidden="true"
/>
{choice.state === "active" ? "active" : "Reconnect"}
</span>
{selected ? (
<span className="flex-none text-success">
<CheckIcon className="size-4" aria-hidden="true" />
<span className="sr-only">Selected</span>
</span>
) : null}
</>
);
if (choice.state !== "active") {
return (
<a
key={choice.connection_id}
href="/integrations"
target="_blank"
rel="noreferrer"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{content}
</a>
);
}
return (
<button
key={choice.connection_id}
type="button"
disabled={disabled || selected}
aria-pressed={selected}
onClick={() => onSelect(choice.connection_id)}
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-default disabled:opacity-60"
>
{content}
</button>
);
})}
</div>
{rows}
</div>
);
}
export function ConnectedAccountChoiceMarker() {
return (
<AttentionMarker
icon={
<Link2Icon aria-hidden className="size-3.5 shrink-0 text-amber-500" />
}
title="Copilot needs a Google account"
hint="choose below"
/>
);
}

View file

@ -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 (
<div ref={rootRef}>
<AskMessage message={frame.message} />
<CopilotCard>
<CardHeader
icon={<LockClosedIcon className="h-3.5 w-3.5 text-warning" />}
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={<PauseCountdown remainingMs={remainingMs} expired={expired} />}
/>
<CardBody>
<GutterRow>
<span className="text-[11px] leading-relaxed text-muted-foreground">
{CREDENTIAL_WHY_LINE_BY_REASON[frame.reason]}
</span>
</GutterRow>
</CardBody>
<CardFooter className="flex flex-wrap items-center gap-2">
<AskChrome
tray={tray}
rootRef={rootRef}
message={frame.message}
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}`
}
countdown={<PauseCountdown remainingMs={remainingMs} expired={expired} />}
lines={[
<span
key="why"
className="text-[11px] leading-relaxed text-muted-foreground"
>
{CREDENTIAL_WHY_LINE_BY_REASON[frame.reason]}
</span>,
]}
footer={
<>
<Button
type="button"
size="sm"
@ -545,16 +554,111 @@ function CredentialUpdateAsk({
<div className="ml-auto">
<SkipButton onSkip={onSkip} disabled={expired} />
</div>
{/* Mounted for the card's lifetime so only its text changes; inside an outer live region
the visible status is already announced. */}
<span
className="sr-only"
role={insideLiveRegion ? undefined : "status"}
>
{insideLiveRegion ? "" : status}
</span>
</>
}
announcer={
// Mounted for the card's lifetime so only its text changes; inside an outer live region
// the visible status is already announced.
<span
className="sr-only"
role={insideLiveRegion ? undefined : "status"}
>
{insideLiveRegion ? "" : status}
</span>
}
/>
);
}
// 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<HTMLDivElement>;
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 (
<div ref={rootRef}>
<AttentionTray
aria-label="Sign-in request"
title={title}
wrapTitle
meta={countdown}
collapsedTitle="Copilot needs to sign in"
collapsedMeta={countdown}
collapsed={tray.collapsed}
onCollapsedChange={tray.onCollapsedChange}
minimizeLabel="Minimize sign-in request"
upNext={tray.upNext}
>
<div className="flex min-h-0 flex-col gap-1 overflow-y-auto px-3 pb-2.5 pt-1">
{lines.map((line, index) => (
<div key={index}>{line}</div>
))}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-t border-border px-3 py-1.5">
{footer}
</div>
</AttentionTray>
{announcer}
</div>
);
}
return (
<div ref={rootRef}>
<AskMessage message={message} />
<CopilotCard>
<CardHeader
icon={<LockClosedIcon className="h-3.5 w-3.5 text-warning" />}
wrapTitle
title={title}
right={countdown}
/>
<CardBody>
{lines.map((line, index) => (
<GutterRow key={index}>{line}</GutterRow>
))}
</CardBody>
<CardFooter className="flex flex-wrap items-center gap-2">
{footer}
</CardFooter>
</CopilotCard>
{announcer}
</div>
);
}
// 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 (
<div>
<AskMessage message={message} />
<AttentionMarker
icon={
<LockClosedIcon
aria-hidden
className="size-3.5 shrink-0 text-amber-500"
/>
}
title="Copilot needs to sign in"
hint="continue below"
/>
</div>
);
}
@ -631,6 +735,7 @@ export function CredentialCard(props: Readonly<CredentialCardProps>) {
credentialId={updateTargetId}
onUpdateCredential={props.onUpdateCredential}
onSkip={props.onSkip}
tray={props.tray}
/>
);
}
@ -647,6 +752,7 @@ function CredentialAskCard({
reloadKey,
autoBound,
canChange = false,
tray,
}: Readonly<CredentialCardProps>) {
// 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 (
<div ref={rootRef}>
<AskMessage message={frame.message} />
<CopilotCard>
<CardHeader
icon={<LockClosedIcon className="h-3.5 w-3.5 text-warning" />}
wrapTitle
title={`Copilot needs to sign in to ${site}`}
right={
countdownActive ? (
<PauseCountdown remainingMs={remainingMs} expired={expired} />
) : null
}
/>
<CardBody>
<GutterRow>
<span className="text-[11px] leading-relaxed text-muted-foreground">
{CREDENTIAL_WHY_LINE_BY_REASON[frame.reason] ?? SIGN_IN_WHY_LINE}
</span>
</GutterRow>
{mode === "terminal" ? (
<GutterRow>
<span className="text-[11px] font-medium leading-relaxed">
<AskChrome
tray={tray}
rootRef={rootRef}
message={frame.message}
title={`Copilot needs to sign in to ${site}`}
countdown={
countdownActive ? (
<PauseCountdown remainingMs={remainingMs} expired={expired} />
) : null
}
lines={[
<span
key="why"
className="text-[11px] leading-relaxed text-muted-foreground"
>
{CREDENTIAL_WHY_LINE_BY_REASON[frame.reason] ?? SIGN_IN_WHY_LINE}
</span>,
...(mode === "terminal"
? [
<span
key="continue"
className="text-[11px] font-medium leading-relaxed"
>
Connect a credential and I&apos;ll continue.
</span>
</GutterRow>
) : null}
</CardBody>
<CardFooter className="flex flex-wrap items-center gap-2">
</span>,
]
: []),
]}
footer={
<>
<Button
type="button"
size="sm"
@ -974,27 +1082,29 @@ function CredentialAskCard({
<div className="ml-auto">
<SkipButton onSkip={onSkip} disabled={disabled} />
</div>
{/* 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. */}
<span
className="sr-only"
role={insideLiveRegion ? undefined : "status"}
>
{searchFailed
? SEARCH_FAILED_ANNOUNCEMENT
: insideLiveRegion
? ""
: orgCredentials.status === "loading"
? "Loading saved logins…"
: orgCredentials.status === "error"
? "Couldn't load your saved logins."
: ""}
</span>
</CardFooter>
</CopilotCard>
</div>
</>
}
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.
<span
className="sr-only"
role={insideLiveRegion ? undefined : "status"}
>
{searchFailed
? SEARCH_FAILED_ANNOUNCEMENT
: insideLiveRegion
? ""
: orgCredentials.status === "loading"
? "Loading saved logins…"
: orgCredentials.status === "error"
? "Couldn't load your saved logins."
: ""}
</span>
}
/>
);
}

View file

@ -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";

View file

@ -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<HTMLButtonElement>(null);
@ -55,77 +53,35 @@ export function QuestionTray({
const part = interaction.parts[stepper.index];
const noun = total === 1 ? "question" : "questions";
if (collapsed) {
return (
<div className="flex items-center gap-2 rounded-t-lg border border-b-0 border-amber-500/50 bg-amber-500/[0.06] px-3 py-1.5 text-xs">
<span
aria-hidden
className="size-2 shrink-0 rounded-full bg-amber-500"
/>
<span className="min-w-0 flex-1 truncate font-semibold text-amber-700 dark:text-yellow-400">
Copilot is waiting on {total} {noun}
</span>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
aria-expanded={false}
onClick={() => onCollapsedChange(false)}
>
Show
<ChevronUpIcon className="ml-1 size-3.5" />
</Button>
</div>
);
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (disabled || !part || part.choices.length > MAX_KEYED_CHOICES) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
const choice = part.choices[Number(event.key) - 1];
if (!/^[1-9]$/.test(event.key) || !choice) return;
const choice = keyedChoiceFor(event, part.choices);
if (!choice) return;
event.preventDefault();
stepper.toggleChoice(part.part_id, choice.choice_id);
};
return (
<div
role="group"
<AttentionTray
aria-label="Question parts"
aria-describedby={titleId}
data-interaction-id={interaction.interaction_id}
onKeyDown={onKeyDown}
className="flex max-h-[50vh] min-w-0 flex-col overflow-hidden rounded-t-lg border border-b-0 border-amber-500/50 bg-amber-500/[0.06]"
title="Copilot needs your answer"
titleId={titleId}
meta={
total > 1 ? (
<span className="tabular-nums text-muted-foreground">
{stepper.index + 1} of {total}
</span>
) : null
}
collapsedTitle={`Copilot is waiting on ${total} ${noun}`}
collapsed={collapsed}
onCollapsedChange={onCollapsedChange}
minimizeLabel="Minimize question"
upNext={upNext}
>
<div className="flex items-center gap-2 px-3 pb-1 pt-2 text-xs">
<span
aria-hidden
className="size-2 shrink-0 rounded-full bg-amber-500 shadow-[0_0_0_3px_rgba(245,158,11,0.18)]"
/>
<span
id={titleId}
className="min-w-0 truncate font-semibold text-amber-700 dark:text-yellow-400"
>
Copilot needs your answer
</span>
<div className="ml-auto flex shrink-0 items-center gap-1">
{total > 1 ? (
<span className="tabular-nums text-muted-foreground">
{stepper.index + 1} of {total}
</span>
) : null}
<Button
size="icon"
variant="ghost"
className="size-6 text-muted-foreground"
aria-label="Minimize question"
aria-expanded
onClick={() => onCollapsedChange(true)}
>
<ChevronDownIcon className="size-3.5" />
</Button>
</div>
</div>
{/* Stays mounted across steps so Back and Next read the new question to a screen reader
whose focus is still in the composer. */}
<span className="sr-only" aria-live="polite">
@ -271,6 +227,6 @@ export function QuestionTray({
)}
</div>
</div>
</div>
</AttentionTray>
);
}

View file

@ -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<T>(
event: KeyboardEvent<HTMLElement>,
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;
}

View file

@ -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 ? (
<span className="flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-yellow-400">
<span className="size-1 rounded-full bg-amber-500" aria-hidden="true" />
{COPILOT_ATTENTION_CHIP[attention]}
</span>
) : null;
}
return (
<span className="flex items-center gap-1 text-[10px] font-medium text-red-600 dark:text-red-400">

View file

@ -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",

View file

@ -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() {
<span className="absolute inline-flex h-full w-full rounded-full bg-primary opacity-75 motion-safe:animate-ping" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
) : showAwaitingDot ? (
) : awaiting ? (
<span
aria-hidden
title="Copilot is waiting for your answer"
title={`Copilot, ${COPILOT_ATTENTION_LABEL[awaiting]}`}
className="absolute -right-0.5 -top-0.5 flex size-2"
>
<span className="absolute inline-flex h-full w-full rounded-full bg-amber-500 opacity-75 motion-safe:animate-ping" />

View file

@ -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) {
>
<StudioPane
{...paneProps("copilot")}
headerExtras={<CopilotRecordingStatus />}
headerExtras={<CopilotPaneStatus />}
headerActions={<CopilotPaneControls />}
iconBadge={<CopilotActiveDot />}
>

View file

@ -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<CopilotAttention, string> = {
question: "waiting for your answer",
credential: "needs to sign in",
account: "needs a Google account",
};
export const COPILOT_ATTENTION_CHIP: Record<CopilotAttention, string> = {
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<CopilotHeaderState>((set) => ({
controls: null,
setControls: (controls) => set({ controls }),
awaitingAnswer: false,
setAwaitingAnswer: (awaitingAnswer) => set({ awaitingAnswer }),
attention: null,
setAttention: (attention) => set({ attention }),
}));

View file

@ -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:

View file

@ -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,
)