From 3416c17d924a10282db15ead6607254003bfa7c6 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:17:13 +0000 Subject: [PATCH 1/5] refactor(web,mobile): trim the scheduled task destination picker The thread picker repeated "New thread each run", which the mode select above it already offers, so it needed a sentinel id on web and an extra row on mobile. The picker now lists threads only and shows only in one-thread modes. The thread filter moves to client-runtime so web and mobile share it, and the web unbind test (trivial draft logic) goes. Co-Authored-By: Claude Opus 5.5 --- .../settings/ScheduledTaskPickerScreens.tsx | 52 +++-------- .../SettingsScheduledTasksRouteScreen.tsx | 92 +++++++++---------- .../settings/scheduled-task-editor.tsx | 4 +- .../features/settings/scheduledTaskDraft.ts | 12 --- .../settings/ScheduledTasksSettings.tsx | 51 +++------- .../scheduledTasksSettings.logic.test.ts | 17 ---- .../settings/scheduledTasksSettings.logic.ts | 40 +------- packages/client-runtime/package.json | 4 + .../src/scheduledTaskDestination.ts | 27 ++++++ 9 files changed, 108 insertions(+), 191 deletions(-) create mode 100644 packages/client-runtime/src/scheduledTaskDestination.ts diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index 446f022a33317..da63094ade733 100644 --- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx +++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx @@ -1,5 +1,5 @@ import { useNavigation } from "@react-navigation/native"; -import { isListedThreadLineage } from "@t3tools/contracts"; +import { scheduledTaskDestinationThreads } from "@t3tools/client-runtime/scheduled-task-destination"; import * as Haptics from "expo-haptics"; import { useMemo, useState } from "react"; import { FlatList, Platform } from "react-native"; @@ -19,12 +19,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 ( @@ -210,36 +205,24 @@ 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 }] + ...(threadId !== null && + currentLabel.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) + ? [{ id: threadId, title: currentLabel }] : []), - ...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, - })), + ...scheduledTaskDestinationThreads(shells, { + environmentId, + projectId, + currentThreadId: threadId, + query, + }).map((thread) => ({ id: thread.id, title: thread.title.trim() || "Untitled thread" })), ]; - const selectedKey = threadId ?? (editor.draft.threadMode === "new_thread" ? "new-thread" : null); return ( row.key} + keyExtractor={(row) => row.id} className="flex-1" contentInsetAdjustmentBehavior="automatic" automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} @@ -259,20 +242,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 ef4b696c82ec8..3c50fe66a242a 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -908,53 +908,51 @@ 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")} + disabled={!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..961c6a0952ea8 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; diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index ff024e5ba2ed2..7351afe81ce94 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 { scheduledTaskDestinationThreads } from "@t3tools/client-runtime/scheduled-task-destination"; import { WEBHOOK_SIGNATURE_DEFAULTS, changeDraftProject, - moveDraftToThread, scheduleDraftErrors, - scheduledTaskDestinationThreads, toDateTimeLocalValue, webhookDeliveryLabel, defaultScheduledTaskProjectId, @@ -154,9 +153,6 @@ 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", @@ -1212,7 +1208,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; }) { @@ -1226,11 +1222,7 @@ 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, { @@ -1243,10 +1235,11 @@ function ScheduledTaskDestination({ currentThreadId: threadId, query, }); - const search = query.trim().toLocaleLowerCase(); - const items: ReadonlyArray = [ - ...(search ? [] : [NEW_THREAD_DESTINATION]), - ...(threadId !== null && currentLabel.toLocaleLowerCase().includes(search) ? [threadId] : []), + const items: ReadonlyArray = [ + ...(threadId !== null && + currentLabel.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) + ? [threadId] + : []), ...threads.map((row) => row.id), ]; const handleOpenChange = (next: boolean) => { @@ -1282,7 +1275,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); }} > @@ -1329,11 +1316,9 @@ function ScheduledTaskDestination({ {items.map((value, index) => ( - {value === NEW_THREAD_DESTINATION - ? "New thread each run" - : value === threadId - ? currentLabel - : threads.find((row) => row.id === value)?.title || "Untitled thread"} + {value === threadId + ? currentLabel + : threads.find((row) => row.id === value)?.title || "Untitled thread"} ))} @@ -1666,13 +1651,7 @@ function ScheduledTaskEditorForm({ 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), - ) - } + onSelect={(threadId) => setDraft((current) => ({ ...current, threadId }))} onThreadModeChange={ offersThreadModes ? (threadMode) => diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 807b10b8cc5e2..c4c66c1cfbae0 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -6,7 +6,6 @@ import { ProviderInstanceId, ScheduledTaskId, type ScheduledTask, - ThreadId, } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; @@ -23,7 +22,6 @@ import { matchesScheduledTaskScope, scheduleFromDraft, taskToDraft, - unbindDraftFromThread, } from "./scheduledTasksSettings.logic"; const laptopId = EnvironmentId.make("laptop"); @@ -172,21 +170,6 @@ describe("editing scheduled task branch settings", () => { }); }); -describe("scheduled task destination", () => { - it("unbinding leaves the shared checkout for a new worktree from the remote", () => { - const rootBound = taskToDraft({ - ...legacyTask, - threadId: ThreadId.make("thread:bound"), - workspaceStrategy: { type: "root" }, - }); - expect(unbindDraftFromThread(rootBound)).toMatchObject({ - threadId: "", - workspaceMode: "worktree", - startFromOrigin: true, - }); - }); -}); - describe("one-time scheduled tasks", () => { afterEach(() => vi.unstubAllEnvs()); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 8d6a29a6d1857..1ecf7a138abb4 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -1,4 +1,4 @@ -import { isListedThreadLineage, isWebhookDedupeTemplate } from "@t3tools/contracts"; +import { isWebhookDedupeTemplate } from "@t3tools/contracts"; import { EnvironmentId, MAX_WEBHOOK_DELIVERY_AGE_MINUTES, @@ -12,9 +12,7 @@ import { type RuntimeMode, type ProviderInteractionMode, type ServerSettings, - type ThreadId, } from "@t3tools/contracts"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { parseMaxDeliveryAge, parseWebhookFilterDrafts, @@ -298,18 +296,6 @@ export function taskToDraft(task: ScheduledTask): DraftState { }; } -export function moveDraftToThread( - draft: DraftState, - thread: { readonly id: ThreadId; readonly projectId: ProjectId }, -): DraftState { - 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: DraftState): DraftState { if (!draft.threadId) return draft; @@ -327,30 +313,6 @@ export function changeDraftProject(draft: DraftState, projectId: string): DraftS return { ...unbindDraftFromThread(draft), projectId }; } -/** Threads a task can move to. The current one is left out; the picker always shows it. */ -export function scheduledTaskDestinationThreads( - threads: ReadonlyArray, - target: { - readonly environmentId: EnvironmentId; - readonly projectId: string; - readonly currentThreadId: ThreadId | null; - readonly query: string; - }, -): ReadonlyArray { - const search = target.query.trim().toLocaleLowerCase(); - return threads - .filter( - (thread) => - thread.environmentId === target.environmentId && - thread.projectId === target.projectId && - thread.id !== target.currentThreadId && - thread.archivedAt === null && - isListedThreadLineage(thread.lineage) && - thread.title.toLocaleLowerCase().includes(search), - ) - .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)); -} - export function scheduledTaskDefaultModel( settings: ServerSettings, project: (LegacyProjectSettingsFields & { readonly id: ProjectId }) | null, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4d1aedd477e9d..e3dc8eacec3a1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -79,6 +79,10 @@ "types": "./src/linkRequest.ts", "default": "./src/linkRequest.ts" }, + "./scheduled-task-destination": { + "types": "./src/scheduledTaskDestination.ts", + "default": "./src/scheduledTaskDestination.ts" + }, "./scheduled-task-webhook": { "types": "./src/scheduledTaskWebhook.ts", "default": "./src/scheduledTaskWebhook.ts" diff --git a/packages/client-runtime/src/scheduledTaskDestination.ts b/packages/client-runtime/src/scheduledTaskDestination.ts new file mode 100644 index 0000000000000..fd2199b6f702d --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskDestination.ts @@ -0,0 +1,27 @@ +import { isListedThreadLineage, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; + +import type { EnvironmentThreadShell } from "./state/models.ts"; + +/** Threads a task can move to, newest first. The current one is left out; pickers show it first. */ +export function scheduledTaskDestinationThreads( + threads: ReadonlyArray, + target: { + readonly environmentId: EnvironmentId | null; + readonly projectId: string | null; + readonly currentThreadId: ThreadId | null; + readonly query: string; + }, +): ReadonlyArray { + const search = target.query.trim().toLocaleLowerCase(); + return threads + .filter( + (thread) => + thread.environmentId === target.environmentId && + thread.projectId === target.projectId && + thread.id !== target.currentThreadId && + thread.archivedAt === null && + isListedThreadLineage(thread.lineage) && + thread.title.toLocaleLowerCase().includes(search), + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} From 42cc5ae131bff3ecf085230f9519e6770f3601b3 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:33:24 +0000 Subject: [PATCH 2/5] fix(web,mobile): keep the destination thread read-only without thread modes Servers that predate thread modes have no mode select, so once the picker moved a task to a thread there was no way back to a new thread each run. On those servers the picker now only shows the bound thread, as it did before #31379. Co-Authored-By: Claude Opus 5.5 --- .../settings/SettingsScheduledTasksRouteScreen.tsx | 9 ++++++++- .../src/components/settings/ScheduledTasksSettings.tsx | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 3c50fe66a242a..5b937a9596c10 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -948,7 +948,14 @@ function TaskForm({ borderTop={offersThreadModes} value={destinationLabel} onPress={() => navigation.navigate("SettingsScheduledTaskThread")} - disabled={!draft.projectId || saving || dictationPending || environmentUnavailable} + // 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} diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 7351afe81ce94..9d66bcfd585d6 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1650,7 +1650,8 @@ function ScheduledTaskEditorForm({ projectId={selectedProjectId} threadId={draft.threadId ? (draft.threadId as ThreadId) : null} threadMode={draft.threadMode} - disabled={saving || !connected} + // 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 From a31b6c9ad7d3785e81fab08d708d4f23ef5077f8 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:53:01 +0000 Subject: [PATCH 3/5] fix(web,mobile): restore a task's thread when its project changes back Changing a bound task's project clears its thread. Changing back left it cleared, and on servers without thread modes the read-only picker can't offer it again, so saving would unbind the task. Returning to the task's own project now restores its saved thread. Co-Authored-By: Claude Opus 5.5 --- .../src/features/settings/scheduledTaskDraft.ts | 5 +++++ .../settings/ScheduledTasksSettings.tsx | 2 +- .../settings/scheduledTasksSettings.logic.test.ts | 15 +++++++++++++++ .../settings/scheduledTasksSettings.logic.ts | 10 +++++++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 961c6a0952ea8..0b124179a3f07 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -266,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 9d66bcfd585d6..005f7e90ceff6 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1669,7 +1669,7 @@ function ScheduledTaskEditorForm({