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({