Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 107 additions & 43 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
EnvironmentId,
ModelSelection,
OrchestrationV2ThreadLaunchWorkspaceStrategy,
ProjectId,
ScheduledTask,
Expand All @@ -24,6 +23,7 @@ import type {
ThreadId,
} from "@t3tools/contracts";
import { DEFAULT_WEBHOOK_PROMPT } from "@t3tools/client-runtime/scheduled-task-webhook";
import { createModelSelection } from "@t3tools/shared/model";
import {
MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
MIN_SCHEDULED_TASK_INTERVAL_MS,
Expand Down Expand Up @@ -77,6 +77,8 @@ import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/men
import { ToggleGroup, Toggle } from "../ui/toggle-group";
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig";
import { TraitsPicker } from "../chat/TraitsPicker";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import {
Expand Down Expand Up @@ -128,10 +130,9 @@ const EMPTY_DRAFT: DraftState = {
baseRef: "main",
startFromOrigin: true,
existingWorktreePath: "",
modelKey: "",
modelSelection: null,
runtimeMode: "full-access",
interactionMode: "default",
baseModelSelection: null,
signatureEnabled: false,
...WEBHOOK_SIGNATURE_DEFAULTS,
signatureSecret: "",
Expand Down Expand Up @@ -163,15 +164,6 @@ function Field({
);
}

function splitModelKey(value: string): ModelSelection | null {
const index = value.indexOf(":");
if (index <= 0 || index === value.length - 1) return null;
return {
instanceId: ProviderInstanceId.make(value.slice(0, index)),
model: value.slice(index + 1),
};
}

export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
if (schedule.type === "webhook") return "On webhook";
if (schedule.type === "interval") {
Expand Down Expand Up @@ -828,15 +820,25 @@ function ScheduledTaskEditorDialog({
const selectedProjectId = draft.projectId || projects[0]?.id || "";
const selectedProject = projects.find((project) => project.id === selectedProjectId);

// The real model picker is keyed by a `${instanceId}:${model}` string, which
// is exactly how the draft stores its selection.
const firstInstance = instanceEntries[0];
const activeSelection = draft.modelKey
? splitModelKey(draft.modelKey)
: scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries);
const activeSelection =
draft.modelSelection ??
scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries);
const activeInstanceId =
activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId);
const activeModel = activeSelection?.model ?? "";
const activeEntry = instanceEntries.find((entry) => entry.instanceId === activeInstanceId);
const supportedRuntimeModes = activeEntry?.snapshot.supportedRuntimeModes;
const compatibleRuntimeModes =
supportedRuntimeModes && supportedRuntimeModes.length > 0
? runtimeModeOptions.filter((mode) => supportedRuntimeModes.includes(mode))
: runtimeModeOptions;
// A mode the selected provider does not offer would run as Supervised, so the
// dialog shows and saves the provider's first supported mode instead.
const runtimeMode = compatibleRuntimeModes.includes(draft.runtimeMode)
? draft.runtimeMode
: (compatibleRuntimeModes[0] ?? draft.runtimeMode);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const PermissionIcon = runtimeModeConfig[runtimeMode].icon;
const modelOptionsByInstance = useMemo(
() => getCustomModelOptionsByInstance(settings, providers, activeInstanceId, activeModel),
[settings, providers, activeInstanceId, activeModel],
Expand Down Expand Up @@ -901,14 +903,6 @@ function ScheduledTaskEditorDialog({
reportFailure("Checkout path is required", "Enter the path of the checkout to run in.");
return;
}
// Keep the original selection object (with provider options) when the
// picker still points at the same instance+model.
const modelSelection =
draft.baseModelSelection !== null &&
draft.baseModelSelection.instanceId === selection.instanceId &&
draft.baseModelSelection.model === selection.model
? draft.baseModelSelection
: selection;
const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy =
draft.workspaceMode === "root"
? { type: "root" }
Expand All @@ -928,8 +922,8 @@ function ScheduledTaskEditorDialog({
projectId: selectedProjectId as ProjectId,
threadId: draft.threadId ? (draft.threadId as ThreadId) : null,
workspaceStrategy,
modelSelection,
runtimeMode: draft.runtimeMode,
modelSelection: selection,
runtimeMode,
interactionMode: draft.interactionMode,
creationSource: "web",
};
Expand Down Expand Up @@ -980,8 +974,7 @@ function ScheduledTaskEditorDialog({
setDraft((current) => ({
...current,
projectId: "",
modelKey: "",
baseModelSelection: null,
modelSelection: null,
baseRef: "main",
startFromOrigin: true,
existingWorktreePath: "",
Expand Down Expand Up @@ -1121,22 +1114,93 @@ function ScheduledTaskEditorDialog({
</Field>

<Field label="Model">
<ProviderModelPicker
environmentId={environmentId}
disabled={saving || !connected}
activeInstanceId={activeInstanceId}
model={activeModel}
lockedProvider={null}
instanceEntries={instanceEntries}
modelOptionsByInstance={modelOptionsByInstance}
isComposerOwned={false}
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onInstanceModelChange={(instanceId, model) =>
setDraft((current) => ({ ...current, modelKey: `${instanceId}:${model}` }))
}
/>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<ProviderModelPicker
environmentId={environmentId}
disabled={saving || !connected}
activeInstanceId={activeInstanceId}
model={activeModel}
lockedProvider={null}
instanceEntries={instanceEntries}
modelOptionsByInstance={modelOptionsByInstance}
isComposerOwned={false}
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onInstanceModelChange={(instanceId, model) => {
// Re-picking the current model keeps its options.
if (
activeSelection?.instanceId === instanceId &&
activeSelection.model === model
)
return;
setDraft((current) => ({
...current,
modelSelection: createModelSelection(instanceId, model),
}));
}}
/>
{activeSelection && activeEntry ? (
<TraitsPicker
disabled={saving || !connected}
provider={activeEntry.driverKind}
instanceId={activeSelection.instanceId}
models={activeEntry.models}
model={activeSelection.model}
prompt={draft.prompt}
onPromptChange={(prompt) => setDraft((current) => ({ ...current, prompt }))}
modelOptions={activeSelection.options ?? []}
planModeEnabled={settings.planModeEnabled}
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onModelOptionsChange={(options) =>
setDraft((current) => ({
...current,
modelSelection: createModelSelection(
activeSelection.instanceId,
activeSelection.model,
options,
),
}))
}
/>
) : null}
</div>
</Field>

{/* A task bound to a thread runs with that thread's permissions. */}
{draft.threadId ? null : (
<Field label="Permissions" htmlFor="scheduled-task-permissions">
<Select
value={runtimeMode}
onValueChange={(mode) => {
if (mode) setDraft((current) => ({ ...current, runtimeMode: mode }));
}}
>
<SelectTrigger size="sm" id="scheduled-task-permissions">
<PermissionIcon className="size-3.5 shrink-0 text-muted-foreground" />
<SelectValue>{runtimeModeConfig[runtimeMode].label}</SelectValue>
</SelectTrigger>
<SelectPopup>
{compatibleRuntimeModes.map((mode) => {
const option = runtimeModeConfig[mode];
const Icon = option.icon;
return (
<SelectItem key={mode} value={mode}>
<div className="grid gap-0.5">
<span className="inline-flex items-center gap-1.5 font-medium">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
{option.label}
</span>
<span className="text-xs leading-4 text-muted-foreground">
{option.description}
</span>
</div>
</SelectItem>
);
})}
</SelectPopup>
</Select>
</Field>
)}

<div className="space-y-3">
{task?.schedule.type === "interval" &&
task.schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS ? (
Expand Down
14 changes: 4 additions & 10 deletions apps/web/src/components/settings/scheduledTasksSettings.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,11 @@ export interface DraftState {
readonly baseRef: string;
readonly startFromOrigin: boolean;
readonly existingWorktreePath: string;
readonly modelKey: string;
/** Not editable in the dialog, but preserved so editing an agent-created task keeps its modes. */
/** Null follows the project's default model, including its provider options. */
readonly modelSelection: ModelSelection | null;
readonly runtimeMode: RuntimeMode;
/** Not editable in the dialog, but preserved so editing an agent-created task keeps its mode. */
readonly interactionMode: ProviderInteractionMode;
/**
* The task's original model selection. The picker only edits
* `instanceId:model`; keeping the source object preserves provider options
* (reasoning, temperature, …) when the model itself is left unchanged.
*/
readonly baseModelSelection: ModelSelection | null;
readonly signatureEnabled: boolean;
readonly signatureHeader: string;
readonly signatureEncoding: "hex" | "base64";
Expand Down Expand Up @@ -150,10 +145,9 @@ export function taskToDraft(task: ScheduledTask): DraftState {
task.workspaceStrategy.type === "existing_worktree"
? task.workspaceStrategy.worktreePath
: "",
modelKey: `${task.modelSelection.instanceId}:${task.modelSelection.model}`,
modelSelection: task.modelSelection,
runtimeMode: task.runtimeMode,
interactionMode: task.interactionMode,
baseModelSelection: task.modelSelection,
...(schedule.type === "webhook" && schedule.signature !== null
? {
signatureEnabled: true,
Expand Down
Loading