Files
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

327 lines
10 KiB
TypeScript

import { Fragment, useEffect, useState } from "react";
import { prepareWorkspaceUserProjectSelection } from "@tutti-os/workspace-user-project/core";
import { useAgentHostApi } from "../../agentActivityHost";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
cn
} from "@tutti-os/ui-system";
import type {
AgentGUIComposerSettingOption,
AgentGUIComposerSettingsVM
} from "./model/agentGuiNodeTypes";
import type { WorkspaceLinkAction } from "../../actions/workspaceLinkActions";
import { AgentFullAccessWarningDialog } from "./AgentFullAccessWarningDialog";
import {
normalizePermissionModeSelection,
permissionModeSelectionPatch
} from "./model/composerModeSelection";
import { requiresFullAccessSafetyConfirmation } from "./model/agentPermissionModeSafetyPolicy";
import { acknowledgeCodexFullAccessWarning } from "./view/agentFullAccessWarningPreference";
import type { AgentComposerSettingsMenuLabels } from "./model/composerSettingsMenuModel";
import styles from "./AgentGUINode.styles";
import { ComposerOptionInfoTooltip } from "./AgentComposerModelReasoningDropdown";
export type { AgentComposerSettingsMenuLabels } from "./model/composerSettingsMenuModel";
export {
AgentProjectDropdown,
type AgentProjectDropdownLabels,
type AgentProjectPathChangeMetadata
} from "./AgentComposerProjectMenu";
export { AgentModelReasoningDropdown } from "./AgentComposerModelReasoningDropdown";
export function AgentPermissionModeDropdown({
composerSettings,
disabled = false,
disabledTooltip,
onLinkAction,
provider,
labels,
onSettingsChange
}: {
composerSettings: AgentGUIComposerSettingsVM;
disabled?: boolean;
disabledTooltip?: string;
onLinkAction?: (action: WorkspaceLinkAction) => void;
provider: string;
labels: Pick<
AgentComposerSettingsMenuLabels,
"permissionLabel" | "loadingOptions"
>;
onSettingsChange: (patch: {
permissionModeId?: string | null;
planMode?: boolean;
}) => void;
}): React.JSX.Element {
"use memo";
const [isSelectOpen, setIsSelectOpen] = useState(false);
const [pendingFullAccessModeId, setPendingFullAccessModeId] = useState<
string | null
>(null);
// While the daemon's composer options load, the permission options are empty
// and the trigger is disabled; surface a hover hint so the user knows it is
// still loading rather than permanently unavailable.
const isLoading = composerSettings.isSettingsLoading;
const availableOptions = composerSettings.availablePermissionModes ?? [];
const selectedValue =
composerSettings.selectedPermissionModeValue ??
composerSettings.draftSettings.permissionModeId;
// Plan mode is no longer a dropdown option — it is an independent toggle
// (Shift+Tab / plan badge). The dropdown lists only real permission modes.
const permissionOptions = permissionOptionsWithSelectedValue(
availableOptions,
selectedValue
);
const selectDisabled =
disabled ||
composerSettings.isSettingsLoading ||
composerSettings.permissionModeUnavailable ||
permissionOptions.length === 0;
const selectedOption =
permissionOptions.find((option) => option.value === selectedValue) ?? null;
// While loading, the permission options are empty and `selectedValue` is a
// raw mode id (e.g. "full-access"); show the loading copy instead so the
// trigger never surfaces an untranslated enum value.
const triggerLabel = isLoading
? labels.loadingOptions
: (selectedOption?.label ??
selectedValue?.trim() ??
labels.permissionLabel);
const triggerTone = selectDisabled
? undefined
: resolvePermissionModeTriggerTone(selectedValue);
const selectDisabledTooltip = disabled
? disabledTooltip
: isLoading
? labels.loadingOptions
: undefined;
const commitPermissionModeId = (permissionModeId: string): void => {
if (selectDisabled) {
return;
}
onSettingsChange(
permissionModeSelectionPatch(permissionModeId, {
clearsPlanMode:
composerSettings.planExclusiveWithPermissionMode === true
})
);
};
const applyPermissionModeId = (rawPermissionModeId: string): void => {
const permissionModeId =
normalizePermissionModeSelection(rawPermissionModeId);
if (!permissionModeId) {
return;
}
if (requiresFullAccessSafetyConfirmation(provider, permissionModeId)) {
setIsSelectOpen(false);
setPendingFullAccessModeId(permissionModeId);
return;
}
commitPermissionModeId(permissionModeId);
};
const selectTrigger = (
<SelectTrigger
className={cn(
"w-auto max-w-full",
styles.composerMenuTrigger,
selectDisabled &&
"cursor-not-allowed text-[var(--agent-gui-text-tertiary)] opacity-60 hover:text-[var(--agent-gui-text-tertiary)]",
isLoading && "animate-pulse"
)}
aria-label={labels.permissionLabel}
data-permission-tone={triggerTone}
>
<span className="flex min-w-0 flex-1 items-center">
<span className="truncate">{triggerLabel}</span>
</span>
</SelectTrigger>
);
return (
<Fragment>
<Select
open={isSelectOpen}
value={selectedValue ?? ""}
disabled={selectDisabled}
onOpenChange={setIsSelectOpen}
onValueChange={applyPermissionModeId}
>
{selectDisabledTooltip ? (
// Disabled controls do not receive pointer events. Target the tooltip
// at a focusable wrapper span so hover/focus reliably surfaces the
// reason, both during loading and while a turn is active.
<TooltipProvider delayDuration={120}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={0}>
{selectTrigger}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{selectDisabledTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
selectTrigger
)}
{isSelectOpen ? (
<SelectContent
align="end"
side="top"
sideOffset={4}
collisionPadding={16}
className={cn(
styles.composerMenuContent,
"w-max min-w-[220px] max-w-[calc(100vw-32px)] data-[side=top]:!translate-y-0"
)}
>
{permissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={selectDisabled}
className={cn(styles.composerMenuItem, "group/composer-option")}
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate">{option.label}</span>
{option.description ? (
<ComposerOptionInfoTooltip
description={option.description}
tooltipsEnabled
/>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
<AgentFullAccessWarningDialog
onConfirm={() => {
if (!pendingFullAccessModeId) {
return;
}
const permissionModeId = pendingFullAccessModeId;
setPendingFullAccessModeId(null);
acknowledgeCodexFullAccessWarning();
commitPermissionModeId(permissionModeId);
}}
onLinkAction={onLinkAction}
onOpenChange={(open) => {
if (!open) {
setPendingFullAccessModeId(null);
}
}}
open={pendingFullAccessModeId !== null}
/>
</Fragment>
);
}
function permissionOptionsWithSelectedValue(
options: readonly AgentGUIComposerSettingOption[],
selectedValue: string | null | undefined
): AgentGUIComposerSettingOption[] {
const normalizedSelectedValue = selectedValue?.trim() ?? "";
const clonedOptions = options.map((option) => ({ ...option }));
if (
!normalizedSelectedValue ||
clonedOptions.some((option) => option.value === normalizedSelectedValue)
) {
return clonedOptions;
}
return [
...clonedOptions,
{
value: normalizedSelectedValue,
label: normalizedSelectedValue
}
];
}
export function AgentProjectMissingStatusProbe({
composerSettings,
onProjectMissingChange
}: {
composerSettings: Pick<
AgentGUIComposerSettingsVM,
"selectedProjectPath" | "projectLocked"
>;
onProjectMissingChange: (isMissing: boolean) => void;
}): null {
"use memo";
const agentHostApi = useAgentHostApi();
const selectedPath = composerSettings.selectedProjectPath?.trim() ?? "";
useEffect(() => {
let canceled = false;
const userProjects = agentHostApi.userProjects;
if (!userProjects || !composerSettings.projectLocked || !selectedPath) {
onProjectMissingChange(false);
return () => {
canceled = true;
};
}
void prepareWorkspaceUserProjectSelection(userProjects, {
projectLocked: true,
selectedPath
}).then(
(prepared) => {
if (!canceled) {
onProjectMissingChange(prepared.isSelectedPathMissing);
}
},
() => {
if (!canceled) {
onProjectMissingChange(false);
}
}
);
return () => {
canceled = true;
};
}, [
agentHostApi.userProjects,
composerSettings.projectLocked,
onProjectMissingChange,
selectedPath
]);
return null;
}
function resolvePermissionModeTriggerTone(
value: string | null | undefined
): string | undefined {
switch (normalizePermissionModeValue(value)) {
case "read-only":
case "readonly":
case "ask-for-approval":
return "success";
case "auto":
case "default":
case "accept-edits":
case "acceptedits":
return "accent";
case "full-access":
case "bypasspermissions":
return "warning";
default:
return undefined;
}
}
function normalizePermissionModeValue(
value: string | null | undefined
): string | undefined {
const normalized = value?.trim().toLowerCase().replace(/\s+/g, "-");
return normalized || undefined;
}