Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ProjectId,
ProviderInstanceId,
ScheduledTaskId,
ThreadId,
type ScheduledTask,
} from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
Expand All @@ -24,7 +25,7 @@ const task: ScheduledTask = {
enabled: true,
schedule: { type: "interval", everyMs: 60_000 },
projectId,
threadId: null,
threadId: ThreadId.make("older-thread"),
workspaceStrategy: { type: "root" },
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
runtimeMode: "full-access",
Expand All @@ -40,6 +41,12 @@ const task: ScheduledTask = {
runCount: 0,
};

// Stands in for the thread family read the editor makes when the shell lacks the bound thread.
const threadFamilyRead = {
snapshotSequence: 1,
threads: [{ id: task.threadId, title: "Fix login bug" }],
};

const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false }));
const secondaryId = EnvironmentId.make("secondary");
const permissions = new Map<string, Atom.Writable<boolean>>();
Expand Down Expand Up @@ -102,11 +109,21 @@ vi.mock(import("../../state/server"), async (original) => {
},
};
});
vi.mock(import("../../state/threads"), async (original) => {
const actual = await original();
return {
...actual,
threadFamilies: { ...actual.threadFamilies, familyAtom: (() => threadFamilyRead) as never },
};
});
// Mirrors the real query: a null request (disconnected environment) has no data.
vi.mock(import("../../state/query"), async (original) => ({
...(await original()),
useEnvironmentQuery: ((request: unknown) => ({
data: request === null ? null : { tasks: state.tasksLoaded ? [task] : [] },
data:
request === null || request === threadFamilyRead
? request
: { tasks: state.tasksLoaded ? [task] : [] },
error: null,
isPending: false,
refresh: vi.fn(),
Expand Down Expand Up @@ -166,6 +183,7 @@ describe("ScheduledTasksSettings", () => {
it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => {
setup();
await using screen = await render(<ScheduledTasksSettings taskId={task.id} />);
await expect.element(page.getByRole("dialog").getByText("Fix login bug")).toBeVisible();
const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" });
await expect.element(name).toHaveValue("Review issues");
await userEvent.fill(name, "Review stale issues");
Expand Down
41 changes: 40 additions & 1 deletion apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ import {
useEnvironmentHttpBaseUrl,
type EnvironmentPresentation,
} from "../../state/environments";
import { useProjects } from "../../state/entities";
import { useProjects, useThreadShell } from "../../state/entities";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList";
import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query";
import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
import { threadFamilies } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";
import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
Expand Down Expand Up @@ -1108,6 +1110,38 @@ function ScheduledTaskEditorDialog({
);
}

function ScheduledTaskDestination({
environmentId,
threadId,
}: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId | null;
}) {
const ref = threadId === null ? null : scopeThreadRef(environmentId, threadId);
const shell = useThreadShell(ref);
// The shell can omit older settled threads; their family read still carries the title.
const family = useEnvironmentQuery(
ref !== null && shell === null ? threadFamilies.familyAtom(environmentId, ref.threadId) : null,
);
const thread = shell ?? family.data?.threads.find((row) => row.id === threadId);
return (
<Field label="Destination">
{ref === null ? (
<p className="text-sm text-muted-foreground">
Each run starts a new thread with the workspace and model below.
</p>
) : (
<div className="space-y-1 text-sm">
<p className="wrap-anywhere">{thread?.title.trim() || `Thread ${ref.threadId}`}</p>
<p className="text-muted-foreground">
Each run continues this conversation, in its workspace and with its current model.
</p>
</div>
)}
</Field>
);
}

function ScheduledTaskEditorForm({
initialEnvironmentId,
task,
Expand Down Expand Up @@ -1384,6 +1418,11 @@ function ScheduledTaskEditorForm({
/>
</Field>

<ScheduledTaskDestination
environmentId={environmentId}
threadId={draft.threadId ? (draft.threadId as ThreadId) : null}
/>

<div className="grid gap-3 sm:grid-cols-2">
<Field label="Project" htmlFor="scheduled-task-project">
<Select
Expand Down
Loading