Files
tutti/packages/agent/gui/workspaceWorkflow/tuttiModePlan/TuttiModePlanTaskAssignmentEditor.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

380 lines
12 KiB
TypeScript

import { CheckCheck, Split } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
Switch,
cn
} from "@tutti-os/ui-system";
import composerStyles from "../../agent-gui/agentGuiNode/AgentGUINode.styles";
import type { TuttiModePlanAssignmentCatalog } from "./useTuttiModePlanPanels";
import type { TuttiModePlanPanelTaskViewModel } from "./tuttiModePlanPanelProjection";
import {
effectiveTaskAssignmentValue,
effectiveTaskFlag,
type TuttiModePlanTaskAssignmentDraft
} from "./tuttiModePlanTaskAssignments";
export interface TuttiModePlanTaskAssignmentEditorLabels {
agentTarget: string;
model: string;
permissionMode: string;
reasoningEffort: string;
parallelizable: string;
autoAccept: string;
notSpecified: string;
assignmentOptionsLoading: string;
}
const CLEAR_VALUE = "__tutti_plan_assignment_clear__";
function toSelectValue(value: string): string {
return value === "" ? CLEAR_VALUE : value;
}
function fromSelectValue(value: string): string {
return value === CLEAR_VALUE ? "" : value;
}
/**
* Mirrors the composer permission-mode trigger tones so the plan panel's
* permission controls pick up the same accent/success/warning colors.
*/
export function permissionModeAssignmentTone(
value: string | null | undefined
): "accent" | "success" | "warning" | undefined {
switch (value?.trim().toLowerCase().replace(/\s+/g, "-") || undefined) {
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;
}
}
export function assignmentOptionLabel(
options: readonly { label: string; value: string }[],
value: string | null | undefined
): string | null {
const normalizedValue = value?.trim() ?? "";
if (!normalizedValue) return null;
return (
options.find((option) => option.value === normalizedValue)?.label ??
normalizedValue
);
}
/**
* Per-task assignment selectors for the single review checkpoint, rendered as
* one composer-styled row. Option catalogs are agent-scoped and supplied by
* the host; the editor only chooses among them and reports draft edits upward.
*
* Model plans are deliberately not exposed here: a plan enters orchestration
* by configuring an agent target bound to it, so the row offers only agent /
* model / permission mode / reasoning effort. An explicit model choice clears
* any document-stamped plan pin so the two can never contradict.
*/
export function TuttiModePlanTaskAssignmentEditor({
catalog,
disabled,
draft,
labels,
onEdit,
task
}: {
catalog: TuttiModePlanAssignmentCatalog;
disabled: boolean;
draft: TuttiModePlanTaskAssignmentDraft;
labels: TuttiModePlanTaskAssignmentEditorLabels;
onEdit(patch: TuttiModePlanTaskAssignmentDraft): void;
task: TuttiModePlanPanelTaskViewModel;
}): React.JSX.Element {
const agentValue = effectiveTaskAssignmentValue(
draft.agentTargetId,
task.agentTargetId
);
const modelValue = effectiveTaskAssignmentValue(draft.model, task.model);
const permissionValue = effectiveTaskAssignmentValue(
draft.permissionModeId,
task.permissionModeId
);
const effortValue = effectiveTaskAssignmentValue(
draft.reasoningEffort,
task.reasoningEffort
);
const parallelizable = effectiveTaskFlag(
draft.parallelizable,
task.parallelizable
);
const autoAccept = effectiveTaskFlag(draft.autoAccept, task.autoAccept);
// Options for document-referenced agents are preloaded by the panels hook
// alongside the snapshot refresh; user-driven agent changes trigger the
// (deduplicated) load from the change handler, so no component effect is
// needed here.
const agentDetail = agentValue
? (catalog.optionsByAgentId[agentValue] ?? null)
: null;
const modelOptions = agentDetail?.models ?? [];
const detailPending = Boolean(agentValue) && !agentDetail;
const agentLabel = agentValue
? ((catalog.agents ?? []).find(
(agent) => agent.agentTargetId === agentValue
)?.label ?? agentValue)
: null;
return (
<div
className="tutti-plan-task-assignment-row mt-3 flex flex-wrap items-center gap-3 border-t border-[var(--line-2)] pt-2"
data-testid={`tutti-plan-task-assignment-${task.id}`}
>
<Select
disabled={disabled}
value={toSelectValue(agentValue)}
onValueChange={(value) => {
const nextAgent = fromSelectValue(value);
if (nextAgent) catalog.loadAgentOptions(nextAgent);
onEdit({ agentTargetId: nextAgent });
}}
>
<AssignmentSelectTrigger
fieldLabel={labels.agentTarget}
value={agentLabel}
/>
<AssignmentSelectContent>
<AssignmentSelectItem value={CLEAR_VALUE}>
{labels.notSpecified}
</AssignmentSelectItem>
{(catalog.agents ?? []).map((agent) => (
<AssignmentSelectItem
key={agent.agentTargetId}
value={agent.agentTargetId}
>
{agent.label}
</AssignmentSelectItem>
))}
{agentValue &&
!(catalog.agents ?? []).some(
(agent) => agent.agentTargetId === agentValue
) ? (
<AssignmentSelectItem value={agentValue}>
{agentValue}
</AssignmentSelectItem>
) : null}
</AssignmentSelectContent>
</Select>
<AssignmentValueSelect
disabled={disabled || !agentValue || detailPending}
fieldLabel={labels.model}
notSpecifiedLabel={labels.notSpecified}
options={modelOptions}
pending={detailPending}
pendingLabel={labels.assignmentOptionsLoading}
value={modelValue}
onChange={(value) => onEdit({ model: value, modelPlanId: "" })}
/>
<AssignmentValueSelect
disabled={disabled || !agentValue || detailPending}
fieldLabel={labels.permissionMode}
notSpecifiedLabel={labels.notSpecified}
options={(agentDetail?.permissionModes ?? []).map((mode) => ({
label: mode.label,
value: mode.id
}))}
pending={detailPending}
pendingLabel={labels.assignmentOptionsLoading}
tone={permissionModeAssignmentTone(permissionValue)}
value={permissionValue}
onChange={(value) => onEdit({ permissionModeId: value })}
/>
<AssignmentValueSelect
disabled={disabled || !agentValue || detailPending}
fieldLabel={labels.reasoningEffort}
notSpecifiedLabel={labels.notSpecified}
options={agentDetail?.reasoningEfforts ?? []}
pending={detailPending}
pendingLabel={labels.assignmentOptionsLoading}
value={effortValue}
onChange={(value) => onEdit({ reasoningEffort: value })}
/>
<label
className={cn(
"flex min-h-[28px] w-auto shrink-0 items-center gap-1.5 text-[13px]",
disabled
? "cursor-not-allowed text-[var(--agent-gui-text-tertiary)]"
: "cursor-pointer text-[var(--agent-gui-text-secondary)]"
)}
htmlFor={`tutti-plan-task-parallel-${task.id}`}
title={labels.parallelizable}
>
<Split aria-hidden className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{labels.parallelizable}</span>
<Switch
id={`tutti-plan-task-parallel-${task.id}`}
size="sm"
checked={parallelizable}
disabled={disabled}
aria-label={labels.parallelizable}
data-testid={`tutti-plan-task-parallel-toggle-${task.id}`}
onCheckedChange={(checked) => onEdit({ parallelizable: checked })}
/>
</label>
<label
className={cn(
"flex min-h-[28px] w-auto shrink-0 items-center gap-1.5 text-[13px]",
disabled
? "cursor-not-allowed text-[var(--agent-gui-text-tertiary)]"
: "cursor-pointer text-[var(--agent-gui-text-secondary)]"
)}
htmlFor={`tutti-plan-task-auto-accept-${task.id}`}
title={labels.autoAccept}
>
<CheckCheck aria-hidden className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{labels.autoAccept}</span>
<Switch
id={`tutti-plan-task-auto-accept-${task.id}`}
size="sm"
checked={autoAccept}
disabled={disabled}
aria-label={labels.autoAccept}
data-testid={`tutti-plan-task-auto-accept-toggle-${task.id}`}
onCheckedChange={(checked) => onEdit({ autoAccept: checked })}
/>
</label>
</div>
);
}
function AssignmentSelectTrigger({
fieldLabel,
pending = false,
pendingLabel,
tone,
value
}: {
fieldLabel: string;
pending?: boolean;
pendingLabel?: string;
tone?: "accent" | "success" | "warning" | undefined;
value: string | null;
}): React.JSX.Element {
const placeholder = pending && pendingLabel ? pendingLabel : fieldLabel;
return (
<SelectTrigger
aria-label={fieldLabel}
title={fieldLabel}
data-permission-tone={value !== null ? tone : undefined}
className={cn(
"w-auto max-w-full",
composerStyles.composerMenuTrigger,
pending && "animate-pulse"
)}
>
<span className="flex min-w-0 flex-1 items-center">
<span
className={cn(
"truncate",
value === null && "text-[var(--agent-gui-text-secondary)]"
)}
>
{value ?? placeholder}
</span>
</span>
</SelectTrigger>
);
}
function AssignmentSelectContent({
children
}: {
children: React.ReactNode;
}): React.JSX.Element {
return (
<SelectContent
className={cn(
"nodrag",
composerStyles.composerMenuContent,
"min-w-[190px]"
)}
>
{children}
</SelectContent>
);
}
function AssignmentSelectItem({
children,
value
}: {
children: React.ReactNode;
value: string;
}): React.JSX.Element {
return (
<SelectItem className={composerStyles.composerMenuItem} value={value}>
{children}
</SelectItem>
);
}
function AssignmentValueSelect({
disabled,
fieldLabel,
notSpecifiedLabel,
onChange,
options,
pending,
pendingLabel,
tone,
value
}: {
disabled: boolean;
fieldLabel: string;
notSpecifiedLabel: string;
onChange(value: string): void;
options: readonly { label: string; value: string }[];
pending: boolean;
pendingLabel: string;
tone?: "accent" | "success" | "warning" | undefined;
value: string;
}): React.JSX.Element {
const known = options.some((option) => option.value === value);
const selectedLabel = assignmentOptionLabel(options, value);
return (
<Select
disabled={disabled}
value={toSelectValue(value)}
onValueChange={(next) => onChange(fromSelectValue(next))}
>
<AssignmentSelectTrigger
fieldLabel={fieldLabel}
pending={pending}
pendingLabel={pendingLabel}
tone={tone}
value={selectedLabel}
/>
<AssignmentSelectContent>
<AssignmentSelectItem value={CLEAR_VALUE}>
{notSpecifiedLabel}
</AssignmentSelectItem>
{options.map((option) => (
<AssignmentSelectItem key={option.value} value={option.value}>
{option.label}
</AssignmentSelectItem>
))}
{value && !known ? (
<AssignmentSelectItem value={value}>{value}</AssignmentSelectItem>
) : null}
</AssignmentSelectContent>
</Select>
);
}