mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-10-02 19:57:59 +00:00
Follow a click-opened tab in bare Task V3 tasks, as workflow blocks already do (#8733)
This commit is contained in:
parent
a9bed62b14
commit
62bf33b98a
17 changed files with 1048 additions and 509 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue