diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ef79ef182cc57..b8ab00673db12 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -110,6 +110,7 @@ import { import { ScheduledTaskModelPickerRouteScreen, ScheduledTaskBranchPickerRouteScreen, + ScheduledTaskThreadPickerRouteScreen, } from "./features/settings/ScheduledTaskPickerScreens"; import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; @@ -301,6 +302,10 @@ const SETTINGS_SCREENS = { screen: ScheduledTaskBranchPickerRouteScreen, options: settingsPage("Base branch"), }), + SettingsScheduledTaskThread: createNativeStackScreen({ + screen: ScheduledTaskThreadPickerRouteScreen, + options: settingsPage("Destination"), + }), SettingsScheduledTaskModel: createNativeStackScreen({ screen: ScheduledTaskModelPickerRouteScreen, options: THREAD_SETTINGS_ROUTE, diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index aec2b1a0eeb3c..e93a3f7442986 100644 --- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx +++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx @@ -1,16 +1,37 @@ import { useNavigation } from "@react-navigation/native"; +import { isListedThreadLineage } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; +import { FlatList, Platform, type TextInputInstance, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; +import { SearchField } from "../../components/ui/SearchField"; +import { SelectionRow } from "../../components/ui/SelectionRow"; +import { Toolbar } from "../../components/ui/Toolbar"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; -import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; +import { + useEnvironmentServerConfig, + useProjects, + useVisibleThreadShells, +} from "../../state/entities"; +import { useDebouncedValue, usePaginatedBranches, useThreadSearch } from "../../state/queries"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, +} from "../layout/native-mail-search-toolbar"; import { BranchPickerScreen } from "../threads/NewTaskContextPickerScreens"; import { ThreadSettingsPickerScreen } from "../threads/ThreadSettingsSheet"; import { SettingsScreen } from "./components/SettingsScreen"; -import { useScheduledTaskEditor } from "./scheduled-task-editor"; +import { + NEW_THREAD_DESTINATION_LABEL, + useScheduledTaskDestinationLabel, + useScheduledTaskEditor, +} from "./scheduled-task-editor"; +import { moveDraftToThread, unbindDraftFromThread } from "./scheduledTaskDraft"; function MissingTaskDraft() { return ( @@ -164,3 +185,149 @@ export function ScheduledTaskBranchPickerRouteScreen() { /> ); } + +const THREAD_SEARCH_PLACEHOLDER = "Search threads, including older ones"; + +export function ScheduledTaskThreadPickerRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); + const searchRef = useRef(null); + const { editor, setEditor } = useScheduledTaskEditor(); + const environmentId = editor?.environmentId ?? null; + const projectId = editor?.draft.projectId ?? null; + const threadId = editor?.draft.threadId ?? null; + const currentLabel = useScheduledTaskDestinationLabel(environmentId, threadId); + const shells = useVisibleThreadShells(); + const [query, setQuery] = useState(""); + // Mounts older settled threads matching the query, which shells may leave out. + useThreadSearch( + environmentId !== null && projectId !== null ? [environmentId] : [], + query, + projectId === null ? { titlesOnly: true } : { titlesOnly: true, projectIds: [projectId] }, + ); + + if (!editor) return ; + + const search = query.trim().toLocaleLowerCase(); + const rows = [ + ...(search ? [] : [{ key: "new-thread", title: NEW_THREAD_DESTINATION_LABEL, thread: null }]), + ...(threadId !== null && currentLabel.toLocaleLowerCase().includes(search) + ? [{ key: threadId, title: currentLabel, thread: null }] + : []), + ...shells + .filter( + (thread) => + thread.environmentId === environmentId && + thread.projectId === projectId && + thread.id !== threadId && + thread.archivedAt === null && + isListedThreadLineage(thread.lineage) && + thread.title.toLocaleLowerCase().includes(search), + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map((thread) => ({ + key: thread.id, + title: thread.title.trim() || "Untitled thread", + thread, + })), + ]; + const selectedKey = threadId ?? (editor.draft.threadMode === "new_thread" ? "new-thread" : null); + + return ( + + {Platform.OS === "ios" ? ( + [ + createNativeMailSearchToolbarItem({ + onSearchTextChange: setQuery, + placeholder: THREAD_SEARCH_PLACEHOLDER, + searchTextChangeId: "scheduled-task-thread-search-text", + showsSearchDismissButton: true, + }), + ] + : undefined, + headerSearchBarOptions: usesNativeMailSearchToolbar + ? undefined + : { + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + hideWhenScrolling: false, + obscureBackground: false, + onCancelButtonPress: () => setQuery(""), + onChangeText: (event) => setQuery(event.nativeEvent.text), + placeholder: THREAD_SEARCH_PLACEHOLDER, + }, + }} + /> + ) : null} + {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? ( + + + + ) : null} + row.key} + className="flex-1" + contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} + keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: usesNativeMailSearchToolbar + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 + : Math.max(insets.bottom, 16) + 16, + }} + ListHeaderComponent={ + Platform.OS === "ios" ? undefined : ( + + + + ) + } + ListEmptyComponent={ + No matching threads + } + renderItem={({ item, index }) => ( + { + void Haptics.selectionAsync(); + if (item.key !== selectedKey) { + setEditor((current) => + current + ? { + ...current, + draft: item.thread + ? moveDraftToThread(current.draft, item.thread) + : { ...unbindDraftFromThread(current.draft), threadMode: "new_thread" }, + } + : current, + ); + } + navigation.goBack(); + }} + /> + )} + /> + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 6e90e449359ca..ef4b696c82ec8 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -72,14 +72,16 @@ import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; import { + changeDraftProject, editDraft, parseIntervalMinutes, scheduledTaskDefaultModel, scheduleFromDraft, + unbindDraftFromThread, type ScheduledTaskDraft as Draft, } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; -import { useScheduledTaskEditor } from "./scheduled-task-editor"; +import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./scheduled-task-editor"; import { discardScheduledTaskEditor, markScheduledTaskEditorSaved, @@ -96,6 +98,7 @@ type ScheduledTaskRoutes = { SettingsScheduledTaskEdit: undefined; SettingsScheduledTaskModel: undefined; SettingsScheduledTaskBranch: undefined; + SettingsScheduledTaskThread: undefined; }; const DAYS = [ @@ -663,6 +666,7 @@ function TaskForm({ ) .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const config = useEnvironmentServerConfig(environmentId); + const destinationLabel = useScheduledTaskDestinationLabel(environmentId, draft.threadId); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); // Existing webhook tasks keep the option so they can still switch to a timer. const webhookDenial = managedCapabilityDenial(config?.managedPolicy, "webhookTasks"); @@ -766,7 +770,7 @@ function TaskForm({ modelSelection: draft.modelSelection, schedule, enabled: draft.enabled, - threadId: draft.task?.threadId ?? null, + threadId: draft.threadId, ...(offersThreadModes ? { threadMode: draft.threadMode } : {}), workspaceStrategy: draft.workspace === "root" @@ -879,8 +883,7 @@ function TaskForm({ draft.modelSelection.instanceId, )); setDraft({ - ...draft, - projectId: project.id, + ...changeDraftProject(draft, project.id), modelSelection: keepsExplicitModel ? draft.modelSelection : scheduledTaskDefaultModel(config, project), @@ -905,19 +908,21 @@ function TaskForm({ /> - {offersThreadModes ? ( - + + {offersThreadModes ? ( { - if (id === "new_thread" || id === "continue" || id === "fresh_session") + if (id === "new_thread") + setDraft({ ...unbindDraftFromThread(draft), threadMode: id }); + else if (id === "continue" || id === "fresh_session") setDraft({ ...draft, threadMode: id }); }} /> - - ) : null} + ) : null} + {!offersThreadModes || draft.threadMode !== "new_thread" ? ( + navigation.navigate("SettingsScheduledTaskThread")} + disabled={!draft.projectId || saving || dictationPending || environmentUnavailable} + /> + ) : null} + row.id === threadId); + if (ref === null) return NEW_THREAD_DESTINATION_LABEL; + return thread?.title.trim() || `Thread ${ref.threadId}`; +} + /** The form draft lives in an atom, so it survives while its native picker routes are on top. */ export function useScheduledTaskEditor() { const { selectedTargets, selectedProjectKey, projectGroups } = useSettingsEnvironmentFilter(); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3a7d919299896..1ede30d84072b 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -8,6 +8,7 @@ import type { ScheduledTaskThreadMode, ScheduledTaskUpsertSchedule, ScheduledTaskWebhookSignature, + ThreadId, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -202,6 +203,7 @@ export type ScheduledTaskDraft = { readonly title: string; readonly prompt: string; readonly projectId: ProjectId | null; + readonly threadId: ThreadId | null; readonly modelSelection: ModelSelection | null; readonly modelSelectionIsExplicit: boolean; readonly schedule: ScheduleDraft; @@ -219,6 +221,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { draft.title, draft.prompt, draft.projectId, + draft.threadId, draft.modelSelection?.instanceId, draft.modelSelection?.model, [...(draft.modelSelection?.options ?? [])] @@ -251,6 +254,38 @@ export function hasScheduledTaskDraftChanges( return draftSignature(initial) !== draftSignature(current); } +export function moveDraftToThread( + draft: ScheduledTaskDraft, + thread: { readonly id: ThreadId; readonly projectId: ProjectId }, +): ScheduledTaskDraft { + return { + ...draft, + threadId: thread.id, + projectId: thread.projectId, + threadMode: draft.threadMode === "new_thread" ? "continue" : draft.threadMode, + }; +} + +/** Leaves the thread but keeps the mode. Matches MCP: new threads stay out of the shared checkout. */ +export function unbindDraftFromThread(draft: ScheduledTaskDraft): ScheduledTaskDraft { + if (draft.threadId === null) return draft; + return { + ...draft, + threadId: null, + ...(draft.workspace === "root" + ? { workspace: "worktree" as const, startFromOrigin: true } + : {}), + }; +} + +export function changeDraftProject( + draft: ScheduledTaskDraft, + projectId: ProjectId, +): ScheduledTaskDraft { + if (projectId === draft.projectId) return draft; + return { ...unbindDraftFromThread(draft), projectId }; +} + export function createDraft( projectId: ProjectId | null, modelSelection: ModelSelection | null, @@ -260,6 +295,7 @@ export function createDraft( title: "", prompt: "", projectId, + threadId: null, modelSelection, modelSelectionIsExplicit: false, schedule: DEFAULT_SCHEDULE, @@ -279,6 +315,7 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft { title: task.title, prompt: task.prompt, projectId: task.projectId, + threadId: task.threadId, modelSelection: task.modelSelection, modelSelectionIsExplicit: true, schedule: scheduleDraftForTask(task), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 23b46afceca58..fb7a2ffc2d897 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -412,7 +412,11 @@ it.effect( ); return Effect.gen(function* () { const tasks = yield* ScheduledTasks.ScheduledTaskService; - const threadId = ThreadId.make("thread:missing"); + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const { threadId } = yield* launches.launch( + launchInput({ command: "command:missing", thread: "thread:missing" }), + ); const { task } = yield* tasks.upsert({ title: "Daily audit", prompt: "Audit performance and crashes.", @@ -427,6 +431,11 @@ it.effect( createdBy: "user", creationSource: "web", }); + yield* threads.dispatch({ + type: "thread.delete", + commandId: CommandId.make("command:missing:delete"), + threadId, + }); const result = yield* tasks.runNow({ id: task.id }); assert.equal(result.task.lastRunStatus, "failed"); assert.isNull(result.threadId); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 170ceee73a331..85eccd9824f3e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -330,7 +330,16 @@ const layerScheduledTasksTest = ScheduledTaskService.layer.pipe( Layer.mergeAll( ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), + // The orchestrator depends on this layer, so read the shell from its projection. + Layer.unwrap( + ProjectionStore.ProjectionStoreV2.pipe( + Effect.map(({ getThreadShell }) => + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => getThreadShell(threadId).pipe(Effect.orDie), + }), + ), + ), + ).pipe(Layer.provide(ProjectionStore.layer)), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -402,6 +411,7 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i const projectId = ProjectId.make(`automation-delete-project:${deletion}`); const threadId = ThreadId.make(`automation-delete-thread:${deletion}`); const otherThreadId = ThreadId.make(`automation-surviving-thread:${deletion}`); + const otherProjectId = ProjectId.make("automation-other-project"); yield* projects.create({ commandId: CommandId.make(`automation-create-project:${deletion}`), projectId, @@ -415,7 +425,7 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i createdBy: "user", creationSource: "web", threadId: id, - projectId: id === threadId ? projectId : ProjectId.make("automation-other-project"), + projectId: id === threadId ? projectId : otherProjectId, title: "Automation target", modelSelection, runtimeMode: "full-access", @@ -433,7 +443,7 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i prompt: "Check orchestration progress", enabled: !webhook, schedule: webhook ? { type: "webhook" } : { type: "interval", everyMs: 60_000 }, - projectId, + projectId: target === otherThreadId ? otherProjectId : projectId, threadId: target, workspaceStrategy: { type: "root" }, modelSelection, @@ -7191,11 +7201,6 @@ it.layer(layerScratchPromotionTest)("turning a No project thread into a project" const threadId = yield* createThread("main", folder); const siblingId = yield* createThread("sibling", folder); const otherId = yield* createThread("other", `${SCRATCH_ROOT}/2026-01-01-elsewhere`); - yield* orchestrator.dispatch({ - type: "thread.archive", - commandId: CommandId.make("promotion-moves-sibling-archive"), - threadId: siblingId, - }); const { task } = yield* tasks.upsert({ id: ScheduledTaskId.make("promotion-moves-bound"), title: "Bound", @@ -7211,6 +7216,11 @@ it.layer(layerScratchPromotionTest)("turning a No project thread into a project" createdBy: "user", creationSource: "web", }); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("promotion-moves-sibling-archive"), + threadId: siblingId, + }); const { projectId } = yield* promotion.promote({ threadId }); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 71454e55a6540..cbfea6b4e3135 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -8,6 +8,8 @@ import { ProviderInstanceId, ScheduledTaskError, ScheduledTaskId, + type ScheduledTaskUpsertInput, + SecretRef, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -34,6 +36,11 @@ import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; const isScheduledTaskError = Schema.is(ScheduledTaskError); +const threadShell = ( + projectId: ProjectId, + state: { archivedAt?: string | null; deletedAt?: string | null } = {}, +) => ({ projectId, archivedAt: null, deletedAt: null, ...state }) as never; + const insertRow = ( sql: SqlClient.SqlClient, row: { @@ -358,6 +365,7 @@ it.effect("thread cleanup notifies subscribers and a finishing run cannot recrea Effect.andThen(Deferred.await(release)), Effect.andThen(Effect.die("test dispatch failure")), ), + getThreadShell: () => Effect.succeed(threadShell(ProjectId.make("project:test"))), }), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ @@ -415,8 +423,9 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", () const scratch = ProjectId.make("project:scratch"); const promoted = ProjectId.make("project:promoted"); const sends = yield* Ref.make>([]); - // Rebind lands first, then the promotion commits between the shell read and the retry. - const shellProjects = yield* Ref.make([scratch, promoted]); + // Saving reads it first. Then the rebind lands, and the promotion commits between the shell + // read and the retry. + const shellProjects = yield* Ref.make([promoted, scratch, promoted]); const layer = ScheduledTaskService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -440,10 +449,7 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", () ), ), getThreadShell: () => - Ref.modify(shellProjects, ([next, ...rest]) => [ - { projectId: next ?? promoted } as never, - rest, - ]), + Ref.modify(shellProjects, ([next, ...rest]) => [threadShell(next ?? promoted), rest]), }), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ @@ -693,7 +699,9 @@ it.effect("refuses saving an enabled task above the policy ceiling", () => limits: { maxRuntimeMode: "auto-accept-edits" }, }), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(threadShell(ProjectId.make("project:test"))), + }), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -750,6 +758,96 @@ it.effect("refuses saving an enabled task above the policy ceiling", () => }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); +it.effect("checks a task's destination thread only when the destination changes", () => + Effect.gen(function* () { + const project = ProjectId.make("project:test"); + const otherProject = ProjectId.make("project:other"); + const destination = ThreadId.make("thread:destination"); + const archivedAt = "2026-11-05T00:00:00.000Z"; + const shells = yield* Ref.make>( + new Map([ + [destination, threadShell(project)], + [ThreadId.make("thread:archived"), threadShell(project, { archivedAt })], + [ThreadId.make("thread:deleted"), threadShell(project, { deletedAt: archivedAt })], + [ThreadId.make("thread:other-project"), threadShell(otherProject)], + ]), + ); + const consumed = yield* Ref.make(0); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Ref.get(shells).pipe(Effect.map((all) => all.get(threadId) ?? null)), + }), + Layer.mock(SecretRequests.SecretRequests)({ + consume: () => Ref.update(consumed, (count) => count + 1).pipe(Effect.as("secret")), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const id = ScheduledTaskId.make("task:destination"); + const save = ( + threadId: ThreadId, + schedule: ScheduledTaskUpsertInput["schedule"] = { type: "interval", everyMs: 60_000 }, + ) => + service.upsert({ + id, + title: "Follow up", + prompt: "Check progress", + enabled: true, + schedule, + projectId: project, + threadId, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + + for (const [threadId, message] of [ + ["thread:missing", "That thread no longer exists."], + ["thread:deleted", "That thread no longer exists."], + ["thread:archived", "That thread is archived. Unarchive it or pick another destination."], + ["thread:other-project", "That thread belongs to a different project."], + ] as const) { + const refused = yield* save(ThreadId.make(threadId)).pipe(Effect.flip); + assert.equal(refused.message, message); + } + assert.lengthOf((yield* service.list()).tasks, 0); + + // A rejected save must not burn a one-shot secret. + yield* save(ThreadId.make("thread:missing"), { + type: "webhook", + signature: { + header: "x-signature", + encoding: "hex", + prefix: "", + secretRef: SecretRef.make("secret-ref:00000000000000000000000000000001"), + }, + }).pipe(Effect.flip); + assert.equal(yield* Ref.get(consumed), 0); + + yield* save(destination); + // A task whose thread was archived later stays editable. + yield* Ref.update(shells, (all) => + new Map(all).set(destination, threadShell(project, { archivedAt })), + ); + assert.equal((yield* save(destination)).task.threadId, destination); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("a run interrupted right after it reserves the task does not block the next run", () => Effect.gen(function* () { const launches = yield* Ref.make(0); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 9dd04b41c9275..4953cec766210 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1253,6 +1253,30 @@ export const layer = Layer.effect( }), ); + const requireDestinationThread = Effect.fn("ScheduledTaskService.requireDestinationThread")( + function* (threadId: ThreadId, projectId: ProjectId, taskId: ScheduledTaskId) { + const thread = yield* threadManagement + .getThreadShell(threadId) + .pipe( + Effect.mapError((cause) => + taskError("Could not read the destination thread.", { taskId, cause }), + ), + ); + if (thread === null || thread.deletedAt !== null) { + return yield* taskError("That thread no longer exists.", { taskId }); + } + if (thread.archivedAt !== null) { + return yield* taskError( + "That thread is archived. Unarchive it or pick another destination.", + { taskId }, + ); + } + if (thread.projectId !== projectId) { + return yield* taskError("That thread belongs to a different project.", { taskId }); + } + }, + ); + const upsert: ScheduledTaskService["Service"]["upsert"] = Effect.fn( "ScheduledTaskService.upsert", )(function* (input) { @@ -1291,6 +1315,10 @@ export const layer = Layer.effect( existingTask.projectId !== input.projectId && keptThreadId === existingTask.threadId; const threadId = threadMode === "new_thread" || movedOffThread ? null : keptThreadId; + // An unchanged destination is not rechecked, so a task on an archived thread stays editable. + if (threadId !== null && threadId !== existingTask?.threadId) { + yield* requireDestinationThread(threadId, input.projectId, id); + } // Refused on save rather than at run time; a disabled task can still be saved, so one // stored above the ceiling can be turned off before its mode is lowered. A task bound to a // thread runs in that thread's mode, which the orchestrator checks instead. Checked before diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 55efbec750805..ff024e5ba2ed2 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { AsyncResult } from "effect/reactivity"; import { + ChevronDownIcon, Clock3Icon, CopyIcon, InboxIcon, + MessageSquareIcon, MoreHorizontalIcon, PencilIcon, PlayIcon, @@ -61,10 +63,16 @@ import { useEnvironmentHttpBaseUrl, type EnvironmentPresentation, } from "../../state/environments"; -import { useProjects, useQuietThreadShell, useThreadShell } from "../../state/entities"; +import { + useProjects, + useQuietThreadShell, + useThreadShell, + useVisibleThreadShells, +} from "../../state/entities"; import { buildThreadRouteParams } from "../../threadRoutes"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; +import { useThreadSearch } from "../../state/queries"; import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { threadFamilies } from "../../state/threads"; @@ -87,7 +95,10 @@ import { SettingsGroup } from "./SettingsGroup"; import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { WEBHOOK_SIGNATURE_DEFAULTS, + changeDraftProject, + moveDraftToThread, scheduleDraftErrors, + scheduledTaskDestinationThreads, toDateTimeLocalValue, webhookDeliveryLabel, defaultScheduledTaskProjectId, @@ -95,6 +106,7 @@ import { scheduleFromDraft, scheduledTaskDefaultModel, taskToDraft, + unbindDraftFromThread, type DraftState, type ScheduleMode, type WorkspaceMode, @@ -106,6 +118,15 @@ import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from ".. import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../ui/combobox"; import { Dialog, DialogClose, @@ -133,6 +154,9 @@ const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; +// Thread ids are trimmed, so the leading space keeps this from matching one. +const NEW_THREAD_DESTINATION = " new-thread"; + const WORKSPACE_MODE_LABELS: Record = { worktree: "Create a new worktree", root: "Use the project checkout", @@ -1176,16 +1200,24 @@ const THREAD_MODE_LABELS: Record = { function ScheduledTaskDestination({ environmentId, + projectId, threadId, threadMode, + disabled, + onSelect, onThreadModeChange, }: { readonly environmentId: EnvironmentId; + readonly projectId: string; readonly threadId: ThreadId | null; readonly threadMode: ScheduledTaskThreadMode; + readonly disabled: boolean; + readonly onSelect: (thread: { id: ThreadId; projectId: ProjectId } | null) => void; /** Absent on servers that predate thread modes. */ readonly onThreadModeChange: ((mode: ScheduledTaskThreadMode) => void) | undefined; }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); 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. @@ -1193,11 +1225,38 @@ function ScheduledTaskDestination({ ref !== null && shell === null ? threadFamilies.familyAtom(environmentId, ref.threadId) : null, ); const thread = shell ?? family.data?.threads.find((row) => row.id === threadId); - const bound = ref !== null && threadMode !== "new_thread"; + const currentLabel = + ref !== null + ? thread?.title.trim() || `Thread ${ref.threadId}` + : threadMode === "new_thread" + ? "New thread each run" + : "Started by the first run"; + const shells = useVisibleThreadShells(open); + // Mounts older settled threads matching the query, which shells may leave out. + useThreadSearch(open && projectId ? [environmentId] : [], query, { + titlesOnly: true, + projectIds: [projectId as ProjectId], + }); + const threads = scheduledTaskDestinationThreads(shells, { + environmentId, + projectId, + currentThreadId: threadId, + query, + }); + const search = query.trim().toLocaleLowerCase(); + const items: ReadonlyArray = [ + ...(search ? [] : [NEW_THREAD_DESTINATION]), + ...(threadId !== null && currentLabel.toLocaleLowerCase().includes(search) ? [threadId] : []), + ...threads.map((row) => row.id), + ]; + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) setQuery(""); + }; const detail = threadMode === "new_thread" ? "Each run starts a new thread with the workspace and model below." - : !bound + : ref === null ? threadMode === "continue" ? "The first run starts a thread with the workspace and model below. Later runs continue that conversation." : "The first run starts a thread with the workspace and model below. Later runs post there, each in a fresh session without earlier runs' context." @@ -1223,10 +1282,66 @@ function ScheduledTaskDestination({ ) : null} + {!onThreadModeChange || threadMode !== "new_thread" ? ( + { + if (value === NEW_THREAD_DESTINATION) { + onSelect(null); + handleOpenChange(false); + return; + } + const picked = threads.find((row) => row.id === value); + if (picked) onSelect(picked); + handleOpenChange(false); + }} + > + } + className="w-full justify-between" + > + + {currentLabel} + + + + setQuery(event.target.value)} + /> + No threads found. + + {items.map((value, index) => ( + + + {value === NEW_THREAD_DESTINATION + ? "New thread each run" + : value === threadId + ? currentLabel + : threads.find((row) => row.id === value)?.title || "Untitled thread"} + + + ))} + + + + ) : null}
- {bound ? ( -

{thread?.title.trim() || `Thread ${ref.threadId}`}

- ) : null}

{detail}

{threadMode !== "new_thread" ? (

Deleting the thread also deletes this task.

@@ -1471,7 +1586,7 @@ function ScheduledTaskEditorForm({ if (!next) return; setEnvironmentId(next.environmentId); setDraft((current) => ({ - ...current, + ...unbindDraftFromThread(current), projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope), modelKey: "", baseModelSelection: null, @@ -1547,11 +1662,24 @@ function ScheduledTaskEditorForm({ + setDraft((current) => + thread === null + ? { ...unbindDraftFromThread(current), threadMode: "new_thread" } + : moveDraftToThread(current, thread), + ) + } onThreadModeChange={ offersThreadModes - ? (threadMode) => setDraft((current) => ({ ...current, threadMode })) + ? (threadMode) => + setDraft((current) => ({ + ...(threadMode === "new_thread" ? unbindDraftFromThread(current) : current), + threadMode, + })) : undefined } /> @@ -1561,7 +1689,7 @@ function ScheduledTaskEditorForm({