authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-03 13:32:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-03 13:32:14-07:00
log4ac094fef689b71f475e41a9a2fce167804a5e4e
tree06828c512402ccdef2bf731783dc42b137d4f660
parenta7e43b2281879606785707411c1517cd2aca4c08
signature Signed by PGP key B5690EEEBB952194

Surface pending tasks in mobile home and draft flow (#3670)


22 files changed, 1446 insertions(+), 153 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+7
......@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
55
66import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
77import { useProjects, useThreadShells } from "../../state/entities";
8import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
89import { useWorkspaceState } from "../../state/workspace";
910import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
1011import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
......@@ -13,6 +14,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
1314import { HomeScreen } from "./HomeScreen";
1415import { HomeHeader } from "./HomeHeader";
1516import { useHomeListOptions } from "./home-list-options";
17import { usePendingTaskListActions } from "./usePendingTaskListActions";
1618import { useThreadListActions } from "./useThreadListActions";
1719
1820/* ─── Route screen ───────────────────────────────────────────────────── */
......@@ -26,6 +28,8 @@ export function HomeRouteScreen() {
2628 const navigation = useNavigation();
2729 const [searchQuery, setSearchQuery] = useState("");
2830 const { archiveThread, confirmDeleteThread } = useThreadListActions();
31 const pendingTasks = usePendingNewTasks();
32 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
2933 const environments = useMemo(
3034 () =>
3135 Arr.sort(
......@@ -116,8 +120,11 @@ export function HomeRouteScreen() {
116120 threadId: thread.id,
117121 });
118122 }}
123 onSelectPendingTask={openPendingTask}
124 onDeletePendingTask={confirmDeletePendingTask}
119125 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
120126 onThreadSortOrderChange={setThreadSortOrder}
127 pendingTasks={pendingTasks}
121128 projectGroupingMode={listOptions.projectGroupingMode}
122129 projects={projects}
123130 projectSortOrder={listOptions.projectSortOrder}
apps/mobile/src/features/home/HomeScreen.tsx+26-2
......@@ -22,7 +22,9 @@ import { EmptyState } from "../../components/EmptyState";
2222import type { WorkspaceState } from "../../state/workspaceModel";
2323import type { SavedRemoteConnection } from "../../lib/connection";
2424import { scopedProjectKey } from "../../lib/scopedEntities";
25import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2526import {
27 PendingTaskListRow,
2628 ThreadListGroupHeader,
2729 ThreadListRow,
2830 ThreadListShowMoreRow,
......@@ -47,6 +49,7 @@ import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-stat
4749interface HomeScreenProps {
4850 readonly projects: ReadonlyArray<EnvironmentProject>;
4951 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
52 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
5053 readonly catalogState: WorkspaceState;
5154 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
5255 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
......@@ -67,6 +70,8 @@ interface HomeScreenProps {
6770 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
6871 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
6972 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
73 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
74 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
7075}
7176
7277/* ─── Layout constants ───────────────────────────────────────────────── */
......@@ -189,6 +194,7 @@ export function HomeScreen(props: HomeScreenProps) {
189194 buildHomeThreadGroups({
190195 projects: props.projects,
191196 threads: props.threads,
197 pendingTasks: props.pendingTasks,
192198 environmentId: props.selectedEnvironmentId,
193199 searchQuery: props.searchQuery,
194200 projectSortOrder: props.projectSortOrder,
......@@ -196,6 +202,7 @@ export function HomeScreen(props: HomeScreenProps) {
196202 projectGroupingMode: props.projectGroupingMode,
197203 }),
198204 [
205 props.pendingTasks,
199206 props.projectGroupingMode,
200207 props.projects,
201208 props.projectSortOrder,
......@@ -242,10 +249,24 @@ export function HomeScreen(props: HomeScreenProps) {
242249 groupKey={item.group.key}
243250 onGroupAction={updateGroupDisplay}
244251 project={item.group.representative}
245 threadCount={item.group.threads.length}
252 threadCount={item.group.threads.length + item.group.pendingTasks.length}
246253 title={item.group.title}
247254 />
248255 );
256 case "pending-task":
257 return (
258 <PendingTaskListRow
259 variant="compact"
260 pendingTask={item.pendingTask}
261 environmentLabel={
262 props.savedConnectionsById[item.pendingTask.message.environmentId]
263 ?.environmentLabel ?? null
264 }
265 isLast={item.isLast}
266 onSelectPendingTask={props.onSelectPendingTask}
267 onDeletePendingTask={props.onDeletePendingTask}
268 />
269 );
249270 case "thread": {
250271 const thread = item.thread;
251272 return (
......@@ -285,7 +306,9 @@ export function HomeScreen(props: HomeScreenProps) {
285306 handleSwipeableWillOpen,
286307 projectCwdByKey,
287308 props.onArchiveThread,
309 props.onDeletePendingTask,
288310 props.onDeleteThread,
311 props.onSelectPendingTask,
289312 props.onSelectThread,
290313 props.savedConnectionsById,
291314 updateGroupDisplay,
......@@ -295,7 +318,8 @@ export function HomeScreen(props: HomeScreenProps) {
295318 const keyExtractor = useCallback((item: HomeListItem) => item.key, []);
296319
297320 /* Empty states */
298 const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null);
321 const hasAnyThreads =
322 props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
299323 const hasResults = projectGroups.length > 0;
300324 const selectedEnvironmentLabel =
301325 props.selectedEnvironmentId === null
apps/mobile/src/features/home/homeListItems.test.ts+1
......@@ -62,6 +62,7 @@ function makeGroup(key: string, threadCount: number): HomeThreadGroup {
6262 title: key,
6363 representative: project,
6464 projects: [project],
65 pendingTasks: [],
6566 threads: Array.from({ length: threadCount }, (_, index) =>
6667 makeThread(`${key}-thread-${index}`, project.id),
6768 ),
apps/mobile/src/features/home/homeListItems.ts+32-1
......@@ -1,5 +1,6 @@
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
22
3import type { PendingNewTask } from "../../state/use-pending-new-tasks";
34import type { HomeThreadGroup } from "./homeThreadList";
45
56/** Threads shown per project before the "Show more" affordance appears. */
......@@ -33,6 +34,13 @@ export interface HomeThreadListItem {
3334 readonly isLast: boolean;
3435}
3536
37export interface HomePendingTaskListItem {
38 readonly type: "pending-task";
39 readonly key: string;
40 readonly pendingTask: PendingNewTask;
41 readonly isLast: boolean;
42}
43
3644export interface HomeShowMoreListItem {
3745 readonly type: "show-more";
3846 readonly key: string;
......@@ -43,7 +51,11 @@ export interface HomeShowMoreListItem {
4351 readonly canShowLess: boolean;
4452}
4553
46export type HomeListItem = HomeHeaderListItem | HomeThreadListItem | HomeShowMoreListItem;
54export type HomeListItem =
55 | HomeHeaderListItem
56 | HomePendingTaskListItem
57 | HomeThreadListItem
58 | HomeShowMoreListItem;
4759
4860export interface HomeListLayout {
4961 readonly items: ReadonlyArray<HomeListItem>;
......@@ -82,6 +94,12 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem
8294 previous.collapsed === item.collapsed &&
8395 previous.isFirst === item.isFirst
8496 );
97 case "pending-task":
98 return (
99 previous.type === "pending-task" &&
100 previous.pendingTask === item.pendingTask &&
101 previous.isLast === item.isLast
102 );
85103 case "thread":
86104 return (
87105 previous.type === "thread" &&
......@@ -134,6 +152,19 @@ export function buildHomeListLayout(input: {
134152 const hiddenCount = totalCount - visibleCount;
135153 const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS;
136154
155 // Pending (unsent) tasks lead the group and are never paginated away.
156 for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
157 items.push({
158 type: "pending-task",
159 key: `pending-task:${pendingTask.message.messageId}`,
160 pendingTask,
161 isLast:
162 pendingIndex === group.pendingTasks.length - 1 &&
163 visibleThreads.length === 0 &&
164 !hasShowMoreRow,
165 });
166 }
167
137168 for (const [threadIndex, thread] of visibleThreads.entries()) {
138169 items.push({
139170 type: "thread",
apps/mobile/src/features/home/homeThreadList.ts+57-4
......@@ -17,6 +17,7 @@ import * as Arr from "effect/Array";
1717import * as Order from "effect/Order";
1818
1919import { scopedProjectKey } from "../../lib/scopedEntities";
20import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2021
2122export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
2223
......@@ -25,25 +26,32 @@ export interface HomeThreadGroup {
2526 readonly title: string;
2627 readonly representative: EnvironmentProject;
2728 readonly projects: ReadonlyArray<EnvironmentProject>;
29 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
2830 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
2931}
3032
3133interface MutableHomeThreadGroup {
3234 readonly key: string;
3335 readonly projects: EnvironmentProject[];
36 readonly pendingTasks: PendingNewTask[];
3437 readonly threads: EnvironmentThreadShell[];
3538}
3639
3740function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {
38 return group.threads.reduce(
41 const latestThread = group.threads.reduce(
3942 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
4043 Number.NEGATIVE_INFINITY,
4144 );
45 return group.pendingTasks.reduce((latest, pendingTask) => {
46 const timestamp = Date.parse(pendingTask.message.createdAt);
47 return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp);
48 }, latestThread);
4249}
4350
4451export function buildHomeThreadGroups(input: {
4552 readonly projects: ReadonlyArray<EnvironmentProject>;
4653 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
54 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
4755 readonly environmentId: EnvironmentId | null;
4856 readonly searchQuery: string;
4957 readonly projectSortOrder: HomeProjectSortOrder;
......@@ -68,10 +76,49 @@ export function buildHomeThreadGroups(input: {
6876 if (existing) {
6977 existing.projects.push(project);
7078 } else {
71 groups.set(groupKey, { key: groupKey, projects: [project], threads: [] });
79 groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] });
7280 }
7381 }
7482
83 for (const pendingTask of input.pendingTasks ?? []) {
84 if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) {
85 continue;
86 }
87
88 const physicalKey = scopedProjectKey(
89 pendingTask.message.environmentId,
90 pendingTask.creation.projectId,
91 );
92 let groupKey = groupKeyByProjectKey.get(physicalKey);
93 if (!groupKey) {
94 // The project shell is not loaded (environment offline / project gone).
95 // A queued task must stay visible and deletable regardless, so build a
96 // standalone group from the metadata snapshotted at enqueue time.
97 groupKey = `pending-project:${physicalKey}`;
98 groupKeyByProjectKey.set(physicalKey, groupKey);
99 groups.set(groupKey, {
100 key: groupKey,
101 projects: [
102 {
103 environmentId: pendingTask.message.environmentId,
104 id: pendingTask.creation.projectId,
105 title: pendingTask.creation.projectTitle ?? "Unknown project",
106 workspaceRoot:
107 pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId),
108 repositoryIdentity: null,
109 defaultModelSelection: null,
110 scripts: [],
111 createdAt: pendingTask.message.createdAt,
112 updatedAt: pendingTask.message.createdAt,
113 },
114 ],
115 pendingTasks: [],
116 threads: [],
117 });
118 }
119 groups.get(groupKey)?.pendingTasks.push(pendingTask);
120 }
121
75122 for (const thread of input.threads) {
76123 if (thread.archivedAt !== null) {
77124 continue;
......@@ -93,7 +140,7 @@ export function buildHomeThreadGroups(input: {
93140
94141 for (const group of groups.values()) {
95142 const representative = group.projects[0];
96 if (!representative || group.threads.length === 0) {
143 if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) {
97144 continue;
98145 }
99146
......@@ -108,8 +155,13 @@ export function buildHomeThreadGroups(input: {
108155 const matchingThreads = groupMatches
109156 ? group.threads
110157 : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
158 const matchingPendingTasks = groupMatches
159 ? group.pendingTasks
160 : group.pendingTasks.filter((pendingTask) =>
161 pendingTask.title.toLocaleLowerCase().includes(query),
162 );
111163
112 if (matchingThreads.length === 0) {
164 if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) {
113165 continue;
114166 }
115167
......@@ -118,6 +170,7 @@ export function buildHomeThreadGroups(input: {
118170 title,
119171 representative,
120172 projects: group.projects,
173 pendingTasks: matchingPendingTasks,
121174 threads: sortThreads(matchingThreads, input.threadSortOrder),
122175 });
123176 }
apps/mobile/src/features/home/usePendingTaskListActions.ts created+57
......@@ -0,0 +1,57 @@
1import { useNavigation } from "@react-navigation/native";
2import { useCallback } from "react";
3import { Alert } from "react-native";
4
5import { removeThreadOutboxMessage } from "../../state/thread-outbox";
6import type { PendingNewTask } from "../../state/use-pending-new-tasks";
7import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
8
9export function usePendingTaskListActions(): {
10 readonly openPendingTask: (pendingTask: PendingNewTask) => void;
11 readonly confirmDeletePendingTask: (pendingTask: PendingNewTask) => void;
12} {
13 const navigation = useNavigation();
14
15 const openPendingTask = useCallback(
16 (pendingTask: PendingNewTask) => {
17 navigation.navigate("NewTaskSheet", {
18 screen: "NewTaskDraft",
19 params: {
20 environmentId: String(pendingTask.message.environmentId),
21 projectId: String(pendingTask.creation.projectId),
22 pendingTaskId: String(pendingTask.message.messageId),
23 },
24 });
25 },
26 [navigation],
27 );
28
29 const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => {
30 Alert.alert(
31 "Delete pending task?",
32 `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`,
33 [
34 { text: "Cancel", style: "cancel" },
35 {
36 text: "Delete",
37 style: "destructive",
38 onPress: () => {
39 // Release the edit lock only after removal succeeds, and only if
40 // it is held for THIS task — clearing it up front (or for another
41 // task) would let the drain deliver a mid-edit payload.
42 void removeThreadOutboxMessage(pendingTask.message)
43 .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId))
44 .catch((error) => {
45 Alert.alert(
46 "Could not delete pending task",
47 error instanceof Error ? error.message : "The pending task could not be removed.",
48 );
49 });
50 },
51 },
52 ],
53 );
54 }, []);
55
56 return { openPendingTask, confirmDeletePendingTask };
57}
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+19-6
......@@ -1,4 +1,5 @@
11import type { StaticScreenProps } from "@react-navigation/native";
2import { useMemo } from "react";
23import { NativeStackScreenOptions } from "../../native/StackHeader";
34
45import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
......@@ -7,11 +8,25 @@ type NewTaskDraftRouteParams = {
78 readonly environmentId?: string | string[];
89 readonly projectId?: string | string[];
910 readonly title?: string | string[];
11 readonly pendingTaskId?: string | string[];
1012};
1113
1214export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
1315 const params = route.params ?? {};
1416
17 // Keyed on the params object so a fresh navigation to this (already
18 // mounted) screen produces a new reference, letting the draft screen
19 // re-apply the requested project.
20 const initialProjectRef = useMemo(
21 () => ({
22 environmentId: Array.isArray(params.environmentId)
23 ? params.environmentId[0]
24 : params.environmentId,
25 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
26 }),
27 [route.params],
28 );
29
1530 return (
1631 <>
1732 <NativeStackScreenOptions
......@@ -20,12 +35,10 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
2035 }}
2136 />
2237 <NewTaskDraftScreen
23 initialProjectRef={{
24 environmentId: Array.isArray(params.environmentId)
25 ? params.environmentId[0]
26 : params.environmentId,
27 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
28 }}
38 initialProjectRef={initialProjectRef}
39 pendingTaskId={
40 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
41 }
2942 />
3043 </>
3144 );
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+156-11
......@@ -23,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri
2323import { ControlPillMenu } from "../../components/ControlPill";
2424import { ProviderIcon } from "../../components/ProviderIcon";
2525
26import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
2627import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
2728import {
2829 applyProviderOptionMenuEvent,
......@@ -30,10 +31,11 @@ import {
3031 providerOptionsConfigurationLabel,
3132 resolveProviderOptionDescriptors,
3233} from "../../lib/providerOptions";
33import { scopedProjectKey } from "../../lib/scopedEntities";
3434import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
3535import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
3636import { useProjects } from "../../state/entities";
37import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
38import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
3739import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
3840import { useCreateProjectThread } from "./use-project-actions";
3941
......@@ -54,6 +56,8 @@ export function NewTaskDraftScreen(props: {
5456 readonly environmentId?: string;
5557 readonly projectId?: string;
5658 };
59 /** Queued outbox message id when editing an existing pending task. */
60 readonly pendingTaskId?: string;
5761}) {
5862 const projects = useProjects();
5963 const createProjectThread = useCreateProjectThread();
......@@ -64,15 +68,67 @@ export function NewTaskDraftScreen(props: {
6468 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
6569 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
6670 const { logicalProjects, selectedProject, setProject } = flow;
71 const { connectedEnvironments } = useRemoteConnectionStatus();
72 const environmentConnected =
73 selectedProject !== null &&
74 connectedEnvironments.find(
75 (environment) => environment.environmentId === selectedProject.environmentId,
76 )?.connectionState === "connected";
6777 const promptInputRef = useRef<ComposerEditorHandle>(null);
6878 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
79 const appliedInitialProjectKeyRef = useRef<string | null>(null);
80 useEffect(() => {
81 return () => {
82 appliedInitialProjectKeyRef.current = null;
83 };
84 }, []);
85
86 const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow;
87 const attemptedPendingTaskIdRef = useRef<string | null>(null);
88 useEffect(() => {
89 if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
90 return;
91 }
92 // Attempt each pending task once: after it is delivered or deleted the
93 // editing session legitimately ends, and re-running must not navigate.
94 if (attemptedPendingTaskIdRef.current === props.pendingTaskId) {
95 return;
96 }
97 attemptedPendingTaskIdRef.current = props.pendingTaskId;
98 if (!beginEditingPendingTask(props.pendingTaskId)) {
99 // The queued task no longer exists (sent or deleted before opening).
100 navigation.dispatch(StackActions.replace("NewTask"));
101 }
102 }, [beginEditingPendingTask, editingPendingTask?.messageId, navigation, props.pendingTaskId]);
103
104 useEffect(() => {
105 if (!props.pendingTaskId) return;
106 return () => {
107 // Allow a later navigation for the same pending task to re-hydrate it.
108 attemptedPendingTaskIdRef.current = null;
109 cancelEditingPendingTask();
110 };
111 }, [props.pendingTaskId, cancelEditingPendingTask]);
69112
70113 const borderColor = useThemeColor("--color-border");
71114 const bodyText = useScaledTextRole("body");
72115 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
73116 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
74117
118 // A new navigation to this mounted screen delivers a fresh initialProjectRef
119 // reference — treat it as a new request and let it apply again.
120 const lastInitialProjectRefRef = useRef(props.initialProjectRef);
121
75122 useEffect(() => {
123 // Pending-task editing owns project selection (and must not fall through
124 // to the replace("NewTask") fallback while its hydration is in flight).
125 if (props.pendingTaskId) {
126 return;
127 }
128 if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
129 lastInitialProjectRefRef.current = props.initialProjectRef;
130 appliedInitialProjectKeyRef.current = null;
131 }
76132 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {
77133 const directProject =
78134 projects.find(
......@@ -82,6 +138,13 @@ export function NewTaskDraftScreen(props: {
82138 ) ?? null;
83139
84140 if (directProject) {
141 // Apply the route's project once. Re-applying on every change would
142 // instantly revert environment/project switches made in the picker.
143 const directProjectKey = `${directProject.environmentId}:${directProject.id}`;
144 if (appliedInitialProjectKeyRef.current === directProjectKey) {
145 return;
146 }
147 appliedInitialProjectKeyRef.current = directProjectKey;
85148 if (
86149 selectedProject?.environmentId === directProject.environmentId &&
87150 selectedProject.id === directProject.id
......@@ -106,8 +169,8 @@ export function NewTaskDraftScreen(props: {
106169 }, [
107170 logicalProjects,
108171 projects,
109 props.initialProjectRef?.environmentId,
110 props.initialProjectRef?.projectId,
172 props.initialProjectRef,
173 props.pendingTaskId,
111174 navigation,
112175 selectedProject,
113176 setProject,
......@@ -274,12 +337,24 @@ export function NewTaskDraftScreen(props: {
274337 subtitle: flow.selectedBranchName ?? "Choose branch",
275338 subactions: branchActions,
276339 },
340 ...(flow.workspaceMode === "worktree"
341 ? [
342 {
343 id: "workspace:start-from-origin",
344 title: "Start from origin",
345 subtitle: "Base the worktree on the latest origin branch",
346 image: "arrow.triangle.pull",
347 state: flow.startFromOrigin ? ("on" as const) : undefined,
348 },
349 ]
350 : []),
277351 ];
278352 }, [
279353 flow.availableBranches,
280354 flow.branchesLoading,
281355 flow.selectedBranchName,
282356 flow.selectedProject,
357 flow.startFromOrigin,
283358 flow.workspaceMode,
284359 ]);
285360
......@@ -344,6 +419,10 @@ export function NewTaskDraftScreen(props: {
344419 );
345420 return;
346421 }
422 if (event === "workspace:start-from-origin") {
423 flow.setStartFromOrigin(!flow.startFromOrigin);
424 return;
425 }
347426 if (event.startsWith("workspace:branch:")) {
348427 const branchName = event.slice("workspace:branch:".length);
349428 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
......@@ -379,17 +458,17 @@ export function NewTaskDraftScreen(props: {
379458
380459 async function handleStart(): Promise<void> {
381460 const selectedProject = flow.selectedProject;
382 if (!selectedProject) {
461 const draftKey = flow.draftKey;
462 if (!selectedProject || !draftKey) {
383463 return;
384464 }
385 const draft = getComposerDraftSnapshot(
386 `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`,
387 );
465 const draft = getComposerDraftSnapshot(draftKey);
388466 const modelSelection = draft.modelSelection ?? flow.selectedModel;
389467 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
390468 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
391469 const selectedWorktreePath =
392470 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
471 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
393472 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
394473 const interactionMode = draft.interactionMode ?? flow.interactionMode;
395474 const initialMessageText = draft.text.trim();
......@@ -403,6 +482,46 @@ export function NewTaskDraftScreen(props: {
403482 return;
404483 }
405484
485 const editingPendingTask = flow.editingPendingTask;
486
487 if (!environmentConnected) {
488 // Offline: park the task in the outbox; the drain sends it when the
489 // environment reconnects. Editing an existing pending task re-queues it
490 // under its original identifiers.
491 const metadata = editingPendingTask
492 ? {
493 threadId: editingPendingTask.threadId,
494 commandId: editingPendingTask.commandId,
495 messageId: editingPendingTask.messageId,
496 createdAt: editingPendingTask.createdAt,
497 }
498 : makeTurnCommandMetadata();
499 const message = flow.buildPendingTaskMessage(metadata);
500 if (!message) {
501 return;
502 }
503 flow.setSubmitting(true);
504 try {
505 await enqueueThreadOutboxMessage(message);
506 } catch (error) {
507 Alert.alert(
508 "Could not queue task",
509 error instanceof Error ? error.message : "The task could not be saved to the outbox.",
510 );
511 return;
512 } finally {
513 flow.setSubmitting(false);
514 }
515 if (editingPendingTask) {
516 flow.finishEditingPendingTask();
517 } else {
518 flow.setPrompt("");
519 flow.clearAttachments();
520 }
521 navigation.getParent()?.goBack();
522 return;
523 }
524
406525 flow.setSubmitting(true);
407526 const result = await createProjectThread({
408527 project: selectedProject,
......@@ -410,10 +529,21 @@ export function NewTaskDraftScreen(props: {
410529 envMode: workspaceMode,
411530 branch: selectedBranchName,
412531 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
532 startFromOrigin,
413533 runtimeMode,
414534 interactionMode,
415535 initialMessageText,
416536 initialAttachments: draft.attachments,
537 ...(editingPendingTask
538 ? {
539 turnMetadata: {
540 threadId: editingPendingTask.threadId,
541 commandId: editingPendingTask.commandId,
542 messageId: editingPendingTask.messageId,
543 createdAt: editingPendingTask.createdAt,
544 },
545 }
546 : {}),
417547 });
418548 flow.setSubmitting(false);
419549
......@@ -428,8 +558,17 @@ export function NewTaskDraftScreen(props: {
428558 return;
429559 }
430560
431 flow.setPrompt("");
432 flow.clearAttachments();
561 if (editingPendingTask) {
562 try {
563 await removeThreadOutboxMessage(editingPendingTask);
564 } catch (error) {
565 console.warn("[new-task] failed to remove delivered pending task", error);
566 }
567 flow.finishEditingPendingTask();
568 } else {
569 flow.setPrompt("");
570 flow.clearAttachments();
571 }
433572 navigation.dispatch(
434573 StackActions.replace("Thread", {
435574 environmentId: String(result.value.environmentId),
......@@ -538,8 +677,14 @@ export function NewTaskDraftScreen(props: {
538677 </ControlPillMenu>
539678 </ComposerToolbarScroller>
540679 <ComposerToolbarButton
541 accessibilityLabel={flow.submitting ? "Starting task" : "Start task"}
542 icon="arrow.up"
680 accessibilityLabel={
681 flow.submitting
682 ? "Starting task"
683 : environmentConnected
684 ? "Start task"
685 : "Queue task"
686 }
687 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
543688 onPress={() => void handleStart()}
544689 variant="primary"
545690 showChevron={false}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+29-3
......@@ -18,6 +18,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
1818import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
1919import { useThemeColor } from "../../lib/useThemeColor";
2020import { useProjects, useThreadShells } from "../../state/entities";
21import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
2122import { useWorkspaceState } from "../../state/workspace";
2223import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
2324import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
......@@ -40,6 +41,7 @@ import {
4041} from "../home/homeListItems";
4142import { buildHomeThreadGroups } from "../home/homeThreadList";
4243import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
44import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
4345import { useThreadListActions } from "../home/useThreadListActions";
4446import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";
4547import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";
......@@ -47,7 +49,12 @@ import { SidebarHeaderActions } from "./sidebar-header-actions";
4749import { SidebarFilterButton } from "./sidebar-filter-button";
4850import { createSidebarHeaderItems } from "./sidebar-native-header-items";
4951import { SidebarNavigationShell } from "./sidebar-navigation-shell";
50import { ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow } from "./thread-list-items";
52import {
53 PendingTaskListRow,
54 ThreadListGroupHeader,
55 ThreadListRow,
56 ThreadListShowMoreRow,
57} from "./thread-list-items";
5158
5259/**
5360 * Shared capsule behind the sidebar header buttons — a native liquid-glass
......@@ -162,6 +169,8 @@ function ThreadNavigationSidebarPane(
162169 const headerIsOverContentRef = useRef(false);
163170 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
164171 const { archiveThread, confirmDeleteThread } = useThreadListActions();
172 const pendingTasks = usePendingNewTasks();
173 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
165174 const environments = useMemo(
166175 () =>
167176 Object.values(savedConnectionsById)
......@@ -188,13 +197,14 @@ function ThreadNavigationSidebarPane(
188197 buildHomeThreadGroups({
189198 projects,
190199 threads,
200 pendingTasks,
191201 environmentId: options.selectedEnvironmentId,
192202 searchQuery: props.searchQuery,
193203 projectSortOrder: options.projectSortOrder,
194204 threadSortOrder: options.threadSortOrder,
195205 projectGroupingMode: options.projectGroupingMode,
196206 }),
197 [options, projects, props.searchQuery, threads],
207 [options, pendingTasks, projects, props.searchQuery, threads],
198208 );
199209 const [groupDisplayStates, setGroupDisplayStates] = useState<
200210 ReadonlyMap<string, HomeGroupDisplayState>
......@@ -404,10 +414,24 @@ function ThreadNavigationSidebarPane(
404414 groupKey={item.group.key}
405415 onGroupAction={updateGroupDisplay}
406416 project={item.group.representative}
407 threadCount={item.group.threads.length}
417 threadCount={item.group.threads.length + item.group.pendingTasks.length}
408418 title={item.group.title}
409419 />
410420 );
421 case "pending-task":
422 return (
423 <PendingTaskListRow
424 variant="sidebar"
425 pendingTask={item.pendingTask}
426 environmentLabel={
427 savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??
428 null
429 }
430 isLast={item.isLast}
431 onSelectPendingTask={openPendingTask}
432 onDeletePendingTask={confirmDeletePendingTask}
433 />
434 );
411435 case "thread": {
412436 const thread = item.thread;
413437 return (
......@@ -449,10 +473,12 @@ function ThreadNavigationSidebarPane(
449473 },
450474 [
451475 archiveThread,
476 confirmDeletePendingTask,
452477 confirmDeleteThread,
453478 handleSelectThread,
454479 handleSwipeableClose,
455480 handleSwipeableWillOpen,
481 openPendingTask,
456482 projectCwdByKey,
457483 props.selectedThreadKey,
458484 props.width,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+318-10
......@@ -1,4 +1,4 @@
1import React, { useCallback, useEffect, useMemo, useState } from "react";
1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
33import type {
44 EnvironmentId,
......@@ -8,18 +8,29 @@ import type {
88 RuntimeMode,
99 ServerProviderSkill,
1010} from "@t3tools/contracts";
11import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
11import {
12 CommandId,
13 DEFAULT_PROVIDER_INTERACTION_MODE,
14 DEFAULT_RUNTIME_MODE,
15 MessageId,
16 ThreadId,
17} from "@t3tools/contracts";
1218import * as Arr from "effect/Array";
1319import { pipe } from "effect/Function";
1420
1521import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
22import type { TurnCommandMetadata } from "../../lib/commandMetadata";
1623import type { DraftComposerImageAttachment } from "../../lib/composerImages";
1724import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
1825import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
1926import { groupProjectsByRepository } from "../../lib/repositoryGroups";
2027import { scopedProjectKey } from "../../lib/scopedEntities";
28import { appAtomRegistry } from "../../state/atom-registry";
2129import {
2230 appendComposerDraftAttachments,
31 clearComposerDraft,
32 getComposerDraftSnapshot,
33 isComposerDraftEmpty,
2334 removeComposerDraftAttachment,
2435 replaceComposerDraftAttachments,
2536 setComposerDraftText,
......@@ -27,6 +38,17 @@ import {
2738 useComposerDraft,
2839} from "../../state/use-composer-drafts";
2940import { useBranches } from "../../state/queries";
41import {
42 flattenQueuedThreadMessages,
43 threadOutboxManager,
44 updateThreadOutboxMessage,
45 type QueuedThreadMessage,
46} from "../../state/thread-outbox";
47import {
48 holdEditingQueuedMessage,
49 releaseEditingQueuedMessage,
50 useThreadOutboxMessages,
51} from "../../state/use-thread-outbox";
3052import {
3153 setPendingConnectionError,
3254 useSavedRemoteConnections,
......@@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
3658
3759type WorkspaceMode = "local" | "worktree";
3860
61function pendingTaskDraftKey(messageId: string): string {
62 return `pending-task:${messageId}`;
63}
64
65// The message id owned by the currently active editing session, tracked
66// across provider instances. An in-flight flush from a dismissed session
67// consults it so it never drops the draft or releases the drain lock out from
68// under a newer session editing the same task.
69let activeEditingMessageId: string | null = null;
70
71function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {
72 const message = flattenQueuedThreadMessages(
73 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
74 ).find((candidate) => candidate.messageId === messageId);
75 return message?.creation !== undefined ? message : null;
76}
77
3978function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {
4079 if (!branch.worktreePath) {
4180 return null;
......@@ -74,6 +113,9 @@ type NewTaskFlowContextValue = {
74113 readonly workspaceMode: WorkspaceMode;
75114 readonly selectedBranchName: string | null;
76115 readonly selectedWorktreePath: string | null;
116 readonly startFromOrigin: boolean;
117 readonly draftKey: string | null;
118 readonly editingPendingTask: QueuedThreadMessage | null;
77119 readonly prompt: string;
78120 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
79121 readonly submitting: boolean;
......@@ -100,6 +142,11 @@ type NewTaskFlowContextValue = {
100142 readonly setSelectedModelKey: (key: string | null) => void;
101143 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
102144 readonly selectBranch: (branch: VcsRef) => void;
145 readonly setStartFromOrigin: (value: boolean) => void;
146 readonly beginEditingPendingTask: (messageId: string) => boolean;
147 readonly finishEditingPendingTask: () => void;
148 readonly cancelEditingPendingTask: () => void;
149 readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null;
103150 readonly setPrompt: (value: string) => void;
104151 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
105152 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
......@@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
162209 const [submitting, setSubmitting] = useState(false);
163210 const [branchQuery, setBranchQuery] = useState("");
164211 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
212 const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null);
213 // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act
214 // on a task whose editing session already ended this render.
215 const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);
165216
166217 const reset = useCallback(() => {
167218 setSelectedEnvironmentId(null);
......@@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
169220 setSubmitting(false);
170221 setBranchQuery("");
171222 setExpandedProvider(null);
223 const editing = editingPendingTaskRef.current;
224 editingPendingTaskRef.current = null;
225 setEditingPendingTask(null);
226 if (editing) {
227 if (activeEditingMessageId === editing.messageId) {
228 activeEditingMessageId = null;
229 }
230 releaseEditingQueuedMessage(editing.messageId);
231 }
172232 }, []);
173233
174234 const environments = useMemo(
......@@ -214,24 +274,59 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
214274 [projects, selectedEnvironmentId],
215275 );
216276
277 // Stand-in for the edited task's project while its shell is not loaded
278 // (environment offline / still synchronizing), built from the metadata
279 // snapshotted at enqueue time.
280 const editingPendingProject = useMemo<EnvironmentProject | null>(() => {
281 const creation = editingPendingTask?.creation;
282 if (!editingPendingTask || !creation) {
283 return null;
284 }
285 return {
286 environmentId: editingPendingTask.environmentId,
287 id: creation.projectId,
288 title: creation.projectTitle ?? "Unknown project",
289 // Deliberately empty when the snapshot has no cwd — downstream consumers
290 // (branch queries, worktree bootstrap) must skip it, not receive a
291 // fabricated path.
292 workspaceRoot: creation.projectCwd ?? "",
293 repositoryIdentity: null,
294 defaultModelSelection: editingPendingTask.modelSelection ?? null,
295 scripts: [],
296 createdAt: editingPendingTask.createdAt,
297 updatedAt: editingPendingTask.createdAt,
298 };
299 }, [editingPendingTask]);
300
217301 const selectedProject =
218302 projectsForEnvironment.find(
219303 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
220304 ) ??
221 projectsForEnvironment[0] ??
222 null;
305 // While editing a queued task whose project shell is absent, keep the task
306 // pinned to its own project — falling through to an arbitrary first
307 // project would silently retarget it (and its reused turn identifiers).
308 (editingPendingProject !== null &&
309 selectedProjectKey ===
310 scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id)
311 ? editingPendingProject
312 : (projectsForEnvironment[0] ?? null));
223313 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
224314 selectedProject?.environmentId ?? null,
225315 );
226 const selectedProjectDraftKey = selectedProject
227 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
228 : null;
316 // While a queued pending task is being edited its draft lives under a key
317 // scoped to the queued message, so per-project new-task drafts stay intact.
318 const selectedProjectDraftKey = editingPendingTask
319 ? pendingTaskDraftKey(editingPendingTask.messageId)
320 : selectedProject
321 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
322 : null;
229323 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
230324 const prompt = selectedProjectDraft.text;
231325 const attachments = selectedProjectDraft.attachments;
232326 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";
233327 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
234328 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
329 const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false;
235330 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
236331 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
237332
......@@ -350,7 +445,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
350445 const branchTarget = useMemo(
351446 () => ({
352447 environmentId: selectedProject?.environmentId ?? null,
353 cwd: selectedProject?.workspaceRoot ?? null,
448 // `|| null` also skips the stand-in project's empty workspaceRoot.
449 cwd: selectedProject?.workspaceRoot || null,
354450 query: null,
355451 }),
356452 [selectedProject?.environmentId, selectedProject?.workspaceRoot],
......@@ -399,10 +495,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
399495 mode,
400496 branch: selectedBranchName,
401497 worktreePath: selectedWorktreePath,
498 startFromOrigin,
402499 },
403500 });
404501 },
405 [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath],
502 [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin],
406503 );
407504
408505 const selectBranch = useCallback(
......@@ -415,10 +512,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
415512 mode: workspaceMode,
416513 branch: branch.name,
417514 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
515 startFromOrigin,
418516 },
419517 });
420518 },
421 [selectedProject, selectedProjectDraftKey, workspaceMode],
519 [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode],
520 );
521
522 const setStartFromOrigin = useCallback(
523 (value: boolean) => {
524 if (!selectedProjectDraftKey) {
525 return;
526 }
527 updateComposerDraftSettings(selectedProjectDraftKey, {
528 workspaceSelection: {
529 mode: workspaceMode,
530 branch: selectedBranchName,
531 worktreePath: selectedWorktreePath,
532 startFromOrigin: value,
533 },
534 });
535 },
536 [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, workspaceMode],
422537 );
423538
424539 const refreshBranches = branchState.refresh;
......@@ -460,6 +575,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
460575 [selectedProjectDraftKey],
461576 );
462577
578 const beginEditingPendingTask = useCallback((messageId: string): boolean => {
579 const message = findQueuedPendingTask(messageId);
580 if (!message?.creation) {
581 return false;
582 }
583 const draftKey = pendingTaskDraftKey(message.messageId);
584 // Only hydrate a fresh editing draft; reopening mid-edit keeps newer edits.
585 if (isComposerDraftEmpty(getComposerDraftSnapshot(draftKey))) {
586 setComposerDraftText(draftKey, message.text);
587 replaceComposerDraftAttachments(draftKey, message.attachments);
588 updateComposerDraftSettings(draftKey, {
589 modelSelection: message.modelSelection,
590 runtimeMode: message.runtimeMode,
591 interactionMode: message.interactionMode,
592 workspaceSelection: {
593 mode: message.creation.workspaceMode,
594 branch: message.creation.branch,
595 worktreePath: message.creation.worktreePath,
596 startFromOrigin: message.creation.startFromOrigin ?? false,
597 },
598 });
599 }
600 setSelectedEnvironmentId(message.environmentId);
601 setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId));
602 activeEditingMessageId = message.messageId;
603 editingPendingTaskRef.current = message;
604 setEditingPendingTask(message);
605 // Hold the outbox drain off this task while it is open in the editor.
606 holdEditingQueuedMessage(message.messageId);
607 return true;
608 }, []);
609
610 const buildPendingTaskMessage = useCallback(
611 (metadata: TurnCommandMetadata): QueuedThreadMessage | null => {
612 if (!selectedProject || !selectedProjectDraftKey) {
613 return null;
614 }
615 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
616 const text = draft.text.trim();
617 const draftModelSelection = draft.modelSelection ?? selectedModel;
618 if (text.length === 0 || !draftModelSelection) {
619 return null;
620 }
621 const workspaceSelection = draft.workspaceSelection;
622 const mode = workspaceSelection?.mode ?? "local";
623 // When the selection is the stand-in built from the queued snapshot,
624 // persist the original (possibly absent) snapshot values — the
625 // stand-in's placeholder title/workspaceRoot must never be written back
626 // as if they were real project metadata.
627 const usingPendingSnapshot = selectedProject === editingPendingProject;
628 const projectTitle = usingPendingSnapshot
629 ? editingPendingTask?.creation?.projectTitle
630 : selectedProject.title;
631 const projectCwd = usingPendingSnapshot
632 ? editingPendingTask?.creation?.projectCwd
633 : selectedProject.workspaceRoot;
634 return {
635 environmentId: selectedProject.environmentId,
636 threadId: ThreadId.make(metadata.threadId),
637 messageId: MessageId.make(metadata.messageId),
638 commandId: CommandId.make(metadata.commandId),
639 text,
640 attachments: draft.attachments,
641 modelSelection: draftModelSelection,
642 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
643 interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
644 creation: {
645 projectId: selectedProject.id,
646 ...(projectTitle !== undefined ? { projectTitle } : {}),
647 ...(projectCwd !== undefined ? { projectCwd } : {}),
648 workspaceMode: mode,
649 branch: workspaceSelection?.branch ?? null,
650 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
651 ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}),
652 },
653 createdAt: metadata.createdAt,
654 };
655 },
656 [
657 editingPendingProject,
658 editingPendingTask,
659 selectedModel,
660 selectedProject,
661 selectedProjectDraftKey,
662 ],
663 );
664
665 const finishEditingPendingTask = useCallback(() => {
666 const editing = editingPendingTaskRef.current;
667 editingPendingTaskRef.current = null;
668 if (editing) {
669 if (activeEditingMessageId === editing.messageId) {
670 activeEditingMessageId = null;
671 }
672 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
673 releaseEditingQueuedMessage(editing.messageId);
674 }
675 setEditingPendingTask(null);
676 }, []);
677
678 // If the queued task disappears mid-edit (deleted from the list, or
679 // delivered), end the editing session immediately without saving — a later
680 // flush must not resurrect it, and the composer should fall back to the
681 // regular per-project draft.
682 const queuedMessagesByThreadKey = useThreadOutboxMessages();
683 useEffect(() => {
684 const editing = editingPendingTaskRef.current;
685 if (!editing) {
686 return;
687 }
688 const stillQueued = flattenQueuedThreadMessages(queuedMessagesByThreadKey).some(
689 (candidate) => candidate.messageId === editing.messageId,
690 );
691 if (!stillQueued) {
692 finishEditingPendingTask();
693 }
694 }, [finishEditingPendingTask, queuedMessagesByThreadKey]);
695
696 // Leaving the flow mid-edit (sheet dismissed or draft screen popped) saves
697 // the current edits back into the queued task so nothing typed here is lost.
698 const editingFlushRef = useRef<(() => void) | null>(null);
699 useEffect(() => {
700 editingFlushRef.current = () => {
701 const editing = editingPendingTaskRef.current;
702 if (!editing) {
703 return;
704 }
705 editingPendingTaskRef.current = null;
706 setEditingPendingTask(null);
707 if (activeEditingMessageId === editing.messageId) {
708 activeEditingMessageId = null;
709 }
710
711 const message = buildPendingTaskMessage({
712 threadId: editing.threadId,
713 commandId: editing.commandId,
714 messageId: editing.messageId,
715 createdAt: editing.createdAt,
716 });
717
718 if (!message) {
719 // The edits are currently unsendable (e.g. the prompt was cleared).
720 // Keep both the draft and the drain lock: the stale queued payload
721 // must not auto-send content the user just removed, and reopening the
722 // task resumes from the saved draft.
723 return;
724 }
725
726 // update() rewrites the task only if it is still queued — a concurrent
727 // delete or delivery wins, so the flush cannot resurrect it.
728 void updateThreadOutboxMessage(message)
729 .then(() => {
730 // If this task was reopened (possibly in a fresh provider) while
731 // the save was in flight, that session owns the draft and the lock.
732 if (activeEditingMessageId === editing.messageId) {
733 return;
734 }
735 clearComposerDraft(pendingTaskDraftKey(editing.messageId));
736 releaseEditingQueuedMessage(editing.messageId);
737 })
738 .catch((error) => {
739 // Keep the drain lock and the draft: delivering the stale payload
740 // would silently drop the newer edits. Reopening the task retries.
741 console.warn("[new-task] failed to save edited pending task", error);
742 });
743 };
744 }, [buildPendingTaskMessage]);
745 const cancelEditingPendingTask = useCallback(() => {
746 editingFlushRef.current?.();
747 }, []);
748 useEffect(
749 () => () => {
750 editingFlushRef.current?.();
751 },
752 [],
753 );
754
463755 const value = useMemo<NewTaskFlowContextValue>(
464756 () => ({
465757 logicalProjects,
......@@ -469,6 +761,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
469761 workspaceMode,
470762 selectedBranchName,
471763 selectedWorktreePath,
764 startFromOrigin,
765 draftKey: selectedProjectDraftKey,
766 editingPendingTask,
472767 prompt,
473768 attachments,
474769 submitting,
......@@ -492,6 +787,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
492787 setSelectedModelKey,
493788 setWorkspaceMode,
494789 selectBranch,
790 setStartFromOrigin,
791 beginEditingPendingTask,
792 finishEditingPendingTask,
793 cancelEditingPendingTask,
794 buildPendingTaskMessage,
495795 setPrompt,
496796 replaceAttachments,
497797 appendAttachments,
......@@ -508,11 +808,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
508808 [
509809 attachments,
510810 availableBranches,
811 beginEditingPendingTask,
511812 branchQuery,
512813 branchesLoading,
814 buildPendingTaskMessage,
815 cancelEditingPendingTask,
816 editingPendingTask,
513817 environments,
514818 expandedProvider,
515819 filteredBranches,
820 finishEditingPendingTask,
516821 interactionMode,
517822 loadBranches,
518823 logicalProjects,
......@@ -527,6 +832,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
527832 selectedModel,
528833 selectedModelKey,
529834 selectedModelOption,
835 selectedProjectDraftKey,
530836 selectedProviderSkills,
531837 setSelectedModelOptions,
532838 selectedProject,
......@@ -539,7 +845,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
539845 setPrompt,
540846 setRuntimeMode,
541847 setSelectedModelKey,
848 setStartFromOrigin,
542849 setWorkspaceMode,
850 startFromOrigin,
543851 submitting,
544852 workspaceMode,
545853 appendAttachments,
apps/mobile/src/features/threads/thread-list-items.tsx+168
......@@ -14,6 +14,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
1414import { ProjectFavicon } from "../../components/ProjectFavicon";
1515import { relativeTime } from "../../lib/time";
1616import { useThemeColor } from "../../lib/useThemeColor";
17import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1718import { useThreadPr } from "../../state/use-thread-pr";
1819import type { HomeGroupDisplayAction } from "../home/homeListItems";
1920import { ThreadSwipeable } from "../home/thread-swipe-actions";
......@@ -180,6 +181,173 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:
180181 );
181182});
182183
184/* ─── Pending task row ───────────────────────────────────────────────── */
185
186const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
187 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
188];
189
190/**
191 * A queued new task waiting in the outbox for its environment to reconnect.
192 * Tapping reopens the new-task composer with everything prefilled; the row
193 * disappears once the task is delivered and the real thread arrives.
194 */
195export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
196 readonly variant: ThreadListVariant;
197 readonly pendingTask: PendingNewTask;
198 readonly environmentLabel: string | null;
199 readonly isLast: boolean;
200 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
201 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
202}) {
203 const compact = props.variant === "compact";
204 const separatorColor = useThemeColor("--color-separator");
205 const iconSubtleColor = useThemeColor("--color-icon-subtle");
206 const foregroundColor = useThemeColor("--color-foreground");
207 const mutedColor = useThemeColor("--color-foreground-muted");
208 const pressedBackgroundColor = useThemeColor("--color-subtle");
209
210 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
211 const timestamp = relativeTime(pendingTask.message.createdAt);
212 const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter(
213 (part): part is string => Boolean(part),
214 );
215
216 const handleMenuAction = useCallback(
217 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
218 if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask);
219 },
220 [onDeletePendingTask, pendingTask],
221 );
222
223 const statusPill = (
224 <View
225 className="bg-zinc-500/12 dark:bg-zinc-500/16"
226 style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
227 >
228 <Text className="text-3xs font-t3-bold text-zinc-600 dark:text-zinc-300">Pending</Text>
229 </View>
230 );
231
232 const subtitleRow =
233 subtitleParts.length > 0 ? (
234 <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
235 <SymbolView
236 name="tray.and.arrow.up"
237 size={10}
238 tintColor={compact ? iconSubtleColor : mutedColor}
239 type="monochrome"
240 />
241 <Text
242 className={compact ? "text-sm text-foreground-muted" : "text-xs"}
243 numberOfLines={1}
244 style={compact ? { flexShrink: 1 } : { flexShrink: 1, color: mutedColor }}
245 >
246 {subtitleParts.join(" · ")}
247 </Text>
248 </View>
249 ) : null;
250
251 const rowContent = compact ? (
252 <Pressable
253 accessibilityHint="Opens the queued task for editing"
254 accessibilityLabel={pendingTask.title}
255 accessibilityRole="button"
256 className="bg-screen"
257 onPress={() => onSelectPendingTask(pendingTask)}
258 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
259 >
260 <View
261 style={{
262 paddingLeft: THREAD_LIST_COMPACT_INSET,
263 paddingRight: 18,
264 paddingTop: 10,
265 }}
266 >
267 <View
268 style={{
269 gap: 3,
270 borderBottomWidth: props.isLast ? 0 : 1,
271 borderBottomColor: separatorColor,
272 paddingBottom: 10,
273 }}
274 >
275 <View className="flex-row items-center justify-between gap-2">
276 <Text className="flex-1 text-lg font-t3-bold text-foreground" numberOfLines={1}>
277 {pendingTask.title}
278 </Text>
279 <View className="flex-row items-center gap-2">
280 {statusPill}
281 <Text
282 className="text-base text-foreground-tertiary"
283 style={{ fontVariant: ["tabular-nums"] }}
284 >
285 {timestamp}
286 </Text>
287 <SymbolView
288 name="chevron.right"
289 size={13}
290 tintColor={iconSubtleColor}
291 type="monochrome"
292 />
293 </View>
294 </View>
295 {subtitleRow}
296 </View>
297 </View>
298 </Pressable>
299 ) : (
300 <Pressable
301 accessibilityHint="Opens the queued task for editing"
302 accessibilityLabel={pendingTask.title}
303 accessibilityRole="button"
304 onPress={() => onSelectPendingTask(pendingTask)}
305 style={({ pressed }) => ({
306 backgroundColor: pressed ? pressedBackgroundColor : "transparent",
307 borderRadius: SIDEBAR_ROW_RADIUS,
308 cursor: "pointer",
309 minHeight: 64,
310 justifyContent: "center",
311 paddingHorizontal: 12,
312 paddingVertical: 10,
313 })}
314 >
315 <View style={{ gap: 3 }}>
316 <View className="flex-row items-center justify-between gap-2">
317 <Text
318 className="flex-1 text-base font-t3-medium"
319 numberOfLines={1}
320 style={{ color: foregroundColor }}
321 >
322 {pendingTask.title}
323 </Text>
324 <View className="flex-row items-center gap-2">
325 {statusPill}
326 <Text
327 className="text-xs"
328 numberOfLines={1}
329 style={{ color: mutedColor, fontVariant: ["tabular-nums"] }}
330 >
331 {timestamp}
332 </Text>
333 </View>
334 </View>
335 {subtitleRow}
336 </View>
337 </Pressable>
338 );
339
340 return (
341 <ControlPillMenu
342 actions={PENDING_TASK_MENU_ACTIONS}
343 onPressAction={handleMenuAction}
344 shouldOpenOnLongPress
345 >
346 {rowContent}
347 </ControlPillMenu>
348 );
349});
350
183351/* ─── Thread row ─────────────────────────────────────────────────────── */
184352
185353const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
apps/mobile/src/features/threads/use-project-actions.ts+22-51
......@@ -4,8 +4,6 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
44import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
55import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime";
66import {
7 CommandId,
8 MessageId,
97 ThreadId,
108 type ModelSelection,
119 type ProviderInteractionMode,
......@@ -17,22 +15,13 @@ import { AsyncResult } from "effect/unstable/reactivity";
1715
1816import { threadEnvironment } from "../../state/threads";
1917import type { DraftComposerImageAttachment } from "../../lib/composerImages";
20import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
21import { uuidv4 } from "../../lib/uuid";
18import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
19import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
20import { randomHex } from "../../lib/uuid";
2221import { useAtomCommand } from "../../state/use-atom-command";
2322import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
2423import { validateProjectThreadCreation } from "./projectThreadCreationValidation";
2524
26function deriveThreadTitleFromPrompt(value: string): string {
27 const trimmed = value.trim();
28 if (trimmed.length === 0) {
29 return "New thread";
30 }
31
32 const compact = trimmed.replace(/\s+/g, " ");
33 return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
34}
35
3625export function useCreateProjectThread() {
3726 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
3827
......@@ -43,15 +32,17 @@ export function useCreateProjectThread() {
4332 readonly envMode: "local" | "worktree";
4433 readonly branch: string | null;
4534 readonly worktreePath: string | null;
35 readonly startFromOrigin?: boolean;
4636 readonly runtimeMode: RuntimeMode;
4737 readonly interactionMode: ProviderInteractionMode;
4838 readonly initialMessageText: string;
4939 readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>;
40 /** Reuse identifiers from a queued pending task instead of minting new ones. */
41 readonly turnMetadata?: TurnCommandMetadata;
5042 }) => {
51 const metadata = makeTurnCommandMetadata();
43 const metadata = input.turnMetadata ?? makeTurnCommandMetadata();
5244 const threadId = ThreadId.make(metadata.threadId);
5345 const initialMessageText = input.initialMessageText.trim();
54 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
5546
5647 const validationError = validateProjectThreadCreation({
5748 environmentId: input.project.environmentId,
......@@ -65,46 +56,26 @@ export function useCreateProjectThread() {
6556 return AsyncResult.failure(Cause.fail(validationError));
6657 }
6758
68 const isWorktree = input.envMode === "worktree";
6959 const result = await startTurn({
7060 environmentId: input.project.environmentId,
71 input: {
72 commandId: CommandId.make(metadata.commandId),
73 threadId,
74 message: {
75 messageId: MessageId.make(metadata.messageId),
76 role: "user",
77 text: initialMessageText,
78 attachments: input.initialAttachments,
79 },
61 input: buildProjectThreadStartTurnInput({
62 projectId: input.project.id,
63 projectCwd: input.project.workspaceRoot,
64 threadId: metadata.threadId,
65 commandId: metadata.commandId,
66 messageId: metadata.messageId,
67 createdAt: metadata.createdAt,
68 text: initialMessageText,
69 attachments: input.initialAttachments,
8070 modelSelection: input.modelSelection,
81 titleSeed: nextTitle,
8271 runtimeMode: input.runtimeMode,
8372 interactionMode: input.interactionMode,
84 bootstrap: {
85 createThread: {
86 projectId: input.project.id,
87 title: nextTitle,
88 modelSelection: input.modelSelection,
89 runtimeMode: input.runtimeMode,
90 interactionMode: input.interactionMode,
91 branch: input.branch,
92 worktreePath: isWorktree ? null : input.worktreePath,
93 createdAt: metadata.createdAt,
94 },
95 ...(isWorktree
96 ? {
97 prepareWorktree: {
98 projectCwd: input.project.workspaceRoot,
99 baseBranch: input.branch!,
100 branch: buildTemporaryWorktreeBranchName(uuidv4),
101 },
102 runSetupScript: true,
103 }
104 : {}),
105 },
106 createdAt: metadata.createdAt,
107 },
73 workspaceMode: input.envMode,
74 branch: input.branch,
75 worktreePath: input.worktreePath,
76 startFromOrigin: input.startFromOrigin ?? false,
77 worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
78 }),
10879 });
10980 if (AsyncResult.isFailure(result)) {
11081 const error = Cause.squash(result.cause);
apps/mobile/src/lib/projectThreadStartTurn.ts created+89
......@@ -0,0 +1,89 @@
1import {
2 CommandId,
3 MessageId,
4 ThreadId,
5 type ModelSelection,
6 type ProjectId,
7 type ProviderInteractionMode,
8 type RuntimeMode,
9} from "@t3tools/contracts";
10
11import type { DraftComposerImageAttachment } from "./composerImages";
12
13export function deriveThreadTitleFromPrompt(value: string): string {
14 const trimmed = value.trim();
15 if (trimmed.length === 0) {
16 return "New thread";
17 }
18
19 const compact = trimmed.replace(/\s+/g, " ");
20 return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
21}
22
23export interface ProjectThreadStartTurnSpec {
24 readonly projectId: ProjectId;
25 readonly projectCwd: string;
26 readonly threadId: string;
27 readonly commandId: string;
28 readonly messageId: string;
29 readonly createdAt: string;
30 readonly text: string;
31 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
32 readonly modelSelection: ModelSelection;
33 readonly runtimeMode: RuntimeMode;
34 readonly interactionMode: ProviderInteractionMode;
35 readonly workspaceMode: "local" | "worktree";
36 readonly branch: string | null;
37 readonly worktreePath: string | null;
38 readonly startFromOrigin: boolean;
39 /** Generated temp branch for worktree mode; unused for local mode. */
40 readonly worktreeBranchName: string;
41}
42
43/**
44 * Single source of the `thread.turn.start` bootstrap payload used to create a
45 * thread from a project draft — shared by the immediate send path and the
46 * offline outbox drain so both deliver identical commands.
47 */
48export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) {
49 const title = deriveThreadTitleFromPrompt(spec.text);
50 const isWorktree = spec.workspaceMode === "worktree";
51 return {
52 commandId: CommandId.make(spec.commandId),
53 threadId: ThreadId.make(spec.threadId),
54 message: {
55 messageId: MessageId.make(spec.messageId),
56 role: "user" as const,
57 text: spec.text,
58 attachments: spec.attachments,
59 },
60 modelSelection: spec.modelSelection,
61 titleSeed: title,
62 runtimeMode: spec.runtimeMode,
63 interactionMode: spec.interactionMode,
64 bootstrap: {
65 createThread: {
66 projectId: spec.projectId,
67 title,
68 modelSelection: spec.modelSelection,
69 runtimeMode: spec.runtimeMode,
70 interactionMode: spec.interactionMode,
71 branch: spec.branch,
72 worktreePath: isWorktree ? null : spec.worktreePath,
73 createdAt: spec.createdAt,
74 },
75 ...(isWorktree
76 ? {
77 prepareWorktree: {
78 projectCwd: spec.projectCwd,
79 baseBranch: spec.branch!,
80 branch: spec.worktreeBranchName,
81 ...(spec.startFromOrigin ? { startFromOrigin: true } : {}),
82 },
83 runSetupScript: true,
84 }
85 : {}),
86 },
87 createdAt: spec.createdAt,
88 };
89}
apps/mobile/src/lib/uuid.ts+6
......@@ -1,3 +1,9 @@
11import * as Crypto from "expo-crypto";
22
33export const uuidv4 = () => Crypto.randomUUID();
4
5/** Random lowercase hex string of `byteLength` bytes (2 chars per byte). */
6export const randomHex = (byteLength: number): string =>
7 Array.from(Crypto.getRandomBytes(byteLength), (byte) => byte.toString(16).padStart(2, "0")).join(
8 "",
9 );
apps/mobile/src/state/thread-outbox-manager.ts+31
......@@ -15,6 +15,7 @@ export class ThreadOutboxManagerError extends Schema.TaggedErrorClass<ThreadOutb
1515 operation: Schema.Literals([
1616 "load",
1717 "enqueue",
18 "update",
1819 "remove",
1920 "clear-environment-load",
2021 "clear-environment-remove",
......@@ -103,6 +104,35 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
103104 setMessages([...currentMessages(), message]);
104105 });
105106
107 // Rewrites an already-queued message. A no-op when the message has been
108 // removed in the meantime (e.g. deleted or delivered), so a trailing editor
109 // flush can never resurrect it. Returns whether the message was updated.
110 const update = (message: QueuedThreadMessage): Promise<boolean> =>
111 serialize(async () => {
112 const exists = currentMessages().some(
113 (candidate) => candidate.messageId === message.messageId,
114 );
115 if (!exists) {
116 return false;
117 }
118 try {
119 await options.storage.write(message);
120 } catch (cause) {
121 throw new ThreadOutboxManagerError({
122 operation: "update",
123 environmentId: message.environmentId,
124 threadId: message.threadId,
125 messageId: message.messageId,
126 cause,
127 });
128 }
129 setMessages([
130 ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
131 message,
132 ]);
133 return true;
134 });
135
106136 const remove = (message: QueuedThreadMessage): Promise<void> =>
107137 serialize(async () => {
108138 try {
......@@ -171,6 +201,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
171201 serialize,
172202 load,
173203 enqueue,
204 update,
174205 remove,
175206 clearEnvironment,
176207 };
apps/mobile/src/state/thread-outbox-model.ts+56-2
......@@ -6,10 +6,12 @@ import {
66 IsoDateTime,
77 MessageId,
88 ModelSelection,
9 ProjectId,
910 ProviderInteractionMode,
1011 RuntimeMode,
1112 ThreadId,
1213 type ModelSelection as ModelSelectionType,
14 type ProjectId as ProjectIdType,
1315 type ProviderInteractionMode as ProviderInteractionModeType,
1416 type RuntimeMode as RuntimeModeType,
1517} from "@t3tools/contracts";
......@@ -19,11 +21,23 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema
1921import type { DraftComposerImageAttachment } from "../lib/composerImages";
2022import { scopedThreadKey } from "../lib/scopedEntities";
2123
22const THREAD_OUTBOX_SCHEMA_VERSION = 2;
24const THREAD_OUTBOX_SCHEMA_VERSION = 3;
2325const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
2426
27const QueuedThreadCreationSchema = Schema.Struct({
28 projectId: ProjectId,
29 // Snapshot of the project's display metadata so a pending task stays
30 // presentable in the thread list even when the project shell is not loaded.
31 projectTitle: Schema.optional(Schema.String),
32 projectCwd: Schema.optional(Schema.String),
33 workspaceMode: Schema.Literals(["local", "worktree"]),
34 branch: Schema.NullOr(Schema.String),
35 worktreePath: Schema.NullOr(Schema.String),
36 startFromOrigin: Schema.optional(Schema.Boolean),
37});
38
2539export const QueuedThreadMessageSchema = Schema.Struct({
26 schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]),
40 schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),
2741 environmentId: EnvironmentId,
2842 threadId: ThreadId,
2943 messageId: MessageId,
......@@ -33,12 +47,25 @@ export const QueuedThreadMessageSchema = Schema.Struct({
3347 modelSelection: Schema.optional(ModelSelection),
3448 runtimeMode: Schema.optional(RuntimeMode),
3549 interactionMode: Schema.optional(ProviderInteractionMode),
50 // Present when the queued item creates a brand-new thread (pending task)
51 // instead of appending a turn to an existing one.
52 creation: Schema.optional(QueuedThreadCreationSchema),
3653 createdAt: IsoDateTime,
3754});
3855
3956const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema);
4057const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema);
4158
59export interface QueuedThreadCreation {
60 readonly projectId: ProjectIdType;
61 readonly projectTitle?: string;
62 readonly projectCwd?: string;
63 readonly workspaceMode: "local" | "worktree";
64 readonly branch: string | null;
65 readonly worktreePath: string | null;
66 readonly startFromOrigin?: boolean;
67}
68
4269export interface QueuedThreadMessage {
4370 readonly environmentId: EnvironmentId;
4471 readonly threadId: ThreadId;
......@@ -49,6 +76,7 @@ export interface QueuedThreadMessage {
4976 readonly modelSelection?: ModelSelectionType;
5077 readonly runtimeMode?: RuntimeModeType;
5178 readonly interactionMode?: ProviderInteractionModeType;
79 readonly creation?: QueuedThreadCreation;
5280 readonly createdAt: string;
5381}
5482
......@@ -121,17 +149,43 @@ export function threadOutboxRetryDelayMs(attempt: number): number {
121149export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";
122150
123151export function resolveThreadOutboxDeliveryAction(input: {
152 readonly isCreation: boolean;
124153 readonly threadExists: boolean;
125154 readonly shellStatus: EnvironmentShellStatus;
126155 readonly environmentConnected: boolean;
127156 readonly threadBusy: boolean;
128157}): ThreadOutboxDeliveryAction {
158 if (input.isCreation) {
159 // A pending task creates its thread on delivery. If the thread already
160 // exists the creation command went through and only cleanup remains.
161 if (input.threadExists) {
162 return "remove";
163 }
164 // Wait for the shell to be live before sending: until the thread list has
165 // synchronized, a previously delivered creation whose cleanup failed would
166 // look missing and get re-issued, duplicating the thread.
167 return input.environmentConnected && input.shellStatus === "live" ? "send" : "wait";
168 }
129169 if (!input.threadExists) {
130170 return input.shellStatus === "live" ? "remove" : "wait";
131171 }
132172 return input.environmentConnected && !input.threadBusy ? "send" : "wait";
133173}
134174
175/**
176 * A queued creation can only be dispatched once its payload would pass server
177 * validation; incomplete payloads stay pending until the user edits them.
178 */
179export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): boolean {
180 if (!message.creation) {
181 return false;
182 }
183 if (message.text.trim().length === 0 || message.modelSelection === undefined) {
184 return false;
185 }
186 return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch);
187}
188
135189function errorMessage(error: unknown): string | null {
136190 if (error instanceof Error) {
137191 return error.message;
apps/mobile/src/state/thread-outbox.test.ts+121
......@@ -3,6 +3,7 @@ import {
33 CommandId,
44 EnvironmentId,
55 MessageId,
6 ProjectId,
67 ProviderInstanceId,
78 ThreadId,
89} from "@t3tools/contracts";
......@@ -12,6 +13,7 @@ import {
1213 decodeQueuedThreadMessage,
1314 encodeQueuedThreadMessage,
1415 groupQueuedThreadMessages,
16 isQueuedThreadCreationSendable,
1517 modelSelectionsEqual,
1618 resolveThreadOutboxDeliveryAction,
1719 resolveThreadOutboxFailureAction,
......@@ -292,9 +294,43 @@ describe("thread outbox", () => {
292294 registry.dispose();
293295 });
294296
297 it("updates a queued message in place but never resurrects a removed one", async () => {
298 const registry = AtomRegistry.make();
299 const stored = new Map<MessageId, QueuedThreadMessage>();
300 const storage: ThreadOutboxStorage = {
301 load: async () => [...stored.values()],
302 write: async (message) => {
303 stored.set(message.messageId, message);
304 },
305 remove: async (message) => {
306 stored.delete(message.messageId);
307 },
308 };
309 const manager = createThreadOutboxManager({ registry, storage });
310 const message = queuedMessage({
311 messageId: "message-1",
312 createdAt: "2026-06-08T10:00:01.000Z",
313 });
314
315 await manager.enqueue(message);
316 const edited = { ...message, text: "edited" };
317 await expect(manager.update(edited)).resolves.toBe(true);
318 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
319 "environment-1:thread-1": [edited],
320 });
321 expect(stored.get(message.messageId)).toEqual(edited);
322
323 await manager.remove(edited);
324 await expect(manager.update({ ...message, text: "stale flush" })).resolves.toBe(false);
325 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
326 expect(stored.size).toBe(0);
327 registry.dispose();
328 });
329
295330 it("only removes a missing-thread message after shell synchronization is live", () => {
296331 expect(
297332 resolveThreadOutboxDeliveryAction({
333 isCreation: false,
298334 threadExists: false,
299335 shellStatus: "synchronizing",
300336 environmentConnected: true,
......@@ -303,6 +339,7 @@ describe("thread outbox", () => {
303339 ).toBe("wait");
304340 expect(
305341 resolveThreadOutboxDeliveryAction({
342 isCreation: false,
306343 threadExists: false,
307344 shellStatus: "live",
308345 environmentConnected: true,
......@@ -311,6 +348,7 @@ describe("thread outbox", () => {
311348 ).toBe("remove");
312349 expect(
313350 resolveThreadOutboxDeliveryAction({
351 isCreation: false,
314352 threadExists: true,
315353 shellStatus: "live",
316354 environmentConnected: true,
......@@ -319,6 +357,89 @@ describe("thread outbox", () => {
319357 ).toBe("send");
320358 });
321359
360 it("sends queued creations once connected and live, removing already-created ones", () => {
361 expect(
362 resolveThreadOutboxDeliveryAction({
363 isCreation: true,
364 threadExists: false,
365 shellStatus: "cached",
366 environmentConnected: false,
367 threadBusy: false,
368 }),
369 ).toBe("wait");
370 // Connected but not yet synchronized: a previously delivered creation may
371 // simply not be visible yet — sending now could duplicate the thread.
372 expect(
373 resolveThreadOutboxDeliveryAction({
374 isCreation: true,
375 threadExists: false,
376 shellStatus: "synchronizing",
377 environmentConnected: true,
378 threadBusy: false,
379 }),
380 ).toBe("wait");
381 expect(
382 resolveThreadOutboxDeliveryAction({
383 isCreation: true,
384 threadExists: false,
385 shellStatus: "live",
386 environmentConnected: true,
387 threadBusy: false,
388 }),
389 ).toBe("send");
390 expect(
391 resolveThreadOutboxDeliveryAction({
392 isCreation: true,
393 threadExists: true,
394 shellStatus: "live",
395 environmentConnected: true,
396 threadBusy: true,
397 }),
398 ).toBe("remove");
399 });
400
401 it("round-trips queued creations and gates incomplete ones from sending", () => {
402 const base = queuedMessage({
403 messageId: "message-1",
404 createdAt: "2026-06-08T10:00:01.000Z",
405 });
406 const creationMessage = {
407 ...base,
408 modelSelection: {
409 instanceId: ProviderInstanceId.make("codex"),
410 model: "gpt-5.4",
411 },
412 creation: {
413 projectId: ProjectId.make("project-1"),
414 workspaceMode: "worktree",
415 branch: "main",
416 worktreePath: null,
417 startFromOrigin: true,
418 },
419 } satisfies QueuedThreadMessage;
420
421 expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(creationMessage))).toEqual(
422 creationMessage,
423 );
424 expect(isQueuedThreadCreationSendable(creationMessage)).toBe(true);
425 expect(
426 isQueuedThreadCreationSendable({
427 ...creationMessage,
428 creation: { ...creationMessage.creation, branch: null },
429 }),
430 ).toBe(false);
431 expect(
432 isQueuedThreadCreationSendable({
433 ...creationMessage,
434 creation: { ...creationMessage.creation, branch: "" },
435 }),
436 ).toBe(false);
437 expect(isQueuedThreadCreationSendable({ ...creationMessage, modelSelection: undefined })).toBe(
438 false,
439 );
440 expect(isQueuedThreadCreationSendable(base)).toBe(false);
441 });
442
322443 it("retries transport failures but drops deterministic command failures", () => {
323444 expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true);
324445 expect(
apps/mobile/src/state/thread-outbox.ts+5
......@@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis
2020 return threadOutboxManager.enqueue(message);
2121}
2222
23/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */
24export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> {
25 return threadOutboxManager.update(message);
26}
27
2328export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {
2429 return threadOutboxManager.remove(message);
2530}
apps/mobile/src/state/use-composer-drafts.ts+6
......@@ -48,6 +48,7 @@ export interface ComposerDraftWorkspaceSelection {
4848 readonly mode: "local" | "worktree";
4949 readonly branch: string | null;
5050 readonly worktreePath: string | null;
51 readonly startFromOrigin?: boolean;
5152}
5253
5354export type ComposerDraftSettingsUpdate = Pick<
......@@ -59,6 +60,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
5960 mode: Schema.Literals(["local", "worktree"]),
6061 branch: Schema.NullOr(Schema.String),
6162 worktreePath: Schema.NullOr(Schema.String),
63 startFromOrigin: Schema.optional(Schema.Boolean),
6264});
6365
6466const ComposerDraftSchema = Schema.Struct({
......@@ -107,6 +109,10 @@ export function getComposerDraftSnapshot(draftKey: string): ComposerDraft {
107109 return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]);
108110}
109111
112export function isComposerDraftEmpty(draft: ComposerDraft): boolean {
113 return isEmptyDraft(draft);
114}
115
110116function isEmptyDraft(draft: ComposerDraft): boolean {
111117 return (
112118 draft.text.length === 0 &&
apps/mobile/src/state/use-pending-new-tasks.ts created+35
......@@ -0,0 +1,35 @@
1import { useMemo } from "react";
2
3import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
4import {
5 flattenQueuedThreadMessages,
6 type QueuedThreadCreation,
7 type QueuedThreadMessage,
8} from "./thread-outbox-model";
9import { useThreadOutboxMessages } from "./use-thread-outbox";
10
11/** A queued new-task creation, shaped for thread-list presentation. */
12export interface PendingNewTask {
13 readonly message: QueuedThreadMessage;
14 readonly creation: QueuedThreadCreation;
15 readonly title: string;
16}
17
18export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {
19 const queuedMessagesByThreadKey = useThreadOutboxMessages();
20 return useMemo(() => {
21 const tasks: PendingNewTask[] = [];
22 for (const message of flattenQueuedThreadMessages(queuedMessagesByThreadKey)) {
23 if (!message.creation) {
24 continue;
25 }
26 tasks.push({
27 message,
28 creation: message.creation,
29 title: deriveThreadTitleFromPrompt(message.text),
30 });
31 }
32 tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt));
33 return tasks;
34 }, [queuedMessagesByThreadKey]);
35}
apps/mobile/src/state/use-thread-outbox-drain.ts+174-62
......@@ -1,27 +1,44 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import type {
3 EnvironmentProject,
4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";
36import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import { CommandId, type MessageId } from "@t3tools/contracts";
7import {
8 CommandId,
9 DEFAULT_PROVIDER_INTERACTION_MODE,
10 DEFAULT_RUNTIME_MODE,
11 type MessageId,
12} from "@t3tools/contracts";
13import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
514import * as Cause from "effect/Cause";
615import { AsyncResult, Atom } from "effect/unstable/reactivity";
716import { useCallback, useEffect, useRef, useState } from "react";
817
918import { scopedThreadKey } from "../lib/scopedEntities";
19import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
20import { randomHex } from "../lib/uuid";
1021import { appAtomRegistry } from "./atom-registry";
11import { useThreadShells } from "./entities";
22import { useProjects, useThreadShells } from "./entities";
1223import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";
1324import {
25 isQueuedThreadCreationSendable,
1426 modelSelectionsEqual,
1527 resolveThreadOutboxDeliveryAction,
1628 resolveThreadOutboxFailureAction,
1729 resolveQueuedThreadSettings,
1830 threadOutboxRetryDelayMs,
31 type QueuedThreadCreation,
1932 type QueuedThreadMessage,
2033 type ThreadOutboxCommandStage,
2134} from "./thread-outbox-model";
2235import { threadEnvironment } from "./threads";
2336import { useAtomCommand } from "./use-atom-command";
24import { useThreadOutboxMessages, useThreadOutboxShellStatuses } from "./use-thread-outbox";
37import {
38 editingQueuedMessageIdsAtom,
39 useThreadOutboxMessages,
40 useThreadOutboxShellStatuses,
41} from "./use-thread-outbox";
2542import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
2643
2744export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
......@@ -48,6 +65,17 @@ function findThread(
4865 );
4966}
5067
68function findCreationProject(
69 projects: ReadonlyArray<EnvironmentProject>,
70 message: QueuedThreadMessage,
71): EnvironmentProject | undefined {
72 return projects.find(
73 (candidate) =>
74 candidate.environmentId === message.environmentId &&
75 candidate.id === message.creation?.projectId,
76 );
77}
78
5179function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId {
5280 return CommandId.make(`${message.commandId}:${setting}`);
5381}
......@@ -64,9 +92,11 @@ export function useThreadOutboxDrain(): void {
6492 reportFailure: false,
6593 });
6694 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
95 const editingQueuedMessageIds = useAtomValue(editingQueuedMessageIdsAtom);
6796 const queuedMessagesByThreadKey = useThreadOutboxMessages();
6897 const shellStatuses = useThreadOutboxShellStatuses();
6998 const threads = useThreadShells();
99 const projects = useProjects();
70100 const { connectedEnvironments } = useRemoteConnectionStatus();
71101 const [retryTick, setRetryTick] = useState(0);
72102 const retryAttemptRef = useRef(new Map<MessageId, number>());
......@@ -83,52 +113,57 @@ export function useThreadOutboxDrain(): void {
83113 };
84114 }, []);
85115
86 const sendQueuedMessage = useCallback(
87 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
88 const settings = resolveQueuedThreadSettings(queuedMessage, thread);
89 const reportFailure = (
90 commandResult: AtomCommandResult<unknown, unknown>,
91 stage: ThreadOutboxCommandStage,
92 ): boolean => {
93 if (!AsyncResult.isFailure(commandResult)) {
94 return false;
95 }
96 const action = resolveThreadOutboxFailureAction({
97 stage,
98 error: Cause.squash(commandResult.cause),
99 interrupted: Cause.hasInterruptsOnly(commandResult.cause),
100 });
101 const retry = action === "retry";
102 console.warn("[thread-outbox] queued message delivery failed", {
116 const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => {
117 const reportFailure = (
118 commandResult: AtomCommandResult<unknown, unknown>,
119 stage: ThreadOutboxCommandStage,
120 ): boolean => {
121 if (!AsyncResult.isFailure(commandResult)) {
122 return false;
123 }
124 const action = resolveThreadOutboxFailureAction({
125 stage,
126 error: Cause.squash(commandResult.cause),
127 interrupted: Cause.hasInterruptsOnly(commandResult.cause),
128 });
129 const retry = action === "retry";
130 console.warn("[thread-outbox] queued message delivery failed", {
131 environmentId: queuedMessage.environmentId,
132 threadId: queuedMessage.threadId,
133 messageId: queuedMessage.messageId,
134 stage,
135 cause: commandResult.cause,
136 retry,
137 });
138 return retry;
139 };
140 const completeDelivery = async (
141 deliveryResult: AtomCommandResult<unknown, unknown>,
142 ): Promise<boolean> => {
143 if (reportFailure(deliveryResult, "start-turn")) {
144 return false;
145 }
146
147 try {
148 await removeThreadOutboxMessage(queuedMessage);
149 return true;
150 } catch (error) {
151 console.warn("[thread-outbox] failed to remove delivered queued message", {
103152 environmentId: queuedMessage.environmentId,
104153 threadId: queuedMessage.threadId,
105154 messageId: queuedMessage.messageId,
106 stage,
107 cause: commandResult.cause,
108 retry,
155 error,
109156 });
110 return retry;
111 };
112 const completeDelivery = async (
113 deliveryResult: AtomCommandResult<unknown, unknown>,
114 ): Promise<boolean> => {
115 if (reportFailure(deliveryResult, "start-turn")) {
116 return false;
117 }
157 return false;
158 }
159 };
160 return { reportFailure, completeDelivery };
161 }, []);
118162
119 try {
120 await removeThreadOutboxMessage(queuedMessage);
121 return true;
122 } catch (error) {
123 console.warn("[thread-outbox] failed to remove delivered queued message", {
124 environmentId: queuedMessage.environmentId,
125 threadId: queuedMessage.threadId,
126 messageId: queuedMessage.messageId,
127 error,
128 });
129 return false;
130 }
131 };
163 const sendQueuedMessage = useCallback(
164 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
165 const settings = resolveQueuedThreadSettings(queuedMessage, thread);
166 const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage);
132167
133168 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
134169 const updateResult = await updateThreadMetadata({
......@@ -196,7 +231,50 @@ export function useThreadOutboxDrain(): void {
196231 });
197232 return completeDelivery(deliveryResult);
198233 },
199 [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata],
234 [
235 makeDeliveryHelpers,
236 setThreadInteractionMode,
237 setThreadRuntimeMode,
238 startTurn,
239 updateThreadMetadata,
240 ],
241 );
242
243 const sendQueuedCreation = useCallback(
244 async (
245 queuedMessage: QueuedThreadMessage,
246 creation: QueuedThreadCreation,
247 projectCwd: string,
248 ) => {
249 const modelSelection = queuedMessage.modelSelection;
250 if (modelSelection === undefined) {
251 return false;
252 }
253 const { completeDelivery } = makeDeliveryHelpers(queuedMessage);
254 const deliveryResult = await startTurn({
255 environmentId: queuedMessage.environmentId,
256 input: buildProjectThreadStartTurnInput({
257 projectId: creation.projectId,
258 projectCwd,
259 threadId: queuedMessage.threadId,
260 commandId: queuedMessage.commandId,
261 messageId: queuedMessage.messageId,
262 createdAt: queuedMessage.createdAt,
263 text: queuedMessage.text.trim(),
264 attachments: queuedMessage.attachments,
265 modelSelection,
266 runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,
267 interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
268 workspaceMode: creation.workspaceMode,
269 branch: creation.branch,
270 worktreePath: creation.worktreePath,
271 startFromOrigin: creation.startFromOrigin ?? false,
272 worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
273 }),
274 });
275 return completeDelivery(deliveryResult);
276 },
277 [makeDeliveryHelpers, startTurn],
200278 );
201279
202280 useEffect(() => {
......@@ -209,6 +287,9 @@ export function useThreadOutboxDrain(): void {
209287 if (!nextQueuedMessage) {
210288 continue;
211289 }
290 if (editingQueuedMessageIds[nextQueuedMessage.messageId]) {
291 continue;
292 }
212293 if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
213294 continue;
214295 }
......@@ -218,37 +299,65 @@ export function useThreadOutboxDrain(): void {
218299 continue;
219300 }
220301
302 const creation = nextQueuedMessage.creation;
221303 const environment = connectedEnvironments.find(
222304 (candidate) => candidate.environmentId === nextQueuedMessage.environmentId,
223305 );
306 const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty";
224307 const deliveryAction = resolveThreadOutboxDeliveryAction({
308 isCreation: creation !== undefined,
225309 threadExists: thread !== undefined,
226 shellStatus: shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty",
310 shellStatus,
227311 environmentConnected: environment?.connectionState === "connected",
228312 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
229313 });
230314 if (deliveryAction === "wait") {
231315 continue;
232316 }
317 // The live project shell is preferred for the workspace path, with the
318 // snapshot taken at enqueue time as the fallback so a task never dies
319 // just because its project shell is not loaded.
320 const creationProjectCwd =
321 creation !== undefined
322 ? (findCreationProject(projects, nextQueuedMessage)?.workspaceRoot ??
323 creation.projectCwd ??
324 null)
325 : null;
326 // An incomplete pending task (e.g. worktree mode without a branch) stays
327 // queued until the user finishes it in the editor.
328 if (deliveryAction === "send" && creation !== undefined) {
329 if (!isQueuedThreadCreationSendable(nextQueuedMessage)) {
330 continue;
331 }
332 if (creationProjectCwd === null && shellStatus !== "live") {
333 continue;
334 }
335 }
233336
234337 beginDispatchingQueuedMessage(nextQueuedMessage.messageId);
338 const removeQueuedMessage = (warning: string) =>
339 removeThreadOutboxMessage(nextQueuedMessage).then(
340 () => true,
341 (error) => {
342 console.warn(warning, {
343 environmentId: nextQueuedMessage.environmentId,
344 threadId: nextQueuedMessage.threadId,
345 messageId: nextQueuedMessage.messageId,
346 error,
347 });
348 return false;
349 },
350 );
235351 const delivery =
236352 deliveryAction === "remove"
237 ? removeThreadOutboxMessage(nextQueuedMessage).then(
238 () => true,
239 (error) => {
240 console.warn("[thread-outbox] failed to remove message for a missing thread", {
241 environmentId: nextQueuedMessage.environmentId,
242 threadId: nextQueuedMessage.threadId,
243 messageId: nextQueuedMessage.messageId,
244 error,
245 });
246 return false;
247 },
248 )
249 : thread !== undefined
250 ? sendQueuedMessage(nextQueuedMessage, thread)
251 : Promise.resolve(false);
353 ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
354 : creation !== undefined
355 ? creationProjectCwd !== null
356 ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd)
357 : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project")
358 : thread !== undefined
359 ? sendQueuedMessage(nextQueuedMessage, thread)
360 : Promise.resolve(false);
252361 void delivery
253362 .then((sent) => {
254363 if (sent) {
......@@ -284,8 +393,11 @@ export function useThreadOutboxDrain(): void {
284393 }, [
285394 connectedEnvironments,
286395 dispatchingQueuedMessageId,
396 editingQueuedMessageIds,
397 projects,
287398 queuedMessagesByThreadKey,
288399 retryTick,
400 sendQueuedCreation,
289401 sendQueuedMessage,
290402 shellStatuses,
291403 threads,
apps/mobile/src/state/use-thread-outbox.ts+31-1
......@@ -1,8 +1,9 @@
11import { useAtomValue } from "@effect/atom-react";
22import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId } from "@t3tools/contracts";
3import type { EnvironmentId, MessageId } from "@t3tools/contracts";
44import { Atom } from "effect/unstable/reactivity";
55
6import { appAtomRegistry } from "./atom-registry";
67import { environmentShell } from "./shell";
78import { threadOutboxManager } from "./thread-outbox";
89
......@@ -19,6 +20,35 @@ const threadOutboxShellStatusesAtom = Atom.make(
1920 },
2021).pipe(Atom.withLabel("mobile:thread-outbox:shell-statuses"));
2122
23/**
24 * Queued pending tasks the outbox drain must not deliver right now: the one
25 * open in the new-task editor, plus any whose latest edits could not be saved
26 * back yet (delivering those would send stale content). Editing sessions hold
27 * their message id here and release it once the queued payload is current.
28 */
29export const editingQueuedMessageIdsAtom = Atom.make<Readonly<Record<MessageId, true>>>({}).pipe(
30 Atom.keepAlive,
31 Atom.withLabel("mobile:thread-outbox:editing-message-ids"),
32);
33
34export function holdEditingQueuedMessage(messageId: MessageId): void {
35 const current = appAtomRegistry.get(editingQueuedMessageIdsAtom);
36 if (current[messageId]) {
37 return;
38 }
39 appAtomRegistry.set(editingQueuedMessageIdsAtom, { ...current, [messageId]: true });
40}
41
42export function releaseEditingQueuedMessage(messageId: MessageId): void {
43 const current = appAtomRegistry.get(editingQueuedMessageIdsAtom);
44 if (!current[messageId]) {
45 return;
46 }
47 const next = { ...current };
48 delete next[messageId];
49 appAtomRegistry.set(editingQueuedMessageIdsAtom, next);
50}
51
2252export function useThreadOutboxMessages() {
2353 return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom);
2454}