improvement(function): secrets access dropdown (#6118)

This commit is contained in:
Vikhyath Mondreti
2026-07-30 20:40:20 -07:00
committed by GitHub
parent 04a8c0ac8f
commit 413784eceb
6 changed files with 62 additions and 31 deletions
@@ -66,6 +66,8 @@ interface DropdownProps {
dependsOn?: SubBlockConfig['dependsOn']
/** Enable search input in dropdown */
searchable?: boolean
/** Render option labels verbatim instead of lowercasing them */
preserveLabelCase?: boolean
}
/**
@@ -92,6 +94,7 @@ export const Dropdown = memo(function Dropdown({
fetchOptionById,
dependsOn,
searchable = false,
preserveLabelCase = false,
}: DropdownProps) {
const activeSearchTarget = useActiveSearchTarget()
const { isToolAllowed } = usePermissionConfig()
@@ -208,18 +211,19 @@ export const Dropdown = memo(function Dropdown({
}, [subBlockId, blockConfig, allOptions, isToolAllowed])
const comboboxOptions = useMemo((): ComboboxOption[] => {
const toLabel = (raw: string) => (preserveLabelCase ? raw : raw.toLowerCase())
return allOptions.map((opt) => {
if (typeof opt === 'string') {
return { label: opt.toLowerCase(), value: opt, hidden: deniedOperationIds.has(opt) }
return { label: toLabel(opt), value: opt, hidden: deniedOperationIds.has(opt) }
}
return {
label: opt.label.toLowerCase(),
label: toLabel(opt.label),
value: opt.id,
icon: 'icon' in opt ? opt.icon : undefined,
hidden: opt.hidden || deniedOperationIds.has(opt.id),
}
})
}, [allOptions, deniedOperationIds])
}, [allOptions, deniedOperationIds, preserveLabelCase])
const optionMap = useMemo(() => {
return new Map(comboboxOptions.map((opt) => [opt.value, opt.label]))
@@ -370,7 +374,8 @@ export const Dropdown = memo(function Dropdown({
return (
<div className='flex items-center gap-1 overflow-hidden whitespace-nowrap'>
{visibleValues.map((selectedValue: string, index) => {
const label = (optionMap.get(selectedValue) || selectedValue).toLowerCase()
const rawLabel = optionMap.get(selectedValue) || selectedValue
const label = preserveLabelCase ? rawLabel : rawLabel.toLowerCase()
const workflowSearchHighlight = getWorkflowSearchLabelHighlight({
activeSearchTarget,
blockId,
@@ -379,7 +384,7 @@ export const Dropdown = memo(function Dropdown({
label,
})
return (
<ChipTag key={selectedValue} variant='mono' className='min-w-0 shrink'>
<ChipTag key={selectedValue} variant='field' className='min-w-0 shrink'>
<span className='truncate'>
{formatDisplayText(label, { workflowSearchHighlight })}
</span>
@@ -387,13 +392,21 @@ export const Dropdown = memo(function Dropdown({
)
})}
{overflowCount > 0 && (
<ChipTag variant='mono' className='shrink-0'>
<ChipTag variant='field' className='shrink-0'>
+{overflowCount}
</ChipTag>
)}
</div>
)
}, [activeSearchTarget, blockId, multiSelect, multiValues, optionMap, subBlockId])
}, [
activeSearchTarget,
blockId,
multiSelect,
multiValues,
optionMap,
preserveLabelCase,
subBlockId,
])
const singleSelectOverlay = useMemo(() => {
if (multiSelect || !singleValue) return undefined
@@ -689,6 +689,7 @@ function SubBlockComponent({
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
searchable={config.searchable}
preserveLabelCase={config.preserveLabelCase}
/>
</div>
)
+3
View File
@@ -138,6 +138,9 @@ try {
paramVisibility: 'user-only',
multiSelect: true,
searchable: true,
// Secret names are case-sensitive: the code references the exact name via
// `{{NAME}}`, so the picker must not lowercase what it shows.
preserveLabelCase: true,
options: [],
condition: { field: 'secretScope', value: 'selected' },
placeholder: 'Select secrets this tool can read',
+9
View File
@@ -437,6 +437,15 @@ export interface SubBlockConfig {
rows?: number
// Multi-select functionality
multiSelect?: boolean
/**
* Dropdown-specific: render option labels verbatim instead of lowercasing them.
*
* The editor lowercases dropdown labels as a typographic convention, which
* suits authored operation names ("Send Message"). It corrupts labels that are
* case-sensitive identifiers the user must reproduce elsewhere — a workspace
* secret shown as `stripe_key` cannot be referenced as `{{stripe_key}}`.
*/
preserveLabelCase?: boolean
// Combobox specific: Enable search input in dropdown
searchable?: boolean
/** Dropdown-specific: include static options as Cmd K search entries that preset this subblock. */
+22 -23
View File
@@ -1,10 +1,6 @@
import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api'
import { fetchWorkspaceEnvironment } from '@/lib/environment/api'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import {
environmentKeys,
PERSONAL_ENVIRONMENT_STALE_TIME,
WORKSPACE_ENVIRONMENT_STALE_TIME,
} from '@/hooks/queries/environment'
import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment'
import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes'
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -43,30 +39,33 @@ export async function fetchWorkspaceWorkflowOptions(options?: {
* Loads the active workspace's secret NAMES for the Function block's secret-scope
* picker. Names only — values stay server-side and are injected at execution, the
* same discipline the copilot's workspace context uses.
*
* Both halves come from the single workspace-environment response, which is the
* client-side mirror of `getEffectiveDecryptedEnv` — the resolver the executor
* injects from. Its `personal` slice is NOT the caller's raw personal variables:
* under credential filtering it also carries personal secrets other members have
* shared into the workspace, so reading `/api/environment` instead would hide
* names the code can genuinely resolve. This is the same pair the `{{VAR}}`
* autocomplete lists, so the picker and the editor never disagree.
*
* Values are masked to `''` for non-admin viewers; only the keys are used here.
*/
export async function fetchWorkspaceSecretNameOptions(): Promise<SubBlockOption[]> {
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return []
const [workspace, personal] = await Promise.all([
getQueryClient().fetchQuery({
queryKey: environmentKeys.workspace(workspaceId),
queryFn: ({ signal }: { signal?: AbortSignal }) =>
fetchWorkspaceEnvironment(workspaceId, signal),
staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME,
}),
getQueryClient().fetchQuery({
queryKey: environmentKeys.personal(),
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchPersonalEnvironment(signal),
staleTime: PERSONAL_ENVIRONMENT_STALE_TIME,
}),
])
const environment = await getQueryClient().fetchQuery({
queryKey: environmentKeys.workspace(workspaceId),
queryFn: ({ signal }: { signal?: AbortSignal }) =>
fetchWorkspaceEnvironment(workspaceId, signal),
staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME,
})
// Personal variables shadow workspace ones at execution, so both are offered
// under one de-duplicated list of names.
// Workspace entries shadow personal ones at execution (`getEffectiveDecryptedEnv`
// spreads workspace last), but a name-only list just de-duplicates the union.
const names = new Set<string>([
...Object.keys(workspace?.workspace?.variables ?? {}),
...Object.keys(personal ?? {}),
...Object.keys(environment?.workspace ?? {}),
...Object.keys(environment?.personal ?? {}),
])
return [...names].sort().map((name) => ({ id: name, label: name }))
}
@@ -12,7 +12,11 @@ import { cn } from '../../lib/cn'
* Variants, theme-aware via workspace tokens:
* - `mono` — borderless, sharing the {@link ChipSwitch} trough surface
* (`--surface-5` light / `--surface-4` dark) with strong `--text-primary` text
* for emphasis (e.g. a discount next to a primary CTA).
* for emphasis (e.g. a discount next to a primary CTA). Because its fill *is*
* the trough/field surface, it disappears on one — use `field` there.
* - `field` — `mono` with the fill stepped one level away from the form-field
* surface (`--surface-6` light / `--surface-3` dark), so the pill stays legible
* when it sits *on* a `--surface-5` input, combobox trigger, or tag container.
* - `gray` — a light surface over a slightly darker inset ring with muted
* `--text-secondary` text for low-emphasis status labels.
* - `solid` — a filled inverse tag: a dark neutral surface (`--text-secondary`)
@@ -31,6 +35,8 @@ const chipTagVariants = cva(
variants: {
variant: {
mono: 'h-5 gap-[3px] px-1 bg-[var(--surface-5)] text-[var(--text-primary)] dark:bg-[var(--surface-4)]',
field:
'h-5 gap-[3px] px-1 bg-[var(--surface-6)] text-[var(--text-primary)] dark:bg-[var(--surface-3)]',
gray: 'h-5 gap-[3px] px-1 bg-[var(--surface-5)] text-[var(--text-secondary)] shadow-[inset_0_0_0_1px_var(--border-1)]',
solid: 'h-5 gap-[3px] px-1 bg-[var(--text-secondary)] text-[var(--text-inverse)]',
invite: