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";...@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
55
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { useProjects, useThreadShells } from "../../state/entities";7import { useProjects, useThreadShells } from "../../state/entities";
8import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
8import { useWorkspaceState } from "../../state/workspace";9import { useWorkspaceState } from "../../state/workspace";
9import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";10import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
10import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";11import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
...@@ -13,6 +14,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";...@@ -13,6 +14,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
13import { HomeScreen } from "./HomeScreen";14import { HomeScreen } from "./HomeScreen";
14import { HomeHeader } from "./HomeHeader";15import { HomeHeader } from "./HomeHeader";
15import { useHomeListOptions } from "./home-list-options";16import { useHomeListOptions } from "./home-list-options";
17import { usePendingTaskListActions } from "./usePendingTaskListActions";
16import { useThreadListActions } from "./useThreadListActions";18import { useThreadListActions } from "./useThreadListActions";
1719
18/* ─── Route screen ───────────────────────────────────────────────────── */20/* ─── Route screen ───────────────────────────────────────────────────── */
...@@ -26,6 +28,8 @@ export function HomeRouteScreen() {...@@ -26,6 +28,8 @@ export function HomeRouteScreen() {
26 const navigation = useNavigation();28 const navigation = useNavigation();
27 const [searchQuery, setSearchQuery] = useState("");29 const [searchQuery, setSearchQuery] = useState("");
28 const { archiveThread, confirmDeleteThread } = useThreadListActions();30 const { archiveThread, confirmDeleteThread } = useThreadListActions();
31 const pendingTasks = usePendingNewTasks();
32 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
29 const environments = useMemo(33 const environments = useMemo(
30 () =>34 () =>
31 Arr.sort(35 Arr.sort(
...@@ -116,8 +120,11 @@ export function HomeRouteScreen() {...@@ -116,8 +120,11 @@ export function HomeRouteScreen() {
116 threadId: thread.id,120 threadId: thread.id,
117 });121 });
118 }}122 }}
123 onSelectPendingTask={openPendingTask}
124 onDeletePendingTask={confirmDeletePendingTask}
119 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}125 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
120 onThreadSortOrderChange={setThreadSortOrder}126 onThreadSortOrderChange={setThreadSortOrder}
127 pendingTasks={pendingTasks}
121 projectGroupingMode={listOptions.projectGroupingMode}128 projectGroupingMode={listOptions.projectGroupingMode}
122 projects={projects}129 projects={projects}
123 projectSortOrder={listOptions.projectSortOrder}130 projectSortOrder={listOptions.projectSortOrder}
apps/mobile/src/features/home/HomeScreen.tsx+26-2
...@@ -22,7 +22,9 @@ import { EmptyState } from "../../components/EmptyState";...@@ -22,7 +22,9 @@ import { EmptyState } from "../../components/EmptyState";
22import type { WorkspaceState } from "../../state/workspaceModel";22import type { WorkspaceState } from "../../state/workspaceModel";
23import type { SavedRemoteConnection } from "../../lib/connection";23import type { SavedRemoteConnection } from "../../lib/connection";
24import { scopedProjectKey } from "../../lib/scopedEntities";24import { scopedProjectKey } from "../../lib/scopedEntities";
25import type { PendingNewTask } from "../../state/use-pending-new-tasks";
25import {26import {
27 PendingTaskListRow,
26 ThreadListGroupHeader,28 ThreadListGroupHeader,
27 ThreadListRow,29 ThreadListRow,
28 ThreadListShowMoreRow,30 ThreadListShowMoreRow,
...@@ -47,6 +49,7 @@ import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-stat...@@ -47,6 +49,7 @@ import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-stat
47interface HomeScreenProps {49interface HomeScreenProps {
48 readonly projects: ReadonlyArray<EnvironmentProject>;50 readonly projects: ReadonlyArray<EnvironmentProject>;
49 readonly threads: ReadonlyArray<EnvironmentThreadShell>;51 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
52 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
50 readonly catalogState: WorkspaceState;53 readonly catalogState: WorkspaceState;
51 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;54 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
52 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;55 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
...@@ -67,6 +70,8 @@ interface HomeScreenProps {...@@ -67,6 +70,8 @@ interface HomeScreenProps {
67 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;70 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
68 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;71 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
69 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;72 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
73 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
74 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
70}75}
7176
72/* ─── Layout constants ───────────────────────────────────────────────── */77/* ─── Layout constants ───────────────────────────────────────────────── */
...@@ -189,6 +194,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -189,6 +194,7 @@ export function HomeScreen(props: HomeScreenProps) {
189 buildHomeThreadGroups({194 buildHomeThreadGroups({
190 projects: props.projects,195 projects: props.projects,
191 threads: props.threads,196 threads: props.threads,
197 pendingTasks: props.pendingTasks,
192 environmentId: props.selectedEnvironmentId,198 environmentId: props.selectedEnvironmentId,
193 searchQuery: props.searchQuery,199 searchQuery: props.searchQuery,
194 projectSortOrder: props.projectSortOrder,200 projectSortOrder: props.projectSortOrder,
...@@ -196,6 +202,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -196,6 +202,7 @@ export function HomeScreen(props: HomeScreenProps) {
196 projectGroupingMode: props.projectGroupingMode,202 projectGroupingMode: props.projectGroupingMode,
197 }),203 }),
198 [204 [
205 props.pendingTasks,
199 props.projectGroupingMode,206 props.projectGroupingMode,
200 props.projects,207 props.projects,
201 props.projectSortOrder,208 props.projectSortOrder,
...@@ -242,10 +249,24 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -242,10 +249,24 @@ export function HomeScreen(props: HomeScreenProps) {
242 groupKey={item.group.key}249 groupKey={item.group.key}
243 onGroupAction={updateGroupDisplay}250 onGroupAction={updateGroupDisplay}
244 project={item.group.representative}251 project={item.group.representative}
245 threadCount={item.group.threads.length}252 threadCount={item.group.threads.length + item.group.pendingTasks.length}
246 title={item.group.title}253 title={item.group.title}
247 />254 />
248 );255 );
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 );
249 case "thread": {270 case "thread": {
250 const thread = item.thread;271 const thread = item.thread;
251 return (272 return (
...@@ -285,7 +306,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -285,7 +306,9 @@ export function HomeScreen(props: HomeScreenProps) {
285 handleSwipeableWillOpen,306 handleSwipeableWillOpen,
286 projectCwdByKey,307 projectCwdByKey,
287 props.onArchiveThread,308 props.onArchiveThread,
309 props.onDeletePendingTask,
288 props.onDeleteThread,310 props.onDeleteThread,
311 props.onSelectPendingTask,
289 props.onSelectThread,312 props.onSelectThread,
290 props.savedConnectionsById,313 props.savedConnectionsById,
291 updateGroupDisplay,314 updateGroupDisplay,
...@@ -295,7 +318,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -295,7 +318,8 @@ export function HomeScreen(props: HomeScreenProps) {
295 const keyExtractor = useCallback((item: HomeListItem) => item.key, []);318 const keyExtractor = useCallback((item: HomeListItem) => item.key, []);
296319
297 /* Empty states */320 /* 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;
299 const hasResults = projectGroups.length > 0;323 const hasResults = projectGroups.length > 0;
300 const selectedEnvironmentLabel =324 const selectedEnvironmentLabel =
301 props.selectedEnvironmentId === null325 props.selectedEnvironmentId === null
apps/mobile/src/features/home/homeListItems.test.ts+1
...@@ -62,6 +62,7 @@ function makeGroup(key: string, threadCount: number): HomeThreadGroup {...@@ -62,6 +62,7 @@ function makeGroup(key: string, threadCount: number): HomeThreadGroup {
62 title: key,62 title: key,
63 representative: project,63 representative: project,
64 projects: [project],64 projects: [project],
65 pendingTasks: [],
65 threads: Array.from({ length: threadCount }, (_, index) =>66 threads: Array.from({ length: threadCount }, (_, index) =>
66 makeThread(`${key}-thread-${index}`, project.id),67 makeThread(`${key}-thread-${index}`, project.id),
67 ),68 ),
apps/mobile/src/features/home/homeListItems.ts+32-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
22
3import type { PendingNewTask } from "../../state/use-pending-new-tasks";
3import type { HomeThreadGroup } from "./homeThreadList";4import type { HomeThreadGroup } from "./homeThreadList";
45
5/** Threads shown per project before the "Show more" affordance appears. */6/** Threads shown per project before the "Show more" affordance appears. */
...@@ -33,6 +34,13 @@ export interface HomeThreadListItem {...@@ -33,6 +34,13 @@ export interface HomeThreadListItem {
33 readonly isLast: boolean;34 readonly isLast: boolean;
34}35}
3536
37export interface HomePendingTaskListItem {
38 readonly type: "pending-task";
39 readonly key: string;
40 readonly pendingTask: PendingNewTask;
41 readonly isLast: boolean;
42}
43
36export interface HomeShowMoreListItem {44export interface HomeShowMoreListItem {
37 readonly type: "show-more";45 readonly type: "show-more";
38 readonly key: string;46 readonly key: string;
...@@ -43,7 +51,11 @@ export interface HomeShowMoreListItem {...@@ -43,7 +51,11 @@ export interface HomeShowMoreListItem {
43 readonly canShowLess: boolean;51 readonly canShowLess: boolean;
44}52}
4553
46export type HomeListItem = HomeHeaderListItem | HomeThreadListItem | HomeShowMoreListItem;54export type HomeListItem =
55 | HomeHeaderListItem
56 | HomePendingTaskListItem
57 | HomeThreadListItem
58 | HomeShowMoreListItem;
4759
48export interface HomeListLayout {60export interface HomeListLayout {
49 readonly items: ReadonlyArray<HomeListItem>;61 readonly items: ReadonlyArray<HomeListItem>;
...@@ -82,6 +94,12 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem...@@ -82,6 +94,12 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem
82 previous.collapsed === item.collapsed &&94 previous.collapsed === item.collapsed &&
83 previous.isFirst === item.isFirst95 previous.isFirst === item.isFirst
84 );96 );
97 case "pending-task":
98 return (
99 previous.type === "pending-task" &&
100 previous.pendingTask === item.pendingTask &&
101 previous.isLast === item.isLast
102 );
85 case "thread":103 case "thread":
86 return (104 return (
87 previous.type === "thread" &&105 previous.type === "thread" &&
...@@ -134,6 +152,19 @@ export function buildHomeListLayout(input: {...@@ -134,6 +152,19 @@ export function buildHomeListLayout(input: {
134 const hiddenCount = totalCount - visibleCount;152 const hiddenCount = totalCount - visibleCount;
135 const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS;153 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
137 for (const [threadIndex, thread] of visibleThreads.entries()) {168 for (const [threadIndex, thread] of visibleThreads.entries()) {
138 items.push({169 items.push({
139 type: "thread",170 type: "thread",
apps/mobile/src/features/home/homeThreadList.ts+57-4
...@@ -17,6 +17,7 @@ import * as Arr from "effect/Array";...@@ -17,6 +17,7 @@ import * as Arr from "effect/Array";
17import * as Order from "effect/Order";17import * as Order from "effect/Order";
1818
19import { scopedProjectKey } from "../../lib/scopedEntities";19import { scopedProjectKey } from "../../lib/scopedEntities";
20import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2021
21export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;22export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
2223
...@@ -25,25 +26,32 @@ export interface HomeThreadGroup {...@@ -25,25 +26,32 @@ export interface HomeThreadGroup {
25 readonly title: string;26 readonly title: string;
26 readonly representative: EnvironmentProject;27 readonly representative: EnvironmentProject;
27 readonly projects: ReadonlyArray<EnvironmentProject>;28 readonly projects: ReadonlyArray<EnvironmentProject>;
29 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
28 readonly threads: ReadonlyArray<EnvironmentThreadShell>;30 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
29}31}
3032
31interface MutableHomeThreadGroup {33interface MutableHomeThreadGroup {
32 readonly key: string;34 readonly key: string;
33 readonly projects: EnvironmentProject[];35 readonly projects: EnvironmentProject[];
36 readonly pendingTasks: PendingNewTask[];
34 readonly threads: EnvironmentThreadShell[];37 readonly threads: EnvironmentThreadShell[];
35}38}
3639
37function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {40function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {
38 return group.threads.reduce(41 const latestThread = group.threads.reduce(
39 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),42 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
40 Number.NEGATIVE_INFINITY,43 Number.NEGATIVE_INFINITY,
41 );44 );
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);
42}49}
4350
44export function buildHomeThreadGroups(input: {51export function buildHomeThreadGroups(input: {
45 readonly projects: ReadonlyArray<EnvironmentProject>;52 readonly projects: ReadonlyArray<EnvironmentProject>;
46 readonly threads: ReadonlyArray<EnvironmentThreadShell>;53 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
54 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
47 readonly environmentId: EnvironmentId | null;55 readonly environmentId: EnvironmentId | null;
48 readonly searchQuery: string;56 readonly searchQuery: string;
49 readonly projectSortOrder: HomeProjectSortOrder;57 readonly projectSortOrder: HomeProjectSortOrder;
...@@ -68,10 +76,49 @@ export function buildHomeThreadGroups(input: {...@@ -68,10 +76,49 @@ export function buildHomeThreadGroups(input: {
68 if (existing) {76 if (existing) {
69 existing.projects.push(project);77 existing.projects.push(project);
70 } else {78 } else {
71 groups.set(groupKey, { key: groupKey, projects: [project], threads: [] });79 groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] });
72 }80 }
73 }81 }
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
75 for (const thread of input.threads) {122 for (const thread of input.threads) {
76 if (thread.archivedAt !== null) {123 if (thread.archivedAt !== null) {
77 continue;124 continue;
...@@ -93,7 +140,7 @@ export function buildHomeThreadGroups(input: {...@@ -93,7 +140,7 @@ export function buildHomeThreadGroups(input: {
93140
94 for (const group of groups.values()) {141 for (const group of groups.values()) {
95 const representative = group.projects[0];142 const representative = group.projects[0];
96 if (!representative || group.threads.length === 0) {143 if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) {
97 continue;144 continue;
98 }145 }
99146
...@@ -108,8 +155,13 @@ export function buildHomeThreadGroups(input: {...@@ -108,8 +155,13 @@ export function buildHomeThreadGroups(input: {
108 const matchingThreads = groupMatches155 const matchingThreads = groupMatches
109 ? group.threads156 ? group.threads
110 : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));157 : 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) {
113 continue;165 continue;
114 }166 }
115167
...@@ -118,6 +170,7 @@ export function buildHomeThreadGroups(input: {...@@ -118,6 +170,7 @@ export function buildHomeThreadGroups(input: {
118 title,170 title,
119 representative,171 representative,
120 projects: group.projects,172 projects: group.projects,
173 pendingTasks: matchingPendingTasks,
121 threads: sortThreads(matchingThreads, input.threadSortOrder),174 threads: sortThreads(matchingThreads, input.threadSortOrder),
122 });175 });
123 }176 }
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 @@...@@ -1,4 +1,5 @@
1import type { StaticScreenProps } from "@react-navigation/native";1import type { StaticScreenProps } from "@react-navigation/native";
2import { useMemo } from "react";
2import { NativeStackScreenOptions } from "../../native/StackHeader";3import { NativeStackScreenOptions } from "../../native/StackHeader";
34
4import { NewTaskDraftScreen } from "./NewTaskDraftScreen";5import { NewTaskDraftScreen } from "./NewTaskDraftScreen";
...@@ -7,11 +8,25 @@ type NewTaskDraftRouteParams = {...@@ -7,11 +8,25 @@ type NewTaskDraftRouteParams = {
7 readonly environmentId?: string | string[];8 readonly environmentId?: string | string[];
8 readonly projectId?: string | string[];9 readonly projectId?: string | string[];
9 readonly title?: string | string[];10 readonly title?: string | string[];
11 readonly pendingTaskId?: string | string[];
10};12};
1113
12export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {14export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) {
13 const params = route.params ?? {};15 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
15 return (30 return (
16 <>31 <>
17 <NativeStackScreenOptions32 <NativeStackScreenOptions
...@@ -20,12 +35,10 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf...@@ -20,12 +35,10 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
20 }}35 }}
21 />36 />
22 <NewTaskDraftScreen37 <NewTaskDraftScreen
23 initialProjectRef={{38 initialProjectRef={initialProjectRef}
24 environmentId: Array.isArray(params.environmentId)39 pendingTaskId={
25 ? params.environmentId[0]40 Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
26 : params.environmentId,41 }
27 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
28 }}
29 />42 />
30 </>43 </>
31 );44 );
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+156-11
...@@ -23,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri...@@ -23,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri
23import { ControlPillMenu } from "../../components/ControlPill";23import { ControlPillMenu } from "../../components/ControlPill";
24import { ProviderIcon } from "../../components/ProviderIcon";24import { ProviderIcon } from "../../components/ProviderIcon";
2525
26import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
26import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";27import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
27import {28import {
28 applyProviderOptionMenuEvent,29 applyProviderOptionMenuEvent,
...@@ -30,10 +31,11 @@ import {...@@ -30,10 +31,11 @@ import {
30 providerOptionsConfigurationLabel,31 providerOptionsConfigurationLabel,
31 resolveProviderOptionDescriptors,32 resolveProviderOptionDescriptors,
32} from "../../lib/providerOptions";33} from "../../lib/providerOptions";
33import { scopedProjectKey } from "../../lib/scopedEntities";
34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
36import { useProjects } from "../../state/entities";36import { useProjects } from "../../state/entities";
37import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
38import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
37import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";39import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
38import { useCreateProjectThread } from "./use-project-actions";40import { useCreateProjectThread } from "./use-project-actions";
3941
...@@ -54,6 +56,8 @@ export function NewTaskDraftScreen(props: {...@@ -54,6 +56,8 @@ export function NewTaskDraftScreen(props: {
54 readonly environmentId?: string;56 readonly environmentId?: string;
55 readonly projectId?: string;57 readonly projectId?: string;
56 };58 };
59 /** Queued outbox message id when editing an existing pending task. */
60 readonly pendingTaskId?: string;
57}) {61}) {
58 const projects = useProjects();62 const projects = useProjects();
59 const createProjectThread = useCreateProjectThread();63 const createProjectThread = useCreateProjectThread();
...@@ -64,15 +68,67 @@ export function NewTaskDraftScreen(props: {...@@ -64,15 +68,67 @@ export function NewTaskDraftScreen(props: {
64 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);68 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
65 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);69 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
66 const { logicalProjects, selectedProject, setProject } = flow;70 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";
67 const promptInputRef = useRef<ComposerEditorHandle>(null);77 const promptInputRef = useRef<ComposerEditorHandle>(null);
68 const loadedBranchesProjectKeyRef = useRef<string | null>(null);78 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
70 const borderColor = useThemeColor("--color-border");113 const borderColor = useThemeColor("--color-border");
71 const bodyText = useScaledTextRole("body");114 const bodyText = useScaledTextRole("body");
72 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";115 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
73 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";116 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
75 useEffect(() => {122 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 }
76 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {132 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {
77 const directProject =133 const directProject =
78 projects.find(134 projects.find(
...@@ -82,6 +138,13 @@ export function NewTaskDraftScreen(props: {...@@ -82,6 +138,13 @@ export function NewTaskDraftScreen(props: {
82 ) ?? null;138 ) ?? null;
83139
84 if (directProject) {140 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;
85 if (148 if (
86 selectedProject?.environmentId === directProject.environmentId &&149 selectedProject?.environmentId === directProject.environmentId &&
87 selectedProject.id === directProject.id150 selectedProject.id === directProject.id
...@@ -106,8 +169,8 @@ export function NewTaskDraftScreen(props: {...@@ -106,8 +169,8 @@ export function NewTaskDraftScreen(props: {
106 }, [169 }, [
107 logicalProjects,170 logicalProjects,
108 projects,171 projects,
109 props.initialProjectRef?.environmentId,172 props.initialProjectRef,
110 props.initialProjectRef?.projectId,173 props.pendingTaskId,
111 navigation,174 navigation,
112 selectedProject,175 selectedProject,
113 setProject,176 setProject,
...@@ -274,12 +337,24 @@ export function NewTaskDraftScreen(props: {...@@ -274,12 +337,24 @@ export function NewTaskDraftScreen(props: {
274 subtitle: flow.selectedBranchName ?? "Choose branch",337 subtitle: flow.selectedBranchName ?? "Choose branch",
275 subactions: branchActions,338 subactions: branchActions,
276 },339 },
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 : []),
277 ];351 ];
278 }, [352 }, [
279 flow.availableBranches,353 flow.availableBranches,
280 flow.branchesLoading,354 flow.branchesLoading,
281 flow.selectedBranchName,355 flow.selectedBranchName,
282 flow.selectedProject,356 flow.selectedProject,
357 flow.startFromOrigin,
283 flow.workspaceMode,358 flow.workspaceMode,
284 ]);359 ]);
285360
...@@ -344,6 +419,10 @@ export function NewTaskDraftScreen(props: {...@@ -344,6 +419,10 @@ export function NewTaskDraftScreen(props: {
344 );419 );
345 return;420 return;
346 }421 }
422 if (event === "workspace:start-from-origin") {
423 flow.setStartFromOrigin(!flow.startFromOrigin);
424 return;
425 }
347 if (event.startsWith("workspace:branch:")) {426 if (event.startsWith("workspace:branch:")) {
348 const branchName = event.slice("workspace:branch:".length);427 const branchName = event.slice("workspace:branch:".length);
349 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);428 const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
...@@ -379,17 +458,17 @@ export function NewTaskDraftScreen(props: {...@@ -379,17 +458,17 @@ export function NewTaskDraftScreen(props: {
379458
380 async function handleStart(): Promise<void> {459 async function handleStart(): Promise<void> {
381 const selectedProject = flow.selectedProject;460 const selectedProject = flow.selectedProject;
382 if (!selectedProject) {461 const draftKey = flow.draftKey;
462 if (!selectedProject || !draftKey) {
383 return;463 return;
384 }464 }
385 const draft = getComposerDraftSnapshot(465 const draft = getComposerDraftSnapshot(draftKey);
386 `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`,
387 );
388 const modelSelection = draft.modelSelection ?? flow.selectedModel;466 const modelSelection = draft.modelSelection ?? flow.selectedModel;
389 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;467 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
390 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;468 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
391 const selectedWorktreePath =469 const selectedWorktreePath =
392 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;470 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
471 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
393 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;472 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
394 const interactionMode = draft.interactionMode ?? flow.interactionMode;473 const interactionMode = draft.interactionMode ?? flow.interactionMode;
395 const initialMessageText = draft.text.trim();474 const initialMessageText = draft.text.trim();
...@@ -403,6 +482,46 @@ export function NewTaskDraftScreen(props: {...@@ -403,6 +482,46 @@ export function NewTaskDraftScreen(props: {
403 return;482 return;
404 }483 }
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
406 flow.setSubmitting(true);525 flow.setSubmitting(true);
407 const result = await createProjectThread({526 const result = await createProjectThread({
408 project: selectedProject,527 project: selectedProject,
...@@ -410,10 +529,21 @@ export function NewTaskDraftScreen(props: {...@@ -410,10 +529,21 @@ export function NewTaskDraftScreen(props: {
410 envMode: workspaceMode,529 envMode: workspaceMode,
411 branch: selectedBranchName,530 branch: selectedBranchName,
412 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,531 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
532 startFromOrigin,
413 runtimeMode,533 runtimeMode,
414 interactionMode,534 interactionMode,
415 initialMessageText,535 initialMessageText,
416 initialAttachments: draft.attachments,536 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 : {}),
417 });547 });
418 flow.setSubmitting(false);548 flow.setSubmitting(false);
419549
...@@ -428,8 +558,17 @@ export function NewTaskDraftScreen(props: {...@@ -428,8 +558,17 @@ export function NewTaskDraftScreen(props: {
428 return;558 return;
429 }559 }
430560
431 flow.setPrompt("");561 if (editingPendingTask) {
432 flow.clearAttachments();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 }
433 navigation.dispatch(572 navigation.dispatch(
434 StackActions.replace("Thread", {573 StackActions.replace("Thread", {
435 environmentId: String(result.value.environmentId),574 environmentId: String(result.value.environmentId),
...@@ -538,8 +677,14 @@ export function NewTaskDraftScreen(props: {...@@ -538,8 +677,14 @@ export function NewTaskDraftScreen(props: {
538 </ControlPillMenu>677 </ControlPillMenu>
539 </ComposerToolbarScroller>678 </ComposerToolbarScroller>
540 <ComposerToolbarButton679 <ComposerToolbarButton
541 accessibilityLabel={flow.submitting ? "Starting task" : "Start task"}680 accessibilityLabel={
542 icon="arrow.up"681 flow.submitting
682 ? "Starting task"
683 : environmentConnected
684 ? "Start task"
685 : "Queue task"
686 }
687 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
543 onPress={() => void handleStart()}688 onPress={() => void handleStart()}
544 variant="primary"689 variant="primary"
545 showChevron={false}690 showChevron={false}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+29-3
...@@ -18,6 +18,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -18,6 +18,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
18import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";18import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
19import { useThemeColor } from "../../lib/useThemeColor";19import { useThemeColor } from "../../lib/useThemeColor";
20import { useProjects, useThreadShells } from "../../state/entities";20import { useProjects, useThreadShells } from "../../state/entities";
21import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
21import { useWorkspaceState } from "../../state/workspace";22import { useWorkspaceState } from "../../state/workspace";
22import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";23import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
23import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";24import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
...@@ -40,6 +41,7 @@ import {...@@ -40,6 +41,7 @@ import {
40} from "../home/homeListItems";41} from "../home/homeListItems";
41import { buildHomeThreadGroups } from "../home/homeThreadList";42import { buildHomeThreadGroups } from "../home/homeThreadList";
42import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";43import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
44import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
43import { useThreadListActions } from "../home/useThreadListActions";45import { useThreadListActions } from "../home/useThreadListActions";
44import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";46import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";
45import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";47import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";
...@@ -47,7 +49,12 @@ import { SidebarHeaderActions } from "./sidebar-header-actions";...@@ -47,7 +49,12 @@ import { SidebarHeaderActions } from "./sidebar-header-actions";
47import { SidebarFilterButton } from "./sidebar-filter-button";49import { SidebarFilterButton } from "./sidebar-filter-button";
48import { createSidebarHeaderItems } from "./sidebar-native-header-items";50import { createSidebarHeaderItems } from "./sidebar-native-header-items";
49import { SidebarNavigationShell } from "./sidebar-navigation-shell";51import { 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
52/**59/**
53 * Shared capsule behind the sidebar header buttons — a native liquid-glass60 * Shared capsule behind the sidebar header buttons — a native liquid-glass
...@@ -162,6 +169,8 @@ function ThreadNavigationSidebarPane(...@@ -162,6 +169,8 @@ function ThreadNavigationSidebarPane(
162 const headerIsOverContentRef = useRef(false);169 const headerIsOverContentRef = useRef(false);
163 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);170 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
164 const { archiveThread, confirmDeleteThread } = useThreadListActions();171 const { archiveThread, confirmDeleteThread } = useThreadListActions();
172 const pendingTasks = usePendingNewTasks();
173 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
165 const environments = useMemo(174 const environments = useMemo(
166 () =>175 () =>
167 Object.values(savedConnectionsById)176 Object.values(savedConnectionsById)
...@@ -188,13 +197,14 @@ function ThreadNavigationSidebarPane(...@@ -188,13 +197,14 @@ function ThreadNavigationSidebarPane(
188 buildHomeThreadGroups({197 buildHomeThreadGroups({
189 projects,198 projects,
190 threads,199 threads,
200 pendingTasks,
191 environmentId: options.selectedEnvironmentId,201 environmentId: options.selectedEnvironmentId,
192 searchQuery: props.searchQuery,202 searchQuery: props.searchQuery,
193 projectSortOrder: options.projectSortOrder,203 projectSortOrder: options.projectSortOrder,
194 threadSortOrder: options.threadSortOrder,204 threadSortOrder: options.threadSortOrder,
195 projectGroupingMode: options.projectGroupingMode,205 projectGroupingMode: options.projectGroupingMode,
196 }),206 }),
197 [options, projects, props.searchQuery, threads],207 [options, pendingTasks, projects, props.searchQuery, threads],
198 );208 );
199 const [groupDisplayStates, setGroupDisplayStates] = useState<209 const [groupDisplayStates, setGroupDisplayStates] = useState<
200 ReadonlyMap<string, HomeGroupDisplayState>210 ReadonlyMap<string, HomeGroupDisplayState>
...@@ -404,10 +414,24 @@ function ThreadNavigationSidebarPane(...@@ -404,10 +414,24 @@ function ThreadNavigationSidebarPane(
404 groupKey={item.group.key}414 groupKey={item.group.key}
405 onGroupAction={updateGroupDisplay}415 onGroupAction={updateGroupDisplay}
406 project={item.group.representative}416 project={item.group.representative}
407 threadCount={item.group.threads.length}417 threadCount={item.group.threads.length + item.group.pendingTasks.length}
408 title={item.group.title}418 title={item.group.title}
409 />419 />
410 );420 );
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 );
411 case "thread": {435 case "thread": {
412 const thread = item.thread;436 const thread = item.thread;
413 return (437 return (
...@@ -449,10 +473,12 @@ function ThreadNavigationSidebarPane(...@@ -449,10 +473,12 @@ function ThreadNavigationSidebarPane(
449 },473 },
450 [474 [
451 archiveThread,475 archiveThread,
476 confirmDeletePendingTask,
452 confirmDeleteThread,477 confirmDeleteThread,
453 handleSelectThread,478 handleSelectThread,
454 handleSwipeableClose,479 handleSwipeableClose,
455 handleSwipeableWillOpen,480 handleSwipeableWillOpen,
481 openPendingTask,
456 projectCwdByKey,482 projectCwdByKey,
457 props.selectedThreadKey,483 props.selectedThreadKey,
458 props.width,484 props.width,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+318-10
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import React, { useCallback, useEffect, useMemo, useState } from "react";1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
...@@ -8,18 +8,29 @@ import type {...@@ -8,18 +8,29 @@ import type {
8 RuntimeMode,8 RuntimeMode,
9 ServerProviderSkill,9 ServerProviderSkill,
10} from "@t3tools/contracts";10} 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";
12import * as Arr from "effect/Array";18import * as Arr from "effect/Array";
13import { pipe } from "effect/Function";19import { pipe } from "effect/Function";
1420
15import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";21import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
22import type { TurnCommandMetadata } from "../../lib/commandMetadata";
16import type { DraftComposerImageAttachment } from "../../lib/composerImages";23import type { DraftComposerImageAttachment } from "../../lib/composerImages";
17import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";24import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
18import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";25import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
19import { groupProjectsByRepository } from "../../lib/repositoryGroups";26import { groupProjectsByRepository } from "../../lib/repositoryGroups";
20import { scopedProjectKey } from "../../lib/scopedEntities";27import { scopedProjectKey } from "../../lib/scopedEntities";
28import { appAtomRegistry } from "../../state/atom-registry";
21import {29import {
22 appendComposerDraftAttachments,30 appendComposerDraftAttachments,
31 clearComposerDraft,
32 getComposerDraftSnapshot,
33 isComposerDraftEmpty,
23 removeComposerDraftAttachment,34 removeComposerDraftAttachment,
24 replaceComposerDraftAttachments,35 replaceComposerDraftAttachments,
25 setComposerDraftText,36 setComposerDraftText,
...@@ -27,6 +38,17 @@ import {...@@ -27,6 +38,17 @@ import {
27 useComposerDraft,38 useComposerDraft,
28} from "../../state/use-composer-drafts";39} from "../../state/use-composer-drafts";
29import { useBranches } from "../../state/queries";40import { 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";
30import {52import {
31 setPendingConnectionError,53 setPendingConnectionError,
32 useSavedRemoteConnections,54 useSavedRemoteConnections,
...@@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs";...@@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
3658
37type WorkspaceMode = "local" | "worktree";59type 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
39function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {78function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {
40 if (!branch.worktreePath) {79 if (!branch.worktreePath) {
41 return null;80 return null;
...@@ -74,6 +113,9 @@ type NewTaskFlowContextValue = {...@@ -74,6 +113,9 @@ type NewTaskFlowContextValue = {
74 readonly workspaceMode: WorkspaceMode;113 readonly workspaceMode: WorkspaceMode;
75 readonly selectedBranchName: string | null;114 readonly selectedBranchName: string | null;
76 readonly selectedWorktreePath: string | null;115 readonly selectedWorktreePath: string | null;
116 readonly startFromOrigin: boolean;
117 readonly draftKey: string | null;
118 readonly editingPendingTask: QueuedThreadMessage | null;
77 readonly prompt: string;119 readonly prompt: string;
78 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;120 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
79 readonly submitting: boolean;121 readonly submitting: boolean;
...@@ -100,6 +142,11 @@ type NewTaskFlowContextValue = {...@@ -100,6 +142,11 @@ type NewTaskFlowContextValue = {
100 readonly setSelectedModelKey: (key: string | null) => void;142 readonly setSelectedModelKey: (key: string | null) => void;
101 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;143 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
102 readonly selectBranch: (branch: VcsRef) => void;144 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;
103 readonly setPrompt: (value: string) => void;150 readonly setPrompt: (value: string) => void;
104 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;151 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
105 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;152 readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;
...@@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
162 const [submitting, setSubmitting] = useState(false);209 const [submitting, setSubmitting] = useState(false);
163 const [branchQuery, setBranchQuery] = useState("");210 const [branchQuery, setBranchQuery] = useState("");
164 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);211 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
166 const reset = useCallback(() => {217 const reset = useCallback(() => {
167 setSelectedEnvironmentId(null);218 setSelectedEnvironmentId(null);
...@@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
169 setSubmitting(false);220 setSubmitting(false);
170 setBranchQuery("");221 setBranchQuery("");
171 setExpandedProvider(null);222 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 }
172 }, []);232 }, []);
173233
174 const environments = useMemo(234 const environments = useMemo(
...@@ -214,24 +274,59 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -214,24 +274,59 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
214 [projects, selectedEnvironmentId],274 [projects, selectedEnvironmentId],
215 );275 );
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
217 const selectedProject =301 const selectedProject =
218 projectsForEnvironment.find(302 projectsForEnvironment.find(
219 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,303 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
220 ) ??304 ) ??
221 projectsForEnvironment[0] ??305 // While editing a queued task whose project shell is absent, keep the task
222 null;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));
223 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(313 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
224 selectedProject?.environmentId ?? null,314 selectedProject?.environmentId ?? null,
225 );315 );
226 const selectedProjectDraftKey = selectedProject316 // While a queued pending task is being edited its draft lives under a key
227 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`317 // scoped to the queued message, so per-project new-task drafts stay intact.
228 : null;318 const selectedProjectDraftKey = editingPendingTask
319 ? pendingTaskDraftKey(editingPendingTask.messageId)
320 : selectedProject
321 ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
322 : null;
229 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);323 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
230 const prompt = selectedProjectDraft.text;324 const prompt = selectedProjectDraft.text;
231 const attachments = selectedProjectDraft.attachments;325 const attachments = selectedProjectDraft.attachments;
232 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";326 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";
233 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;327 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
234 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;328 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
329 const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false;
235 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;330 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
236 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;331 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
237332
...@@ -350,7 +445,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -350,7 +445,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
350 const branchTarget = useMemo(445 const branchTarget = useMemo(
351 () => ({446 () => ({
352 environmentId: selectedProject?.environmentId ?? null,447 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,
354 query: null,450 query: null,
355 }),451 }),
356 [selectedProject?.environmentId, selectedProject?.workspaceRoot],452 [selectedProject?.environmentId, selectedProject?.workspaceRoot],
...@@ -399,10 +495,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -399,10 +495,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
399 mode,495 mode,
400 branch: selectedBranchName,496 branch: selectedBranchName,
401 worktreePath: selectedWorktreePath,497 worktreePath: selectedWorktreePath,
498 startFromOrigin,
402 },499 },
403 });500 });
404 },501 },
405 [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath],502 [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin],
406 );503 );
407504
408 const selectBranch = useCallback(505 const selectBranch = useCallback(
...@@ -415,10 +512,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -415,10 +512,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
415 mode: workspaceMode,512 mode: workspaceMode,
416 branch: branch.name,513 branch: branch.name,
417 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),514 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
515 startFromOrigin,
418 },516 },
419 });517 });
420 },518 },
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],
422 );537 );
423538
424 const refreshBranches = branchState.refresh;539 const refreshBranches = branchState.refresh;
...@@ -460,6 +575,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -460,6 +575,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
460 [selectedProjectDraftKey],575 [selectedProjectDraftKey],
461 );576 );
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
463 const value = useMemo<NewTaskFlowContextValue>(755 const value = useMemo<NewTaskFlowContextValue>(
464 () => ({756 () => ({
465 logicalProjects,757 logicalProjects,
...@@ -469,6 +761,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -469,6 +761,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
469 workspaceMode,761 workspaceMode,
470 selectedBranchName,762 selectedBranchName,
471 selectedWorktreePath,763 selectedWorktreePath,
764 startFromOrigin,
765 draftKey: selectedProjectDraftKey,
766 editingPendingTask,
472 prompt,767 prompt,
473 attachments,768 attachments,
474 submitting,769 submitting,
...@@ -492,6 +787,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -492,6 +787,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
492 setSelectedModelKey,787 setSelectedModelKey,
493 setWorkspaceMode,788 setWorkspaceMode,
494 selectBranch,789 selectBranch,
790 setStartFromOrigin,
791 beginEditingPendingTask,
792 finishEditingPendingTask,
793 cancelEditingPendingTask,
794 buildPendingTaskMessage,
495 setPrompt,795 setPrompt,
496 replaceAttachments,796 replaceAttachments,
497 appendAttachments,797 appendAttachments,
...@@ -508,11 +808,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -508,11 +808,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
508 [808 [
509 attachments,809 attachments,
510 availableBranches,810 availableBranches,
811 beginEditingPendingTask,
511 branchQuery,812 branchQuery,
512 branchesLoading,813 branchesLoading,
814 buildPendingTaskMessage,
815 cancelEditingPendingTask,
816 editingPendingTask,
513 environments,817 environments,
514 expandedProvider,818 expandedProvider,
515 filteredBranches,819 filteredBranches,
820 finishEditingPendingTask,
516 interactionMode,821 interactionMode,
517 loadBranches,822 loadBranches,
518 logicalProjects,823 logicalProjects,
...@@ -527,6 +832,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -527,6 +832,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
527 selectedModel,832 selectedModel,
528 selectedModelKey,833 selectedModelKey,
529 selectedModelOption,834 selectedModelOption,
835 selectedProjectDraftKey,
530 selectedProviderSkills,836 selectedProviderSkills,
531 setSelectedModelOptions,837 setSelectedModelOptions,
532 selectedProject,838 selectedProject,
...@@ -539,7 +845,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -539,7 +845,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
539 setPrompt,845 setPrompt,
540 setRuntimeMode,846 setRuntimeMode,
541 setSelectedModelKey,847 setSelectedModelKey,
848 setStartFromOrigin,
542 setWorkspaceMode,849 setWorkspaceMode,
850 startFromOrigin,
543 submitting,851 submitting,
544 workspaceMode,852 workspaceMode,
545 appendAttachments,853 appendAttachments,
apps/mobile/src/features/threads/thread-list-items.tsx+168
...@@ -14,6 +14,7 @@ import { ControlPillMenu } from "../../components/ControlPill";...@@ -14,6 +14,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
14import { ProjectFavicon } from "../../components/ProjectFavicon";14import { ProjectFavicon } from "../../components/ProjectFavicon";
15import { relativeTime } from "../../lib/time";15import { relativeTime } from "../../lib/time";
16import { useThemeColor } from "../../lib/useThemeColor";16import { useThemeColor } from "../../lib/useThemeColor";
17import type { PendingNewTask } from "../../state/use-pending-new-tasks";
17import { useThreadPr } from "../../state/use-thread-pr";18import { useThreadPr } from "../../state/use-thread-pr";
18import type { HomeGroupDisplayAction } from "../home/homeListItems";19import type { HomeGroupDisplayAction } from "../home/homeListItems";
19import { ThreadSwipeable } from "../home/thread-swipe-actions";20import { ThreadSwipeable } from "../home/thread-swipe-actions";
...@@ -180,6 +181,173 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:...@@ -180,6 +181,173 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:
180 );181 );
181});182});
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
183/* ─── Thread row ─────────────────────────────────────────────────────── */351/* ─── Thread row ─────────────────────────────────────────────────────── */
184352
185const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [353const 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";...@@ -4,8 +4,6 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";4import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime";5import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime";
6import {6import {
7 CommandId,
8 MessageId,
9 ThreadId,7 ThreadId,
10 type ModelSelection,8 type ModelSelection,
11 type ProviderInteractionMode,9 type ProviderInteractionMode,
...@@ -17,22 +15,13 @@ import { AsyncResult } from "effect/unstable/reactivity";...@@ -17,22 +15,13 @@ import { AsyncResult } from "effect/unstable/reactivity";
1715
18import { threadEnvironment } from "../../state/threads";16import { threadEnvironment } from "../../state/threads";
19import type { DraftComposerImageAttachment } from "../../lib/composerImages";17import type { DraftComposerImageAttachment } from "../../lib/composerImages";
20import { makeTurnCommandMetadata } from "../../lib/commandMetadata";18import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
21import { uuidv4 } from "../../lib/uuid";19import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
20import { randomHex } from "../../lib/uuid";
22import { useAtomCommand } from "../../state/use-atom-command";21import { useAtomCommand } from "../../state/use-atom-command";
23import { setPendingConnectionError } from "../../state/use-remote-environment-registry";22import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
24import { validateProjectThreadCreation } from "./projectThreadCreationValidation";23import { 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
36export function useCreateProjectThread() {25export function useCreateProjectThread() {
37 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });26 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
3827
...@@ -43,15 +32,17 @@ export function useCreateProjectThread() {...@@ -43,15 +32,17 @@ export function useCreateProjectThread() {
43 readonly envMode: "local" | "worktree";32 readonly envMode: "local" | "worktree";
44 readonly branch: string | null;33 readonly branch: string | null;
45 readonly worktreePath: string | null;34 readonly worktreePath: string | null;
35 readonly startFromOrigin?: boolean;
46 readonly runtimeMode: RuntimeMode;36 readonly runtimeMode: RuntimeMode;
47 readonly interactionMode: ProviderInteractionMode;37 readonly interactionMode: ProviderInteractionMode;
48 readonly initialMessageText: string;38 readonly initialMessageText: string;
49 readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>;39 readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>;
40 /** Reuse identifiers from a queued pending task instead of minting new ones. */
41 readonly turnMetadata?: TurnCommandMetadata;
50 }) => {42 }) => {
51 const metadata = makeTurnCommandMetadata();43 const metadata = input.turnMetadata ?? makeTurnCommandMetadata();
52 const threadId = ThreadId.make(metadata.threadId);44 const threadId = ThreadId.make(metadata.threadId);
53 const initialMessageText = input.initialMessageText.trim();45 const initialMessageText = input.initialMessageText.trim();
54 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
5546
56 const validationError = validateProjectThreadCreation({47 const validationError = validateProjectThreadCreation({
57 environmentId: input.project.environmentId,48 environmentId: input.project.environmentId,
...@@ -65,46 +56,26 @@ export function useCreateProjectThread() {...@@ -65,46 +56,26 @@ export function useCreateProjectThread() {
65 return AsyncResult.failure(Cause.fail(validationError));56 return AsyncResult.failure(Cause.fail(validationError));
66 }57 }
6758
68 const isWorktree = input.envMode === "worktree";
69 const result = await startTurn({59 const result = await startTurn({
70 environmentId: input.project.environmentId,60 environmentId: input.project.environmentId,
71 input: {61 input: buildProjectThreadStartTurnInput({
72 commandId: CommandId.make(metadata.commandId),62 projectId: input.project.id,
73 threadId,63 projectCwd: input.project.workspaceRoot,
74 message: {64 threadId: metadata.threadId,
75 messageId: MessageId.make(metadata.messageId),65 commandId: metadata.commandId,
76 role: "user",66 messageId: metadata.messageId,
77 text: initialMessageText,67 createdAt: metadata.createdAt,
78 attachments: input.initialAttachments,68 text: initialMessageText,
79 },69 attachments: input.initialAttachments,
80 modelSelection: input.modelSelection,70 modelSelection: input.modelSelection,
81 titleSeed: nextTitle,
82 runtimeMode: input.runtimeMode,71 runtimeMode: input.runtimeMode,
83 interactionMode: input.interactionMode,72 interactionMode: input.interactionMode,
84 bootstrap: {73 workspaceMode: input.envMode,
85 createThread: {74 branch: input.branch,
86 projectId: input.project.id,75 worktreePath: input.worktreePath,
87 title: nextTitle,76 startFromOrigin: input.startFromOrigin ?? false,
88 modelSelection: input.modelSelection,77 worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
89 runtimeMode: input.runtimeMode,78 }),
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 },
108 });79 });
109 if (AsyncResult.isFailure(result)) {80 if (AsyncResult.isFailure(result)) {
110 const error = Cause.squash(result.cause);81 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 @@...@@ -1,3 +1,9 @@
1import * as Crypto from "expo-crypto";1import * as Crypto from "expo-crypto";
22
3export const uuidv4 = () => Crypto.randomUUID();3export 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...@@ -15,6 +15,7 @@ export class ThreadOutboxManagerError extends Schema.TaggedErrorClass<ThreadOutb
15 operation: Schema.Literals([15 operation: Schema.Literals([
16 "load",16 "load",
17 "enqueue",17 "enqueue",
18 "update",
18 "remove",19 "remove",
19 "clear-environment-load",20 "clear-environment-load",
20 "clear-environment-remove",21 "clear-environment-remove",
...@@ -103,6 +104,35 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -103,6 +104,35 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
103 setMessages([...currentMessages(), message]);104 setMessages([...currentMessages(), message]);
104 });105 });
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
106 const remove = (message: QueuedThreadMessage): Promise<void> =>136 const remove = (message: QueuedThreadMessage): Promise<void> =>
107 serialize(async () => {137 serialize(async () => {
108 try {138 try {
...@@ -171,6 +201,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {...@@ -171,6 +201,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
171 serialize,201 serialize,
172 load,202 load,
173 enqueue,203 enqueue,
204 update,
174 remove,205 remove,
175 clearEnvironment,206 clearEnvironment,
176 };207 };
apps/mobile/src/state/thread-outbox-model.ts+56-2
...@@ -6,10 +6,12 @@ import {...@@ -6,10 +6,12 @@ import {
6 IsoDateTime,6 IsoDateTime,
7 MessageId,7 MessageId,
8 ModelSelection,8 ModelSelection,
9 ProjectId,
9 ProviderInteractionMode,10 ProviderInteractionMode,
10 RuntimeMode,11 RuntimeMode,
11 ThreadId,12 ThreadId,
12 type ModelSelection as ModelSelectionType,13 type ModelSelection as ModelSelectionType,
14 type ProjectId as ProjectIdType,
13 type ProviderInteractionMode as ProviderInteractionModeType,15 type ProviderInteractionMode as ProviderInteractionModeType,
14 type RuntimeMode as RuntimeModeType,16 type RuntimeMode as RuntimeModeType,
15} from "@t3tools/contracts";17} from "@t3tools/contracts";
...@@ -19,11 +21,23 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema...@@ -19,11 +21,23 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema
19import type { DraftComposerImageAttachment } from "../lib/composerImages";21import type { DraftComposerImageAttachment } from "../lib/composerImages";
20import { scopedThreadKey } from "../lib/scopedEntities";22import { scopedThreadKey } from "../lib/scopedEntities";
2123
22const THREAD_OUTBOX_SCHEMA_VERSION = 2;24const THREAD_OUTBOX_SCHEMA_VERSION = 3;
23const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;25const 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
25export const QueuedThreadMessageSchema = Schema.Struct({39export const QueuedThreadMessageSchema = Schema.Struct({
26 schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]),40 schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),
27 environmentId: EnvironmentId,41 environmentId: EnvironmentId,
28 threadId: ThreadId,42 threadId: ThreadId,
29 messageId: MessageId,43 messageId: MessageId,
...@@ -33,12 +47,25 @@ export const QueuedThreadMessageSchema = Schema.Struct({...@@ -33,12 +47,25 @@ export const QueuedThreadMessageSchema = Schema.Struct({
33 modelSelection: Schema.optional(ModelSelection),47 modelSelection: Schema.optional(ModelSelection),
34 runtimeMode: Schema.optional(RuntimeMode),48 runtimeMode: Schema.optional(RuntimeMode),
35 interactionMode: Schema.optional(ProviderInteractionMode),49 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),
36 createdAt: IsoDateTime,53 createdAt: IsoDateTime,
37});54});
3855
39const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema);56const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema);
40const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema);57const 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
42export interface QueuedThreadMessage {69export interface QueuedThreadMessage {
43 readonly environmentId: EnvironmentId;70 readonly environmentId: EnvironmentId;
44 readonly threadId: ThreadId;71 readonly threadId: ThreadId;
...@@ -49,6 +76,7 @@ export interface QueuedThreadMessage {...@@ -49,6 +76,7 @@ export interface QueuedThreadMessage {
49 readonly modelSelection?: ModelSelectionType;76 readonly modelSelection?: ModelSelectionType;
50 readonly runtimeMode?: RuntimeModeType;77 readonly runtimeMode?: RuntimeModeType;
51 readonly interactionMode?: ProviderInteractionModeType;78 readonly interactionMode?: ProviderInteractionModeType;
79 readonly creation?: QueuedThreadCreation;
52 readonly createdAt: string;80 readonly createdAt: string;
53}81}
5482
...@@ -121,17 +149,43 @@ export function threadOutboxRetryDelayMs(attempt: number): number {...@@ -121,17 +149,43 @@ export function threadOutboxRetryDelayMs(attempt: number): number {
121export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";149export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";
122150
123export function resolveThreadOutboxDeliveryAction(input: {151export function resolveThreadOutboxDeliveryAction(input: {
152 readonly isCreation: boolean;
124 readonly threadExists: boolean;153 readonly threadExists: boolean;
125 readonly shellStatus: EnvironmentShellStatus;154 readonly shellStatus: EnvironmentShellStatus;
126 readonly environmentConnected: boolean;155 readonly environmentConnected: boolean;
127 readonly threadBusy: boolean;156 readonly threadBusy: boolean;
128}): ThreadOutboxDeliveryAction {157}): 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 }
129 if (!input.threadExists) {169 if (!input.threadExists) {
130 return input.shellStatus === "live" ? "remove" : "wait";170 return input.shellStatus === "live" ? "remove" : "wait";
131 }171 }
132 return input.environmentConnected && !input.threadBusy ? "send" : "wait";172 return input.environmentConnected && !input.threadBusy ? "send" : "wait";
133}173}
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
135function errorMessage(error: unknown): string | null {189function errorMessage(error: unknown): string | null {
136 if (error instanceof Error) {190 if (error instanceof Error) {
137 return error.message;191 return error.message;
apps/mobile/src/state/thread-outbox.test.ts+121
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 CommandId,3 CommandId,
4 EnvironmentId,4 EnvironmentId,
5 MessageId,5 MessageId,
6 ProjectId,
6 ProviderInstanceId,7 ProviderInstanceId,
7 ThreadId,8 ThreadId,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
...@@ -12,6 +13,7 @@ import {...@@ -12,6 +13,7 @@ import {
12 decodeQueuedThreadMessage,13 decodeQueuedThreadMessage,
13 encodeQueuedThreadMessage,14 encodeQueuedThreadMessage,
14 groupQueuedThreadMessages,15 groupQueuedThreadMessages,
16 isQueuedThreadCreationSendable,
15 modelSelectionsEqual,17 modelSelectionsEqual,
16 resolveThreadOutboxDeliveryAction,18 resolveThreadOutboxDeliveryAction,
17 resolveThreadOutboxFailureAction,19 resolveThreadOutboxFailureAction,
...@@ -292,9 +294,43 @@ describe("thread outbox", () => {...@@ -292,9 +294,43 @@ describe("thread outbox", () => {
292 registry.dispose();294 registry.dispose();
293 });295 });
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
295 it("only removes a missing-thread message after shell synchronization is live", () => {330 it("only removes a missing-thread message after shell synchronization is live", () => {
296 expect(331 expect(
297 resolveThreadOutboxDeliveryAction({332 resolveThreadOutboxDeliveryAction({
333 isCreation: false,
298 threadExists: false,334 threadExists: false,
299 shellStatus: "synchronizing",335 shellStatus: "synchronizing",
300 environmentConnected: true,336 environmentConnected: true,
...@@ -303,6 +339,7 @@ describe("thread outbox", () => {...@@ -303,6 +339,7 @@ describe("thread outbox", () => {
303 ).toBe("wait");339 ).toBe("wait");
304 expect(340 expect(
305 resolveThreadOutboxDeliveryAction({341 resolveThreadOutboxDeliveryAction({
342 isCreation: false,
306 threadExists: false,343 threadExists: false,
307 shellStatus: "live",344 shellStatus: "live",
308 environmentConnected: true,345 environmentConnected: true,
...@@ -311,6 +348,7 @@ describe("thread outbox", () => {...@@ -311,6 +348,7 @@ describe("thread outbox", () => {
311 ).toBe("remove");348 ).toBe("remove");
312 expect(349 expect(
313 resolveThreadOutboxDeliveryAction({350 resolveThreadOutboxDeliveryAction({
351 isCreation: false,
314 threadExists: true,352 threadExists: true,
315 shellStatus: "live",353 shellStatus: "live",
316 environmentConnected: true,354 environmentConnected: true,
...@@ -319,6 +357,89 @@ describe("thread outbox", () => {...@@ -319,6 +357,89 @@ describe("thread outbox", () => {
319 ).toBe("send");357 ).toBe("send");
320 });358 });
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
322 it("retries transport failures but drops deterministic command failures", () => {443 it("retries transport failures but drops deterministic command failures", () => {
323 expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true);444 expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true);
324 expect(445 expect(
apps/mobile/src/state/thread-outbox.ts+5
...@@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis...@@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis
20 return threadOutboxManager.enqueue(message);20 return threadOutboxManager.enqueue(message);
21}21}
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
23export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {28export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {
24 return threadOutboxManager.remove(message);29 return threadOutboxManager.remove(message);
25}30}
apps/mobile/src/state/use-composer-drafts.ts+6
...@@ -48,6 +48,7 @@ export interface ComposerDraftWorkspaceSelection {...@@ -48,6 +48,7 @@ export interface ComposerDraftWorkspaceSelection {
48 readonly mode: "local" | "worktree";48 readonly mode: "local" | "worktree";
49 readonly branch: string | null;49 readonly branch: string | null;
50 readonly worktreePath: string | null;50 readonly worktreePath: string | null;
51 readonly startFromOrigin?: boolean;
51}52}
5253
53export type ComposerDraftSettingsUpdate = Pick<54export type ComposerDraftSettingsUpdate = Pick<
...@@ -59,6 +60,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({...@@ -59,6 +60,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
59 mode: Schema.Literals(["local", "worktree"]),60 mode: Schema.Literals(["local", "worktree"]),
60 branch: Schema.NullOr(Schema.String),61 branch: Schema.NullOr(Schema.String),
61 worktreePath: Schema.NullOr(Schema.String),62 worktreePath: Schema.NullOr(Schema.String),
63 startFromOrigin: Schema.optional(Schema.Boolean),
62});64});
6365
64const ComposerDraftSchema = Schema.Struct({66const ComposerDraftSchema = Schema.Struct({
...@@ -107,6 +109,10 @@ export function getComposerDraftSnapshot(draftKey: string): ComposerDraft {...@@ -107,6 +109,10 @@ export function getComposerDraftSnapshot(draftKey: string): ComposerDraft {
107 return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]);109 return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]);
108}110}
109111
112export function isComposerDraftEmpty(draft: ComposerDraft): boolean {
113 return isEmptyDraft(draft);
114}
115
110function isEmptyDraft(draft: ComposerDraft): boolean {116function isEmptyDraft(draft: ComposerDraft): boolean {
111 return (117 return (
112 draft.text.length === 0 &&118 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 @@...@@ -1,27 +1,44 @@
1import { useAtomValue } from "@effect/atom-react";1import { 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";
3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";6import 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";
5import * as Cause from "effect/Cause";14import * as Cause from "effect/Cause";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";15import { AsyncResult, Atom } from "effect/unstable/reactivity";
7import { useCallback, useEffect, useRef, useState } from "react";16import { useCallback, useEffect, useRef, useState } from "react";
817
9import { scopedThreadKey } from "../lib/scopedEntities";18import { scopedThreadKey } from "../lib/scopedEntities";
19import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
20import { randomHex } from "../lib/uuid";
10import { appAtomRegistry } from "./atom-registry";21import { appAtomRegistry } from "./atom-registry";
11import { useThreadShells } from "./entities";22import { useProjects, useThreadShells } from "./entities";
12import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";23import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";
13import {24import {
25 isQueuedThreadCreationSendable,
14 modelSelectionsEqual,26 modelSelectionsEqual,
15 resolveThreadOutboxDeliveryAction,27 resolveThreadOutboxDeliveryAction,
16 resolveThreadOutboxFailureAction,28 resolveThreadOutboxFailureAction,
17 resolveQueuedThreadSettings,29 resolveQueuedThreadSettings,
18 threadOutboxRetryDelayMs,30 threadOutboxRetryDelayMs,
31 type QueuedThreadCreation,
19 type QueuedThreadMessage,32 type QueuedThreadMessage,
20 type ThreadOutboxCommandStage,33 type ThreadOutboxCommandStage,
21} from "./thread-outbox-model";34} from "./thread-outbox-model";
22import { threadEnvironment } from "./threads";35import { threadEnvironment } from "./threads";
23import { useAtomCommand } from "./use-atom-command";36import { useAtomCommand } from "./use-atom-command";
24import { useThreadOutboxMessages, useThreadOutboxShellStatuses } from "./use-thread-outbox";37import {
38 editingQueuedMessageIdsAtom,
39 useThreadOutboxMessages,
40 useThreadOutboxShellStatuses,
41} from "./use-thread-outbox";
25import { useRemoteConnectionStatus } from "./use-remote-environment-registry";42import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
2643
27export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(44export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
...@@ -48,6 +65,17 @@ function findThread(...@@ -48,6 +65,17 @@ function findThread(
48 );65 );
49}66}
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
51function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId {79function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId {
52 return CommandId.make(`${message.commandId}:${setting}`);80 return CommandId.make(`${message.commandId}:${setting}`);
53}81}
...@@ -64,9 +92,11 @@ export function useThreadOutboxDrain(): void {...@@ -64,9 +92,11 @@ export function useThreadOutboxDrain(): void {
64 reportFailure: false,92 reportFailure: false,
65 });93 });
66 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);94 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
95 const editingQueuedMessageIds = useAtomValue(editingQueuedMessageIdsAtom);
67 const queuedMessagesByThreadKey = useThreadOutboxMessages();96 const queuedMessagesByThreadKey = useThreadOutboxMessages();
68 const shellStatuses = useThreadOutboxShellStatuses();97 const shellStatuses = useThreadOutboxShellStatuses();
69 const threads = useThreadShells();98 const threads = useThreadShells();
99 const projects = useProjects();
70 const { connectedEnvironments } = useRemoteConnectionStatus();100 const { connectedEnvironments } = useRemoteConnectionStatus();
71 const [retryTick, setRetryTick] = useState(0);101 const [retryTick, setRetryTick] = useState(0);
72 const retryAttemptRef = useRef(new Map<MessageId, number>());102 const retryAttemptRef = useRef(new Map<MessageId, number>());
...@@ -83,52 +113,57 @@ export function useThreadOutboxDrain(): void {...@@ -83,52 +113,57 @@ export function useThreadOutboxDrain(): void {
83 };113 };
84 }, []);114 }, []);
85115
86 const sendQueuedMessage = useCallback(116 const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => {
87 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {117 const reportFailure = (
88 const settings = resolveQueuedThreadSettings(queuedMessage, thread);118 commandResult: AtomCommandResult<unknown, unknown>,
89 const reportFailure = (119 stage: ThreadOutboxCommandStage,
90 commandResult: AtomCommandResult<unknown, unknown>,120 ): boolean => {
91 stage: ThreadOutboxCommandStage,121 if (!AsyncResult.isFailure(commandResult)) {
92 ): boolean => {122 return false;
93 if (!AsyncResult.isFailure(commandResult)) {123 }
94 return false;124 const action = resolveThreadOutboxFailureAction({
95 }125 stage,
96 const action = resolveThreadOutboxFailureAction({126 error: Cause.squash(commandResult.cause),
97 stage,127 interrupted: Cause.hasInterruptsOnly(commandResult.cause),
98 error: Cause.squash(commandResult.cause),128 });
99 interrupted: Cause.hasInterruptsOnly(commandResult.cause),129 const retry = action === "retry";
100 });130 console.warn("[thread-outbox] queued message delivery failed", {
101 const retry = action === "retry";131 environmentId: queuedMessage.environmentId,
102 console.warn("[thread-outbox] queued message delivery failed", {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", {
103 environmentId: queuedMessage.environmentId,152 environmentId: queuedMessage.environmentId,
104 threadId: queuedMessage.threadId,153 threadId: queuedMessage.threadId,
105 messageId: queuedMessage.messageId,154 messageId: queuedMessage.messageId,
106 stage,155 error,
107 cause: commandResult.cause,
108 retry,
109 });156 });
110 return retry;157 return false;
111 };158 }
112 const completeDelivery = async (159 };
113 deliveryResult: AtomCommandResult<unknown, unknown>,160 return { reportFailure, completeDelivery };
114 ): Promise<boolean> => {161 }, []);
115 if (reportFailure(deliveryResult, "start-turn")) {
116 return false;
117 }
118162
119 try {163 const sendQueuedMessage = useCallback(
120 await removeThreadOutboxMessage(queuedMessage);164 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
121 return true;165 const settings = resolveQueuedThreadSettings(queuedMessage, thread);
122 } catch (error) {166 const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage);
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 };
132167
133 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {168 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
134 const updateResult = await updateThreadMetadata({169 const updateResult = await updateThreadMetadata({
...@@ -196,7 +231,50 @@ export function useThreadOutboxDrain(): void {...@@ -196,7 +231,50 @@ export function useThreadOutboxDrain(): void {
196 });231 });
197 return completeDelivery(deliveryResult);232 return completeDelivery(deliveryResult);
198 },233 },
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],
200 );278 );
201279
202 useEffect(() => {280 useEffect(() => {
...@@ -209,6 +287,9 @@ export function useThreadOutboxDrain(): void {...@@ -209,6 +287,9 @@ export function useThreadOutboxDrain(): void {
209 if (!nextQueuedMessage) {287 if (!nextQueuedMessage) {
210 continue;288 continue;
211 }289 }
290 if (editingQueuedMessageIds[nextQueuedMessage.messageId]) {
291 continue;
292 }
212 if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {293 if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
213 continue;294 continue;
214 }295 }
...@@ -218,37 +299,65 @@ export function useThreadOutboxDrain(): void {...@@ -218,37 +299,65 @@ export function useThreadOutboxDrain(): void {
218 continue;299 continue;
219 }300 }
220301
302 const creation = nextQueuedMessage.creation;
221 const environment = connectedEnvironments.find(303 const environment = connectedEnvironments.find(
222 (candidate) => candidate.environmentId === nextQueuedMessage.environmentId,304 (candidate) => candidate.environmentId === nextQueuedMessage.environmentId,
223 );305 );
306 const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty";
224 const deliveryAction = resolveThreadOutboxDeliveryAction({307 const deliveryAction = resolveThreadOutboxDeliveryAction({
308 isCreation: creation !== undefined,
225 threadExists: thread !== undefined,309 threadExists: thread !== undefined,
226 shellStatus: shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty",310 shellStatus,
227 environmentConnected: environment?.connectionState === "connected",311 environmentConnected: environment?.connectionState === "connected",
228 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",312 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
229 });313 });
230 if (deliveryAction === "wait") {314 if (deliveryAction === "wait") {
231 continue;315 continue;
232 }316 }
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
234 beginDispatchingQueuedMessage(nextQueuedMessage.messageId);337 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 );
235 const delivery =351 const delivery =
236 deliveryAction === "remove"352 deliveryAction === "remove"
237 ? removeThreadOutboxMessage(nextQueuedMessage).then(353 ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
238 () => true,354 : creation !== undefined
239 (error) => {355 ? creationProjectCwd !== null
240 console.warn("[thread-outbox] failed to remove message for a missing thread", {356 ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd)
241 environmentId: nextQueuedMessage.environmentId,357 : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project")
242 threadId: nextQueuedMessage.threadId,358 : thread !== undefined
243 messageId: nextQueuedMessage.messageId,359 ? sendQueuedMessage(nextQueuedMessage, thread)
244 error,360 : Promise.resolve(false);
245 });
246 return false;
247 },
248 )
249 : thread !== undefined
250 ? sendQueuedMessage(nextQueuedMessage, thread)
251 : Promise.resolve(false);
252 void delivery361 void delivery
253 .then((sent) => {362 .then((sent) => {
254 if (sent) {363 if (sent) {
...@@ -284,8 +393,11 @@ export function useThreadOutboxDrain(): void {...@@ -284,8 +393,11 @@ export function useThreadOutboxDrain(): void {
284 }, [393 }, [
285 connectedEnvironments,394 connectedEnvironments,
286 dispatchingQueuedMessageId,395 dispatchingQueuedMessageId,
396 editingQueuedMessageIds,
397 projects,
287 queuedMessagesByThreadKey,398 queuedMessagesByThreadKey,
288 retryTick,399 retryTick,
400 sendQueuedCreation,
289 sendQueuedMessage,401 sendQueuedMessage,
290 shellStatuses,402 shellStatuses,
291 threads,403 threads,
apps/mobile/src/state/use-thread-outbox.ts+31-1
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId } from "@t3tools/contracts";3import type { EnvironmentId, MessageId } from "@t3tools/contracts";
4import { Atom } from "effect/unstable/reactivity";4import { Atom } from "effect/unstable/reactivity";
55
6import { appAtomRegistry } from "./atom-registry";
6import { environmentShell } from "./shell";7import { environmentShell } from "./shell";
7import { threadOutboxManager } from "./thread-outbox";8import { threadOutboxManager } from "./thread-outbox";
89
...@@ -19,6 +20,35 @@ const threadOutboxShellStatusesAtom = Atom.make(...@@ -19,6 +20,35 @@ const threadOutboxShellStatusesAtom = Atom.make(
19 },20 },
20).pipe(Atom.withLabel("mobile:thread-outbox:shell-statuses"));21).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
22export function useThreadOutboxMessages() {52export function useThreadOutboxMessages() {
23 return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom);53 return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom);
24}54}