diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx
index 1ea4602da9241..ce6f07dbf6833 100644
--- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx
+++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx
@@ -1,7 +1,7 @@
import { NativeScrollView } from "../../native/NativeScrollViewMarker";
import { LegendList } from "@legendapp/list/react-native";
import { useNavigation } from "@react-navigation/native";
-import { isListedThreadLineage } from "@t3tools/contracts";
+import { scheduledTaskDestinationRows } from "@t3tools/client-runtime/scheduled-task-destination";
import * as Haptics from "expo-haptics";
import { useMemo, useState } from "react";
import { Platform } from "react-native";
@@ -21,12 +21,7 @@ import { useDebouncedValue, usePaginatedBranches, useThreadSearch } from "../../
import { BranchPickerScreen } from "../threads/NewTaskContextPickerScreens";
import { ThreadSettingsPickerScreen } from "../threads/ThreadSettingsSheet";
import { SettingsScreen } from "./components/SettingsScreen";
-import {
- NEW_THREAD_DESTINATION_LABEL,
- useScheduledTaskDestinationLabel,
- useScheduledTaskEditor,
-} from "./scheduled-task-editor";
-import { moveDraftToThread, unbindDraftFromThread } from "./scheduledTaskDraft";
+import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./scheduled-task-editor";
function MissingTaskDraft() {
return (
@@ -213,38 +208,20 @@ export function ScheduledTaskThreadPickerRouteScreen() {
if (!editor) return ;
- const needle = query.trim().toLocaleLowerCase();
- const rows = [
- ...(needle ? [] : [{ key: "new-thread", title: NEW_THREAD_DESTINATION_LABEL, thread: null }]),
- ...(threadId !== null && currentLabel.toLocaleLowerCase().includes(needle)
- ? [{ 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(needle),
- )
- .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);
+ const rows = scheduledTaskDestinationRows(shells, {
+ environmentId,
+ projectId,
+ current: threadId === null ? null : { id: threadId, title: currentLabel },
+ query,
+ });
return (
row.key}
- extraData={selectedKey}
+ keyExtractor={(row) => row.id}
+ extraData={threadId}
estimatedItemSize={56}
recycleItems
className="flex-1"
@@ -266,20 +243,15 @@ export function ScheduledTaskThreadPickerRouteScreen() {
renderItem={({ item, index }) => (
{
void Haptics.selectionAsync();
- if (item.key !== selectedKey) {
+ if (item.id !== threadId) {
setEditor((current) =>
current
- ? {
- ...current,
- draft: item.thread
- ? moveDraftToThread(current.draft, item.thread)
- : { ...unbindDraftFromThread(current.draft), threadMode: "new_thread" },
- }
+ ? { ...current, draft: { ...current.draft, threadId: item.id } }
: current,
);
}
diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
index 3f552c1f0c46c..837771bfb9cd9 100644
--- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
@@ -908,53 +908,58 @@ function TaskForm({
/>
-
- {offersThreadModes ? (
- ({
- id: mode,
- title: THREAD_MODE_LABELS[mode],
- state: draft.threadMode === mode ? "on" : undefined,
- }),
- )}
- onSelect={(id) => {
- if (id === "new_thread")
- setDraft({ ...unbindDraftFromThread(draft), threadMode: id });
- else if (id === "continue" || id === "fresh_session")
- setDraft({ ...draft, threadMode: id });
- }}
- />
- ) : null}
- {!offersThreadModes || draft.threadMode !== "new_thread" ? (
- navigation.navigate("SettingsScheduledTaskThread")}
- disabled={!draft.projectId || saving || dictationPending || environmentUnavailable}
- />
- ) : null}
-
+ {offersThreadModes || draft.threadMode !== "new_thread" ? (
+
+ {offersThreadModes ? (
+ ({
+ id: mode,
+ title: THREAD_MODE_LABELS[mode],
+ state: draft.threadMode === mode ? "on" : undefined,
+ }),
+ )}
+ onSelect={(id) => {
+ if (id === "new_thread")
+ setDraft({ ...unbindDraftFromThread(draft), threadMode: id });
+ else if (id === "continue" || id === "fresh_session")
+ setDraft({ ...draft, threadMode: id });
+ }}
+ />
+ ) : null}
+ {draft.threadMode !== "new_thread" ? (
+ navigation.navigate("SettingsScheduledTaskThread")}
+ // Without thread modes there is no way back to a new thread each run, so stay read-only.
+ disabled={
+ !offersThreadModes ||
+ !draft.projectId ||
+ saving ||
+ dictationPending ||
+ environmentUnavailable
+ }
+ />
+ ) : null}
+
+ ) : null}
row.id === threadId);
- if (ref === null) return NEW_THREAD_DESTINATION_LABEL;
+ if (ref === null) return "Started by the first run";
return thread?.title.trim() || `Thread ${ref.threadId}`;
}
diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts
index 1ede30d84072b..0b124179a3f07 100644
--- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts
+++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts
@@ -254,18 +254,6 @@ 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;
@@ -278,11 +266,16 @@ export function unbindDraftFromThread(draft: ScheduledTaskDraft): ScheduledTaskD
};
}
+/** Back in the task's own project, its saved thread returns; a read-only picker can't offer it. */
export function changeDraftProject(
draft: ScheduledTaskDraft,
projectId: ProjectId,
): ScheduledTaskDraft {
if (projectId === draft.projectId) return draft;
+ const saved = draft.task;
+ if (saved?.threadId && projectId === saved.projectId && draft.threadMode !== "new_thread") {
+ return { ...draft, projectId, threadId: saved.threadId };
+ }
return { ...unbindDraftFromThread(draft), projectId };
}
diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx
index fd4f7702d68ab..112524e0ae6f3 100644
--- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx
+++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx
@@ -93,12 +93,11 @@ import { useSettingsScope } from "./SettingsScopeContext";
import { EnvironmentRow, useEnvironmentTransportLabel } from "./EnvironmentRow";
import { SettingsGroup } from "./SettingsGroup";
import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
+import { scheduledTaskDestinationRows } from "@t3tools/client-runtime/scheduled-task-destination";
import {
WEBHOOK_SIGNATURE_DEFAULTS,
changeDraftProject,
- moveDraftToThread,
scheduleDraftErrors,
- scheduledTaskDestinationThreads,
toDateTimeLocalValue,
webhookDeliveryLabel,
defaultScheduledTaskProjectId,
@@ -156,9 +155,6 @@ import {
const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] 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",
@@ -1195,7 +1191,7 @@ function ScheduledTaskDestination({
readonly threadId: ThreadId | null;
readonly threadMode: ScheduledTaskThreadMode;
readonly disabled: boolean;
- readonly onSelect: (thread: { id: ThreadId; projectId: ProjectId } | null) => void;
+ readonly onSelect: (threadId: ThreadId) => void;
/** Absent on servers that predate thread modes. */
readonly onThreadModeChange: ((mode: ScheduledTaskThreadMode) => void) | undefined;
}) {
@@ -1209,29 +1205,20 @@ function ScheduledTaskDestination({
);
const thread = shell ?? family.data?.threads.find((row) => row.id === threadId);
const currentLabel =
- ref !== null
- ? thread?.title.trim() || `Thread ${ref.threadId}`
- : threadMode === "new_thread"
- ? "New thread each run"
- : "Started by the first run";
+ ref !== null ? thread?.title.trim() || `Thread ${ref.threadId}` : "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, {
+ const rows = scheduledTaskDestinationRows(shells, {
environmentId,
projectId,
- currentThreadId: threadId,
+ current: threadId === null ? null : { id: threadId, title: currentLabel },
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 items = rows.map((row) => row.id);
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setQuery("");
@@ -1265,7 +1252,7 @@ function ScheduledTaskDestination({
) : null}
- {!onThreadModeChange || threadMode !== "new_thread" ? (
+ {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);
+ if (value !== null && value !== threadId) onSelect(value);
handleOpenChange(false);
}}
>
@@ -1309,15 +1290,9 @@ function ScheduledTaskDestination({
/>
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"}
-
+ {rows.map((row, index) => (
+
+ {row.title}
))}
@@ -1649,14 +1624,9 @@ function ScheduledTaskEditorForm({
projectId={selectedProjectId}
threadId={draft.threadId ? (draft.threadId as ThreadId) : null}
threadMode={draft.threadMode}
- disabled={saving || !connected}
- onSelect={(thread) =>
- setDraft((current) =>
- thread === null
- ? { ...unbindDraftFromThread(current), threadMode: "new_thread" }
- : moveDraftToThread(current, thread),
- )
- }
+ // Without thread modes there is no way back to a new thread each run, so stay read-only.
+ disabled={saving || !connected || !offersThreadModes}
+ onSelect={(threadId) => setDraft((current) => ({ ...current, threadId }))}
onThreadModeChange={
offersThreadModes
? (threadMode) =>
@@ -1673,7 +1643,7 @@ function ScheduledTaskEditorForm({