Skip to content
Open
54 changes: 13 additions & 41 deletions apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down Expand Up @@ -213,38 +208,20 @@ export function ScheduledTaskThreadPickerRouteScreen() {

if (!editor) return <MissingTaskDraft />;

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 (
<SettingsScreen title="Destination" search={search}>
<LegendList
renderScrollComponent={NativeScrollView}
data={rows}
keyExtractor={(row) => row.key}
extraData={selectedKey}
keyExtractor={(row) => row.id}
extraData={threadId}
estimatedItemSize={56}
recycleItems
className="flex-1"
Expand All @@ -266,20 +243,15 @@ export function ScheduledTaskThreadPickerRouteScreen() {
renderItem={({ item, index }) => (
<SelectionRow
title={item.title}
selected={item.key === selectedKey}
selected={item.id === threadId}
isFirst={index === 0}
isLast={index === rows.length - 1}
onPress={() => {
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,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -908,53 +908,58 @@ function TaskForm({
/>
</SettingsSection>

<SettingsSection
title="Destination"
footer={
draft.threadMode === "new_thread"
? "Each run starts a new thread."
: `${
draft.threadMode === "continue"
? "Runs continue one conversation."
: "Runs share one thread, each in a fresh session without earlier runs' context."
}${
draft.threadId === null ? " The first run starts the thread." : ""
} Deleting it also deletes this task.`
}
>
{offersThreadModes ? (
<SelectRow
label="Runs go to"
value={THREAD_MODE_LABELS[draft.threadMode]}
actions={(Object.keys(THREAD_MODE_LABELS) as ScheduledTaskThreadMode[]).map(
(mode) => ({
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" ? (
<PickerRow
label="Thread"
borderTop={offersThreadModes}
value={
draft.threadId === null && draft.threadMode !== "new_thread"
? "Started by the first run"
: destinationLabel
}
onPress={() => navigation.navigate("SettingsScheduledTaskThread")}
disabled={!draft.projectId || saving || dictationPending || environmentUnavailable}
/>
) : null}
</SettingsSection>
{offersThreadModes || draft.threadMode !== "new_thread" ? (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<SettingsSection
title="Destination"
footer={
draft.threadMode === "new_thread"
? "Each run starts a new thread."
: `${
draft.threadMode === "continue"
? "Runs continue one conversation."
: "Runs share one thread, each in a fresh session without earlier runs' context."
}${
draft.threadId === null ? " The first run starts the thread." : ""
} Deleting it also deletes this task.`
}
>
{offersThreadModes ? (
<SelectRow
label="Runs go to"
value={THREAD_MODE_LABELS[draft.threadMode]}
actions={(Object.keys(THREAD_MODE_LABELS) as ScheduledTaskThreadMode[]).map(
(mode) => ({
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" ? (
<PickerRow
label="Thread"
borderTop={offersThreadModes}
value={destinationLabel}
onPress={() => 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}
</SettingsSection>
) : null}

<SettingsSection title="Workspace">
<SelectRow
Expand Down
4 changes: 1 addition & 3 deletions apps/mobile/src/features/settings/scheduled-task-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ import {
type ScheduledTaskEditor,
} from "./scheduled-task-editor-state";

export const NEW_THREAD_DESTINATION_LABEL = "New thread each run";

export function useScheduledTaskDestinationLabel(
environmentId: EnvironmentId | null,
threadId: ThreadId | null,
Expand All @@ -35,7 +33,7 @@ export function useScheduledTaskDestinationLabel(
: null,
);
const thread = shell ?? family.data?.threads.find((row) => 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}`;
}

Expand Down
17 changes: 5 additions & 12 deletions apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };
}

Expand Down
62 changes: 16 additions & 46 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<WorkspaceMode, string> = {
worktree: "Create a new worktree",
root: "Use the project checkout",
Expand Down Expand Up @@ -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;
}) {
Expand All @@ -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<string> = [
...(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("");
Expand Down Expand Up @@ -1265,23 +1252,17 @@ function ScheduledTaskDestination({
</SelectPopup>
</Select>
) : null}
{!onThreadModeChange || threadMode !== "new_thread" ? (
{threadMode !== "new_thread" ? (
<Combobox
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
items={items}
filteredItems={items}
filter={null}
autoHighlight
open={open && !disabled}
onOpenChange={handleOpenChange}
value={threadId ?? (threadMode === "new_thread" ? NEW_THREAD_DESTINATION : null)}
value={threadId}
onValueChange={(value) => {
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);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
handleOpenChange(false);
}}
>
Expand Down Expand Up @@ -1309,15 +1290,9 @@ function ScheduledTaskDestination({
/>
<ComboboxEmpty>No threads found.</ComboboxEmpty>
<ComboboxList className="max-h-72">
{items.map((value, index) => (
<ComboboxItem key={value} index={index} value={value}>
<span className="block truncate">
{value === NEW_THREAD_DESTINATION
? "New thread each run"
: value === threadId
? currentLabel
: threads.find((row) => row.id === value)?.title || "Untitled thread"}
</span>
{rows.map((row, index) => (
<ComboboxItem key={row.id} index={index} value={row.id}>
<span className="block truncate">{row.title}</span>
</ComboboxItem>
))}
</ComboboxList>
Expand Down Expand Up @@ -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}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
onSelect={(threadId) => setDraft((current) => ({ ...current, threadId }))}
onThreadModeChange={
offersThreadModes
? (threadMode) =>
Expand All @@ -1673,7 +1643,7 @@ function ScheduledTaskEditorForm({
<Select
value={selectedProjectId}
onValueChange={(projectId) =>
setDraft((current) => changeDraftProject(current, projectId ?? ""))
setDraft((current) => changeDraftProject(current, projectId ?? "", task))
}
>
<SelectTrigger size="sm" id="scheduled-task-project">
Expand Down
Loading
Loading