Files
tutti/packages/agent/gui/shared/agentConversation/components/AgentInteractiveDecisionPromptSurfaces.tsx
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

627 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, ShortcutBadge } from "@tutti-os/ui-system";
import {
getOptionalAgentHostApi,
useOptionalAgentHostApi
} from "../../../agentActivityHost";
import {
approvalOptionDisplayLabel,
approvalOptionVisualPresentation
} from "../approvalOptionPresentation";
import type { AgentConversationPromptVM } from "../contracts/agentConversationVM";
import {
PLAN_IMPLEMENTATION_ACTION_FEEDBACK,
PLAN_IMPLEMENTATION_ACTION_IMPLEMENT,
PLAN_IMPLEMENTATION_ACTION_SKIP
} from "../planImplementationPresentation";
import type { AgentInteractivePromptSurfaceProps } from "./AgentInteractivePromptSurface";
import {
approvalFeedbackOptionId,
approvalOptionShortcutLabel,
CommandTextWithTooltip,
formatApprovalToolPresentation,
InteractiveOptionSpinner,
interactiveOptionLabel,
interactivePromptCardClassName,
interactivePromptClassName,
isDenyApprovalOptionToken,
isDarwinPlatform,
isEditableKeyboardTarget,
isEnterLikeKey,
PromptDetailValue,
SendFilledIcon,
stripPromptTitlePunctuation
} from "./interactivePromptPresentation";
import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles";
export function ApprovalPromptSurface({
prompt,
embedded = false,
edgeGlow = false,
keyboardShortcuts = true,
isSubmitting,
isInteractionDisabled = false,
onSubmit,
labels
}: AgentInteractivePromptSurfaceProps & {
prompt: Extract<AgentConversationPromptVM, { kind: "approval" }>;
embedded?: boolean;
}) {
"use memo";
const promptToolPresentation = useMemo(
() => formatApprovalToolPresentation(prompt, labels),
[labels, prompt]
);
const [submittingOptionId, setSubmittingOptionId] = useState<string | null>(
null
);
const [pendingFeedbackOptionId, setPendingFeedbackOptionId] = useState<
string | null
>(null);
const [feedback, setFeedback] = useState("");
const feedbackTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const agentHostApi = useOptionalAgentHostApi() ?? getOptionalAgentHostApi();
const isDarwin = isDarwinPlatform(agentHostApi?.meta?.platform);
const controlsDisabled = isSubmitting || isInteractionDisabled;
const feedbackOptionId = useMemo(
() => approvalFeedbackOptionId(prompt.options),
[prompt.options]
);
const feedbackValue = feedback.trim();
const approveOptionId = useMemo(
() =>
prompt.options.find(
(option) =>
option.id !== feedbackOptionId &&
!isDenyApprovalOptionToken(option.id) &&
!isDenyApprovalOptionToken(option.kind)
)?.id ?? null,
[feedbackOptionId, prompt.options]
);
const denyOptionId = useMemo(
() =>
prompt.options.find(
(option) =>
option.id !== feedbackOptionId &&
(isDenyApprovalOptionToken(option.id) ||
isDenyApprovalOptionToken(option.kind))
)?.id ?? null,
[feedbackOptionId, prompt.options]
);
useEffect(() => {
setSubmittingOptionId(null);
setPendingFeedbackOptionId(null);
setFeedback("");
}, [prompt.requestId]);
useEffect(() => {
if (!isSubmitting) {
setSubmittingOptionId(null);
}
}, [isSubmitting]);
useEffect(() => {
if (pendingFeedbackOptionId !== null) {
feedbackTextareaRef.current?.focus();
}
}, [pendingFeedbackOptionId]);
const submitOption = useCallback(
(optionId: string) => {
if (controlsDisabled) {
return;
}
const feedbackOption = feedbackOptionId === optionId;
if (feedbackOption && pendingFeedbackOptionId !== optionId) {
setFeedback("");
setPendingFeedbackOptionId(optionId);
return;
}
setSubmittingOptionId(optionId);
onSubmit({
requestId: prompt.requestId,
...(feedbackOption ? { action: "deny" } : {}),
optionId,
...(feedbackOption && feedbackValue
? { payload: { denyMessage: feedbackValue } }
: {})
});
},
[
feedbackOptionId,
feedbackValue,
controlsDisabled,
onSubmit,
pendingFeedbackOptionId,
prompt.requestId
]
);
useEffect(() => {
if (
!keyboardShortcuts ||
controlsDisabled ||
submittingOptionId !== null ||
prompt.options.length === 0
) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (
!isEnterLikeKey(event) ||
isEditableKeyboardTarget(event.target) ||
event.isComposing ||
event.altKey ||
event.shiftKey
) {
return;
}
const optionIndex = event.metaKey || event.ctrlKey ? 1 : 0;
const option = prompt.options[optionIndex];
if (!option) {
return;
}
event.preventDefault();
event.stopPropagation();
submitOption(option.id);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [
controlsDisabled,
keyboardShortcuts,
prompt.options,
submitOption,
submittingOptionId
]);
return (
<section
className={interactivePromptClassName(embedded)}
data-agent-interaction-id={prompt.requestId}
data-agent-interaction-kind="approval"
data-testid={`agent-interaction-${prompt.requestId}`}
>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptLeadContent}>
<div className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(promptToolPresentation.lead)}
</div>
{promptToolPresentation.leadDetails.map((detail) => (
<div
key={`${detail.kind}:${detail.value}`}
className={styles.interactivePromptQuestion}
>
{detail.value}
{detail.meta ? ` ${detail.meta}` : null}
</div>
))}
</div>
{promptToolPresentation.cardDetails.length > 0 ? (
<div className={styles.interactivePromptOptions}>
{promptToolPresentation.cardDetails.map((detail) => (
<div
key={`${detail.label}:${detail.value}`}
className={styles.interactiveOptionDisplay}
>
<span className={styles.interactiveOptionTitle}>
{detail.label}
</span>
<PromptDetailValue detail={detail} />
{detail.meta ? (
<span className={styles.interactiveOptionDescription}>
{detail.meta}
</span>
) : null}
</div>
))}
</div>
) : null}
<div className={styles.interactivePromptOptions}>
{prompt.options.map((option, optionIndex) => {
const showSpinner = submittingOptionId === option.id;
const optionSupportsFeedback = feedbackOptionId === option.id;
const optionLabel = approvalOptionDisplayLabel(option, {
feedback: optionSupportsFeedback
});
const optionPresentation = approvalOptionVisualPresentation(
option,
{ feedback: optionSupportsFeedback }
);
const shortcutLabel = approvalOptionShortcutLabel(
optionIndex,
isDarwin
);
const showFeedbackComposer = pendingFeedbackOptionId === option.id;
const action = approvalActionTestId(
option.id,
approveOptionId,
denyOptionId,
feedbackOptionId
);
if (showFeedbackComposer) {
return (
<div
key={option.id}
className={styles.interactiveFeedbackComposer}
>
<textarea
ref={feedbackTextareaRef}
value={feedback}
placeholder={labels.feedbackPlaceholder}
disabled={controlsDisabled || submittingOptionId !== null}
className={styles.interactivePromptTextarea}
aria-label={interactiveOptionLabel(
optionLabel,
option.description
)}
data-testid={`agent-approval-${prompt.requestId}-${action}-input`}
onChange={(event) => setFeedback(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key !== "Enter" ||
event.shiftKey ||
event.nativeEvent.isComposing
) {
return;
}
event.preventDefault();
if (!feedbackValue) {
feedbackTextareaRef.current?.focus();
return;
}
submitOption(option.id);
}}
/>
<button
type="button"
className={styles.interactiveFeedbackSendButton}
disabled={
controlsDisabled ||
submittingOptionId !== null ||
!feedbackValue
}
aria-label={labels.sendFeedback}
title={labels.sendFeedback}
aria-busy={showSpinner}
data-testid={`agent-approval-${prompt.requestId}-${action}-submit`}
onClick={() => {
if (!feedbackValue) {
feedbackTextareaRef.current?.focus();
return;
}
submitOption(option.id);
}}
>
{showSpinner ? (
<InteractiveOptionSpinner />
) : (
<SendFilledIcon />
)}
</button>
</div>
);
}
return (
<button
key={option.id}
type="button"
className={styles.interactiveOptionButton}
aria-label={interactiveOptionLabel(
optionLabel,
option.description
)}
data-agent-approval-action={action}
data-agent-approval-option-id={option.id}
data-testid={`agent-approval-${prompt.requestId}-${action}`}
disabled={controlsDisabled || submittingOptionId !== null}
onClick={() => submitOption(option.id)}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{optionPresentation.label}
</span>
{optionPresentation.commandPrefix ? (
<CommandTextWithTooltip
value={optionPresentation.commandPrefix}
testId="agent-interactive-command-prefix-option"
tooltipsEnabled
/>
) : null}
{option.description ? (
<span className={styles.interactiveOptionDescription}>
{option.description}
</span>
) : null}
</span>
{keyboardShortcuts && shortcutLabel && !showSpinner ? (
<ShortcutBadge
className={styles.interactiveOptionShortcut}
aria-hidden="true"
>
{shortcutLabel}
</ShortcutBadge>
) : null}
{showSpinner ? <InteractiveOptionSpinner /> : null}
</button>
);
})}
</div>
</div>
</section>
);
}
function approvalActionTestId(
optionID: string,
approveOptionID: string | null,
denyOptionID: string | null,
feedbackOptionID: string | null
): string {
if (optionID === feedbackOptionID) return "feedback";
if (optionID === denyOptionID) return "deny";
if (optionID === approveOptionID) return "approve";
return `option-${optionID}`;
}
export function ExitPlanPromptSurface({
prompt,
variant = "full",
embedded = false,
edgeGlow = false,
isSubmitting,
isInteractionDisabled = false,
onSubmit,
labels
}: AgentInteractivePromptSurfaceProps & {
prompt: Extract<AgentConversationPromptVM, { kind: "exit-plan" }>;
embedded?: boolean;
}) {
"use memo";
const [feedback, setFeedback] = useState("");
// Compact (message-center deck): only the permission-mode decision buttons are
// offered; refining / staying in plan is deferred to the conversation.
const showFeedbackFooter = variant !== "compact";
const [submittingOptionId, setSubmittingOptionId] = useState<string | null>(
null
);
const trimmed = feedback.trim();
const continueLabel =
trimmed === "" ? labels.stayInPlan : labels.sendFeedback;
const controlsDisabled = isSubmitting || isInteractionDisabled;
// Render the permission modes the runtime actually offered ("Yes, and ...")
// so newly added modes (e.g. "auto") appear automatically. Localized
// label/description are looked up by option id, falling back to the runtime's
// own label for ids we don't have copy for. Only when the runtime sent no
// options (Codex plan / legacy exitplanmode) do we use the curated defaults.
const modes =
prompt.options.length > 0
? prompt.options.map((option) => {
const known = labels.planModes.find((mode) => mode.id === option.id);
return {
id: option.id,
label: known?.label ?? option.label,
description: known?.description ?? option.description ?? ""
};
})
: labels.planModes;
useEffect(() => {
setSubmittingOptionId(null);
}, [prompt.requestId]);
useEffect(() => {
if (!isSubmitting) {
setSubmittingOptionId(null);
}
}, [isSubmitting]);
return (
<section
className={interactivePromptClassName(embedded)}
data-agent-interaction-id={prompt.requestId}
data-agent-interaction-kind="exit-plan"
data-testid={`agent-exit-plan-${prompt.requestId}`}
>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(labels.planLead)}
</div>
<div className={styles.interactivePromptOptions}>
{modes.map((mode) => {
const showSpinner = submittingOptionId === mode.id;
return (
<button
key={mode.id}
type="button"
className={styles.interactiveOptionButton}
aria-label={interactiveOptionLabel(
mode.label,
mode.description
)}
data-testid={`agent-exit-plan-confirm-${mode.id}`}
disabled={controlsDisabled || submittingOptionId !== null}
onClick={() => {
setSubmittingOptionId(mode.id);
onSubmit({
requestId: prompt.requestId,
action: "allow",
optionId: mode.id
});
}}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{mode.label}
</span>
<span className={styles.interactiveOptionDescription}>
{mode.description}
</span>
</span>
{showSpinner ? <InteractiveOptionSpinner /> : null}
</button>
);
})}
</div>
{showFeedbackFooter ? (
<div className={styles.interactivePromptFooter}>
<textarea
value={feedback}
placeholder={labels.feedbackPlaceholder}
disabled={controlsDisabled}
className={styles.interactivePromptTextarea}
data-testid="agent-exit-plan-feedback"
onChange={(event) => setFeedback(event.currentTarget.value)}
/>
<div className={styles.interactivePromptActions}>
<Button
type="button"
variant="secondary"
size="sm"
disabled={controlsDisabled}
data-testid="agent-exit-plan-keep-planning"
onClick={() =>
onSubmit({
requestId: prompt.requestId,
// The runtime models exit-plan as an approval that requires
// an option id, so "keep planning" submits its `plan` option
// id (when present) rather than a bare deny. `action: deny`
// is kept so the controller doesn't flip plan mode off.
action: "deny",
...(prompt.keepPlanningOptionId
? { optionId: prompt.keepPlanningOptionId }
: {}),
payload: trimmed ? { denyMessage: trimmed } : undefined
})
}
>
{continueLabel}
</Button>
</div>
</div>
) : (
// Compact (deck): no textarea, but keep declining reachable — the deck
// must still let the user keep planning (refining/feedback is deferred
// to the conversation via the card's "open conversation" jump).
<div className={styles.interactivePromptActions}>
<Button
type="button"
variant="secondary"
size="sm"
disabled={controlsDisabled}
data-testid="agent-exit-plan-keep-planning"
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action: "deny",
...(prompt.keepPlanningOptionId
? { optionId: prompt.keepPlanningOptionId }
: {})
})
}
>
{labels.stayInPlan}
</Button>
</div>
)}
</div>
</section>
);
}
// Codex plan-mode "implement this plan?" decision. No server request; actions
// are routed by the consumer (controller inline, desktop chrome in the message
// center) keyed on the action id rather than a server submitInteractive.
export function PlanImplementationSurface({
prompt,
variant = "full",
embedded = false,
edgeGlow = false,
isSubmitting,
isInteractionDisabled = false,
onSubmit,
labels
}: AgentInteractivePromptSurfaceProps & {
prompt: Extract<AgentConversationPromptVM, { kind: "plan-implementation" }>;
embedded?: boolean;
}) {
"use memo";
const [feedback, setFeedback] = useState("");
const trimmed = feedback.trim();
const continueLabel =
trimmed === ""
? labels.planImplementationSkip
: labels.planImplementationSend;
// Compact (message-center deck): only the "implement" decision is offered.
// Refining or staying in plan mode is deferred to the conversation, reachable
// via the card's "open conversation" jump.
const showFeedbackFooter = variant !== "compact";
const controlsDisabled = isSubmitting || isInteractionDisabled;
return (
<section className={interactivePromptClassName(embedded)}>
<div className={interactivePromptCardClassName(edgeGlow)}>
<div className={styles.interactivePromptLead}>
{stripPromptTitlePunctuation(labels.planImplementationLead)}
</div>
<div className={styles.interactivePromptOptions}>
<button
type="button"
className={styles.interactiveOptionButton}
data-testid="agent-plan-implementation-implement"
disabled={controlsDisabled}
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action: PLAN_IMPLEMENTATION_ACTION_IMPLEMENT
})
}
>
<span className={styles.interactiveOptionContent}>
<span className={styles.interactiveOptionTitle}>
{labels.planImplementationConfirm}
</span>
</span>
</button>
</div>
{showFeedbackFooter ? (
<div className={styles.interactivePromptFooter}>
<textarea
value={feedback}
placeholder={labels.planImplementationFeedbackPlaceholder}
disabled={controlsDisabled}
className={styles.interactivePromptTextarea}
data-testid="agent-plan-implementation-feedback"
onChange={(event) => setFeedback(event.currentTarget.value)}
/>
<div className={styles.interactivePromptActions}>
<Button
type="button"
variant="secondary"
size="sm"
data-testid="agent-plan-implementation-continue"
disabled={controlsDisabled}
onClick={() =>
onSubmit({
requestId: prompt.requestId,
action:
trimmed === ""
? PLAN_IMPLEMENTATION_ACTION_SKIP
: PLAN_IMPLEMENTATION_ACTION_FEEDBACK,
payload: trimmed ? { text: trimmed } : undefined
})
}
>
{continueLabel}
</Button>
</div>
</div>
) : null}
</div>
</section>
);
}