# Files changed · PR \#10260 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10260/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mobile\): show new-task drafts alongside pending tasks in the thread list](https://git.cafe/pingdotgg/t3code/pull/10260.md)

Version: 3

Head commit: 0286e753379f59474ff9bf68b4459a3c15c2798a

Base commit: 9d738dab392e70bad8083163f8214de4c14f2980

Comparison base: 9d738dab392e70bad8083163f8214de4c14f2980

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/home/HomeScreen.tsx

Status: modified; +10; −13

```diff
@@ -370,7 +370,7 @@
           ? props.pendingTasks
           : props.pendingTasks.filter((pendingTask) =>
               selectedProjectRefKeys.has(
-                scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+                scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
               ),
             ),
     [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys],
@@ -740,10 +740,10 @@
       props.pendingTasks.filter(
         (pendingTask) =>
           (props.selectedEnvironmentId === null ||
-            pendingTask.message.environmentId === props.selectedEnvironmentId) &&
+            pendingTask.environmentId === props.selectedEnvironmentId) &&
           (v2ScopedProjectKeys === null ||
             v2ScopedProjectKeys.has(
+              scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
-              scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
             )) &&
           (v2SearchQuery.length === 0 ||
             pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
@@ -774,8 +774,8 @@
         (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
       if (item.type === "v2-pending") {
         const pendingScopeKey = scopedProjectKey(
-          item.pendingTask.message.environmentId,
+          item.pendingTask.environmentId,
-          item.pendingTask.creation.projectId,
+          item.pendingTask.projectId,
         );
         return (
           <ThreadListV2PendingRow
@@ -784,11 +784,11 @@
             projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)}
             environmentLabel={
               Object.keys(props.savedConnectionsById).length > 1
+                ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ??
+                  null)
-                ? (props.savedConnectionsById[item.pendingTask.message.environmentId]
-                    ?.environmentLabel ?? null)
                 : null
             }
-            environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)}
+            environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
             showPendingDivider={item.showPendingDivider}
             showTrailingDivider={showTrailingDivider}
             onSelectPendingTask={props.onSelectPendingTask}
@@ -984,12 +984,9 @@
               variant="compact"
               pendingTask={item.pendingTask}
               environmentLabel={
+                props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
-                props.savedConnectionsById[item.pendingTask.message.environmentId]
-                  ?.environmentLabel ?? null
               }
+              environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
-              environmentMachine={machineByEnvironmentId.get(
-                item.pendingTask.message.environmentId,
-              )}
               isLast={item.isLast}
               onSelectPendingTask={props.onSelectPendingTask}
               onDeletePendingTask={props.onDeletePendingTask}
```

## apps/mobile/src/features/home/homeListItems.ts

Status: modified; +1; −1

```diff
@@ -173,7 +173,7 @@
     for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
       items.push({
         type: "pending-task",
-        key: `pending-task:${pendingTask.message.messageId}`,
+        key: pendingTask.key,
         pendingTask,
         isLast:
           pendingIndex === group.pendingTasks.length - 1 &&
```

## apps/mobile/src/features/home/homeThreadList.ts

Status: modified; +11; −17

```diff
@@ -105,10 +105,8 @@
   }
   for (const pendingTask of input.pendingTasks) {
     recordActivity(
-      scopeKeyByProjectRef.get(
-        scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+      scopeKeyByProjectRef.get(scopedProjectKey(pendingTask.environmentId, pendingTask.projectId)),
+      Date.parse(pendingTask.createdAt),
-      ),
-      Date.parse(pendingTask.message.createdAt),
     );
   }
 
@@ -177,7 +175,7 @@
     Number.NEGATIVE_INFINITY,
   );
   return group.pendingTasks.reduce((latest, pendingTask) => {
-    const timestamp = Date.parse(pendingTask.message.createdAt);
+    const timestamp = Date.parse(pendingTask.createdAt);
     return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp);
   }, latestThread);
 }
@@ -235,14 +233,11 @@
   }
 
   for (const pendingTask of input.pendingTasks ?? []) {
-    if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) {
+    if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) {
       continue;
     }
 
-    const physicalKey = scopedProjectKey(
-      pendingTask.message.environmentId,
-      pendingTask.creation.projectId,
-    );
+    const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId);
     let groupKey = groupKeyByProjectKey.get(physicalKey);
     if (!groupKey) {
       // The project shell is not loaded (environment offline / project gone).
@@ -254,16 +249,15 @@
         key: groupKey,
         projects: [
           {
-            environmentId: pendingTask.message.environmentId,
-            id: pendingTask.creation.projectId,
+            environmentId: pendingTask.environmentId,
-            title: pendingTask.creation.projectTitle ?? "Unknown project",
-            workspaceRoot:
-              pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId),
+            id: pendingTask.projectId,
+            title: pendingTask.projectTitle ?? "Unknown project",
+            workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId),
             repositoryIdentity: null,
             defaultModelSelection: null,
             scripts: [],
+            createdAt: pendingTask.createdAt,
-            createdAt: pendingTask.message.createdAt,
+            updatedAt: pendingTask.createdAt,
-            updatedAt: pendingTask.message.createdAt,
           },
         ],
         pendingTasks: [],
```

## apps/mobile/src/features/home/usePendingTaskListActions.ts

Status: modified; +26; −3

```diff
@@ -3,6 +3,7 @@
 import { Alert } from "react-native";
 
 import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
+import { clearComposerDraftContent } from "../../state/use-composer-drafts";
 import type { PendingNewTask } from "../../state/use-pending-new-tasks";
 import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
 
@@ -14,12 +15,16 @@
 
   const openPendingTask = useCallback(
     (pendingTask: PendingNewTask) => {
+      // A draft is the project's own new-task composer content, so opening
+      // the project's new-task screen lands on it without extra params.
       navigation.navigate("NewTaskSheet", {
         screen: "NewTaskDraft",
         params: {
+          environmentId: String(pendingTask.environmentId),
+          projectId: String(pendingTask.projectId),
+          ...(pendingTask.kind === "pending"
+            ? { pendingTaskId: String(pendingTask.message.messageId) }
+            : {}),
-          environmentId: String(pendingTask.message.environmentId),
-          projectId: String(pendingTask.creation.projectId),
-          pendingTaskId: String(pendingTask.message.messageId),
         },
       });
     },
@@ -27,6 +32,24 @@
   );
 
   const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => {
+    if (pendingTask.kind === "draft") {
+      Alert.alert("Discard draft?", `“${pendingTask.title}” will be removed.`, [
+        { text: "Cancel", style: "cancel" },
+        {
+          text: "Discard",
+          style: "destructive",
+          onPress: () => {
+            // Same reset a submit performs: the next task in this project
+            // re-resolves project defaults instead of inheriting the pick.
+            clearComposerDraftContent(pendingTask.draftKey, {
+              clearModelSelection: true,
+              clearWorkspaceSelection: true,
+            });
+          },
+        },
+      ]);
+      return;
+    }
     Alert.alert(
       "Delete pending task?",
       `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`,
```

## apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx

Status: modified; +9; −15

```diff
@@ -300,7 +300,7 @@
           ? pendingTasks
           : pendingTasks.filter((pendingTask) =>
               selectedProjectRefs.has(
-                scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+                scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
               ),
             ),
     [threadListV2Enabled, pendingTasks, selectedProjectRefs],
@@ -569,10 +569,10 @@
     const v2PendingTasks = pendingTasks.filter(
       (pendingTask) =>
         (options.selectedEnvironmentId === null ||
-          pendingTask.message.environmentId === options.selectedEnvironmentId) &&
+          pendingTask.environmentId === options.selectedEnvironmentId) &&
         (selectedProjectRefs === null ||
           selectedProjectRefs.has(
+            scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
-            scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
           )) &&
         (v2SearchQuery.length === 0 ||
           pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
@@ -852,8 +852,8 @@
       switch (item.type) {
         case "v2-pending": {
           const pendingScopeKey = scopedProjectKey(
-            item.pendingTask.message.environmentId,
+            item.pendingTask.environmentId,
-            item.pendingTask.creation.projectId,
+            item.pendingTask.projectId,
           );
           return (
             <ThreadListV2PendingRow
@@ -862,13 +862,10 @@
               projectTitle={projectTitleByProjectKey.get(pendingScopeKey)}
               environmentLabel={
                 Object.keys(savedConnectionsById).length > 1
-                  ? (savedConnectionsById[item.pendingTask.message.environmentId]
-                      ?.environmentLabel ?? null)
+                  ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null)
                   : null
               }
-              environmentMachine={machineByEnvironmentId.get(
-                item.pendingTask.message.environmentId,
-              )}
+              environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
               pane="sidebar"
               showPendingDivider={item.showPendingDivider}
               onSelectPendingTask={openPendingTask}
@@ -1006,12 +1003,9 @@
               variant="sidebar"
               pendingTask={item.pendingTask}
               environmentLabel={
-                savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??
-                null
+                savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
               }
-              environmentMachine={machineByEnvironmentId.get(
-                item.pendingTask.message.environmentId,
+              environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
-              )}
               isLast={item.isLast}
               onSelectPendingTask={openPendingTask}
               onDeletePendingTask={confirmDeletePendingTask}
```

## apps/mobile/src/features/threads/thread-list-items.tsx

Status: modified; +40; −16

```diff
@@ -267,10 +267,17 @@
   { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
 ];
 
+const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
+  { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
+];
+
 /**
- * A queued new task waiting in the outbox for its environment to reconnect.
- * Tapping reopens the new-task composer with everything prefilled; the row
- * disappears once the task is delivered and the real thread arrives.
+ * Unsent work: a task queued in the outbox for its environment to reconnect,
+ * or a draft still sitting in the project's new-task composer. Tapping
+ * reopens the composer with everything prefilled; the row disappears once
+ * the work is sent and the real thread arrives. The two kinds differ in what
+ * happens next, so the pill and icon say which one this is: a queued task
+ * sends itself, a draft waits for the user.
  */
 export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
   readonly variant: ThreadListVariant;
@@ -284,10 +291,15 @@
   const compact = props.variant === "compact";
 
   const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
-  const timestamp = relativeTime(pendingTask.message.createdAt);
+  const isDraft = pendingTask.kind === "draft";
+  const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt);
+  // The pill only has room for one word, so what happens next goes in the
+  // subtitle: a queued task sends itself, a draft waits for the user.
-  const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter(
-    (part): part is string => Boolean(part),
-  );
+  const subtitleParts = [
+    isDraft ? null : "Sends on reconnect",
+    props.environmentLabel,
+    pendingTask.branch,
+  ].filter((part): part is string => Boolean(part));
 
   const handleMenuAction = useCallback(
     ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
@@ -296,7 +308,11 @@
     [onDeletePendingTask, pendingTask],
   );
 
+  const statusPill = isDraft ? (
-  const statusPill = (
+    <View className="rounded-full bg-adaptive-amber-500-a12-a16 px-1.5 py-0.5">
+      <Text className="text-3xs font-t3-bold text-adaptive-amber-700-300">Draft</Text>
+    </View>
+  ) : (
     <View className="rounded-full bg-subtle px-1.5 py-0.5">
       <Text className="text-3xs font-t3-bold text-foreground-muted">Pending</Text>
     </View>
@@ -306,7 +322,7 @@
     subtitleParts.length > 0 ? (
       <View className="mt-px flex-row items-center gap-1.5">
         <SymbolView
-          name="tray.and.arrow.up"
+          name={isDraft ? "square.and.pencil" : "tray.and.arrow.up"}
           size={10}
           tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"}
           type="monochrome"
@@ -331,9 +347,13 @@
       </View>
     ) : null;
 
+  const accessibilityHint = isDraft
+    ? "Opens the draft in the new task composer"
+    : "Sends when the environment reconnects. Opens the task for editing";
+
   const rowContent = compact ? (
     <Pressable
+      accessibilityHint={accessibilityHint}
-      accessibilityHint="Opens the queued task for editing"
       accessibilityLabel={pendingTask.title}
       accessibilityRole="button"
       className="bg-screen active:opacity-70"
@@ -347,7 +367,9 @@
             </Text>
             <View className="flex-row items-center gap-2">
               {statusPill}
+              {timestamp !== null ? (
-              <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text>
+                <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text>
+              ) : null}
               <SymbolView
                 name="chevron.right"
                 size={13}
@@ -362,7 +384,7 @@
     </Pressable>
   ) : (
     <Pressable
-      accessibilityHint="Opens the queued task for editing"
+      accessibilityHint={accessibilityHint}
       accessibilityLabel={pendingTask.title}
       accessibilityRole="button"
       className="active:bg-subtle"
@@ -383,9 +405,11 @@
           </Text>
           <View className="flex-row items-center gap-2">
             {statusPill}
-            <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}>
-              {timestamp}
-            </Text>
+            {timestamp !== null ? (
+              <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}>
+                {timestamp}
+              </Text>
+            ) : null}
           </View>
         </View>
         {subtitleRow}
@@ -395,7 +419,7 @@
 
   return (
     <ControlPillMenu
+      actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
-      actions={PENDING_TASK_MENU_ACTIONS}
       onPressAction={handleMenuAction}
       shouldOpenOnLongPress
     >
```

## apps/mobile/src/features/threads/thread-list-v2-items.tsx

Status: modified; +34; −14

```diff
@@ -186,12 +186,16 @@
   { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
 ];
 
+const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
+  { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
+];
+
 /**
- * A queued new task, in the same idiom as an active v2 row: it is work the
- * user wrote, so it reads like the threads it will become. "Queued" takes
- * the status slot — the state is the one thing that differs — and stays
- * uncolored because nothing is asked of the user; the environment is simply
- * not reachable yet.
+ * Unsent work, in the same idiom as an active v2 row: it is work the user
+ * wrote, so it reads like the thread it will become. The status slot says
+ * what happens next, not where the item sits: "Sends on reconnect" stays
+ * uncolored because nothing is asked of the user; "Draft" takes the amber the
+ * web sidebar uses for drafts, because this one waits on the user.
  */
 export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: {
   readonly pendingTask: PendingNewTask;
@@ -201,7 +205,7 @@
   /** Drawn beside the label; ignored while the label is null. */
   readonly environmentMachine?: EnvironmentMachineKind;
   readonly pane?: "screen" | "sidebar";
-  /** Draws the "Pending" divider above the first queued row. */
+  /** Draws the "Unsent" divider above the first draft or queued row. */
   readonly showPendingDivider: boolean;
   /** Keeps row hairlines inside a section; section headers draw their own rule. */
   readonly showTrailingDivider?: boolean;
@@ -210,9 +214,9 @@
 }) {
   const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
   const sidebarPane = props.pane === "sidebar";
-  const projectTitle =
-    props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? "";
+  const isDraft = pendingTask.kind === "draft";
+  const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? "";
-  const branch = pendingTask.creation.branch;
+  const branch = pendingTask.branch;
 
   const handleMenuAction = useCallback(
     ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
@@ -226,7 +230,7 @@
       <View className="flex-row items-center gap-1.5">
         {props.project ? (
           <ProjectFavicon
+            environmentId={pendingTask.environmentId}
-            environmentId={pendingTask.message.environmentId}
             faviconPath={props.project.faviconPath}
             size={15}
             projectTitle={projectTitle}
@@ -236,7 +240,19 @@
         <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}>
           {projectTitle}
         </Text>
+        {isDraft ? (
+          <View className="flex-row items-center gap-1">
-        <Text className="text-xs text-foreground-tertiary">Queued</Text>
+            <SymbolView
+              name="square.and.pencil"
+              size={10}
+              tintColorClassName="accent-adaptive-amber-700-300"
+              type="monochrome"
+            />
+            <Text className="text-xs text-adaptive-amber-700-300">Draft</Text>
+          </View>
+        ) : (
+          <Text className="text-xs text-foreground-tertiary">Sends on reconnect</Text>
+        )}
       </View>
       {/* One line, unlike the two an active row allows: a queued title is
           derived from the whole prompt rather than written as a title, so the
@@ -272,15 +288,19 @@
   return (
     <>
       {props.showPendingDivider ? (
-        <ThreadListV2SectionDivider label="Pending" pane={props.pane} />
+        <ThreadListV2SectionDivider label="Unsent" pane={props.pane} />
       ) : null}
       <ControlPillMenu
+        actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
-        actions={PENDING_TASK_MENU_ACTIONS}
         onPressAction={handleMenuAction}
         shouldOpenOnLongPress
       >
         <Pressable
+          accessibilityHint={
+            isDraft
-          accessibilityHint="Opens the queued task for editing"
+              ? "Opens the draft in the new task composer"
+              : "Sends when the environment reconnects. Opens the task for editing"
+          }
           accessibilityLabel={pendingTask.title}
           accessibilityRole="button"
           className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined}
```

## apps/mobile/src/features/threads/threadListV2.test.ts

Status: modified; +17; −13

```diff
@@ -866,7 +866,22 @@
 });
 
 function makePendingTask(id: string): PendingNewTask {
+  const creation = {
+    projectId: ProjectId.make("project-1"),
+    workspaceMode: "worktree" as const,
+    branch: null,
+    worktreePath: null,
+  };
   return {
+    kind: "pending",
+    key: `pending-task:${id}`,
+    environmentId,
+    projectId: creation.projectId,
+    projectTitle: undefined,
+    projectCwd: undefined,
+    branch: null,
+    title: id,
+    createdAt: NOW,
     message: {
       environmentId,
       threadId: ThreadId.make(`thread-${id}`),
@@ -875,20 +890,9 @@
       text: id,
       attachments: [],
       createdAt: NOW,
-      creation: {
-        projectId: ProjectId.make("project-1"),
-        workspaceMode: "worktree",
-        branch: null,
-        worktreePath: null,
-      },
+      creation,
     },
+    creation,
-    creation: {
-      projectId: ProjectId.make("project-1"),
-      workspaceMode: "worktree",
-      branch: null,
-      worktreePath: null,
-    },
-    title: id,
   };
 }
 
```

## apps/mobile/src/features/threads/threadListV2.ts

Status: modified; +1; −1

```diff
@@ -298,7 +298,7 @@
   }));
   const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
     type: "v2-pending",
-    key: `v2-pending:${pendingTask.message.messageId}`,
+    key: `v2-${pendingTask.key}`,
     pendingTask,
     showPendingDivider: index === 0,
   }));
```

## apps/mobile/src/state/pending-new-tasks-model.test.ts

Status: added; +126; −0

```diff
@@ -1,0 +1,126 @@
+import { describe, expect, it } from "@effect/vitest";
+import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
+
+import type { QueuedThreadMessage } from "./thread-outbox-model";
+import type { ComposerDraft } from "./use-composer-drafts";
+import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model";
+
+const environmentId = EnvironmentId.make("env-1");
+const projectId = ProjectId.make("project-1");
+const NOW = "2026-09-05T12:00:00.000Z";
+
+function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
+  return {
+    environmentId,
+    threadId: ThreadId.make(`thread-${id}`),
+    messageId: MessageId.make(id),
+    commandId: CommandId.make(`command-${id}`),
+    text: `queued ${id}`,
+    attachments: [],
+    createdAt,
+    creation: {
+      projectId,
+      workspaceMode: "local",
+      branch: "main",
+      worktreePath: null,
+    },
+  };
+}
+
+function draft(text: string, overrides: Partial<ComposerDraft> = {}): ComposerDraft {
+  return { text, attachments: [], ...overrides };
+}
+
+describe("parseNewTaskDraftKey", () => {
+  it("splits the environment and project ids", () => {
+    expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({
+      environmentId,
+      projectId,
+    });
+  });
+
+  it("ignores thread drafts and pending-task editor drafts", () => {
+    expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull();
+    expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull();
+    expect(parseNewTaskDraftKey("new-task:")).toBeNull();
+    expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull();
+  });
+});
+
+describe("buildPendingNewTasks", () => {
+  it("surfaces new-task drafts with content alongside queued creations", () => {
+    const tasks = buildPendingNewTasks({
+      queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
+      drafts: {
+        [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", {
+          workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
+        }),
+      },
+      now: NOW,
+    });
+
+    expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([
+      ["draft", "fix the offline outbox", "main"],
+      ["pending", "queued a", "main"],
+    ]);
+    expect(tasks[0]).toMatchObject({
+      key: `draft-task:new-task:${environmentId}:${projectId}`,
+      environmentId,
+      projectId,
+      draftKey: `new-task:${environmentId}:${projectId}`,
+    });
+  });
+
+  it("hides settings-only drafts and drafts for other surfaces", () => {
+    const tasks = buildPendingNewTasks({
+      queuedMessages: [],
+      drafts: {
+        [`new-task:${environmentId}:${projectId}`]: draft("", {
+          modelSelection: { instanceId: "codex" as never, model: "gpt" },
+        }),
+        [`new-task:${environmentId}:${projectId}-2`]: draft("   "),
+        [`${environmentId}:thread-1`]: draft("thread composer text"),
+        "pending-task:message-1": draft("editor copy of a queued task"),
+      },
+      now: NOW,
+    });
+
+    expect(tasks).toEqual([]);
+  });
+
+  it("titles an attachment-only draft by its attachment count", () => {
+    const attachment = {
+      type: "image",
+      id: "image-1",
+      uri: "file:///image-1.png",
+      mimeType: "image/png",
+      name: "image-1.png",
+      width: 1,
+      height: 1,
+      sizeBytes: 1,
+    } as unknown as ComposerDraft["attachments"][number];
+    const tasks = buildPendingNewTasks({
+      queuedMessages: [],
+      drafts: {
+        [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }),
+      },
+      now: NOW,
+    });
+
+    expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
+  });
+
+  it("orders queued creations newest first and skips existing-thread messages", () => {
+    const tasks = buildPendingNewTasks({
+      queuedMessages: [
+        queuedCreation("old", "2026-09-05T08:00:00.000Z"),
+        { ...queuedCreation("follow-up", "2026-09-05T11:00:00.000Z"), creation: undefined },
+        queuedCreation("new", "2026-09-05T10:00:00.000Z"),
+      ],
+      drafts: {},
+      now: NOW,
+    });
+
+    expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
+  });
+});
```

## apps/mobile/src/state/pending-new-tasks-model.ts

Status: added; +134; −0

```diff
@@ -1,0 +1,134 @@
+import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+
+import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
+import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
+import type { ComposerDraft } from "./use-composer-drafts";
+
+/**
+ * Unsent work that will become a thread, shaped for thread-list presentation.
+ * A `pending` task sits in the outbox and sends itself when its environment
+ * reconnects; a `draft` is the project's new-task composer content, which
+ * only sends when the user submits it. Both share the list slot so the user
+ * can find everything they have written but not yet started in one place.
+ */
+export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
+
+export interface PendingQueuedTask {
+  readonly kind: "pending";
+  readonly key: string;
+  readonly environmentId: EnvironmentId;
+  readonly projectId: ProjectId;
+  readonly projectTitle: string | undefined;
+  readonly projectCwd: string | undefined;
+  readonly branch: string | null;
+  readonly title: string;
+  readonly createdAt: string;
+  readonly message: QueuedThreadMessage;
+  readonly creation: QueuedThreadCreation;
+}
+
+export interface PendingDraftTask {
+  readonly kind: "draft";
+  readonly key: string;
+  readonly environmentId: EnvironmentId;
+  readonly projectId: ProjectId;
+  readonly projectTitle: undefined;
+  readonly projectCwd: undefined;
+  readonly branch: string | null;
+  readonly title: string;
+  /** Drafts have no creation timestamp; they sort as current work. */
+  readonly createdAt: string;
+  readonly draftKey: string;
+  readonly draft: ComposerDraft;
+}
+
+const NEW_TASK_DRAFT_PREFIX = "new-task:";
+
+/** Parses a `new-task:<environmentId>:<projectId>` composer draft key. */
+export function parseNewTaskDraftKey(
+  draftKey: string,
+): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null {
+  if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) {
+    return null;
+  }
+  const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
+  const separator = scope.lastIndexOf(":");
+  if (separator <= 0 || separator === scope.length - 1) {
+    return null;
+  }
+  return {
+    environmentId: EnvironmentId.make(scope.slice(0, separator)),
+    projectId: ProjectId.make(scope.slice(separator + 1)),
+  };
+}
+
+/**
+ * Settings-only drafts (a model pick with no text) are not work the user
+ * would look for in the list; only text or attachments make a draft visible.
+ */
+export function composerDraftHasUserContent(draft: ComposerDraft): boolean {
+  return draft.text.trim().length > 0 || draft.attachments.length > 0;
+}
+
+function draftTitle(draft: ComposerDraft): string {
+  if (draft.text.trim().length > 0) {
+    return deriveThreadTitleFromPrompt(draft.text);
+  }
+  const count = draft.attachments.length;
+  return count === 1 ? "1 attachment" : `${count} attachments`;
+}
+
+export function buildPendingNewTasks(input: {
+  readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
+  readonly drafts: Readonly<Record<string, ComposerDraft>>;
+  /** ISO timestamp drafts sort by; they carry no creation time of their own. */
+  readonly now: string;
+}): ReadonlyArray<PendingNewTask> {
+  const tasks: PendingNewTask[] = [];
+  for (const message of input.queuedMessages) {
+    if (!message.creation) {
+      continue;
+    }
+    tasks.push({
+      kind: "pending",
+      key: `pending-task:${message.messageId}`,
+      environmentId: message.environmentId,
+      projectId: message.creation.projectId,
+      projectTitle: message.creation.projectTitle,
+      projectCwd: message.creation.projectCwd,
+      branch: message.creation.branch,
+      title: deriveThreadTitleFromPrompt(message.text),
+      createdAt: message.createdAt,
+      message,
+      creation: message.creation,
+    });
+  }
+  for (const [draftKey, draft] of Object.entries(input.drafts)) {
+    const ref = parseNewTaskDraftKey(draftKey);
+    if (ref === null || !composerDraftHasUserContent(draft)) {
+      continue;
+    }
+    tasks.push({
+      kind: "draft",
+      key: `draft-task:${draftKey}`,
+      environmentId: ref.environmentId,
+      projectId: ref.projectId,
+      projectTitle: undefined,
+      projectCwd: undefined,
+      branch: draft.workspaceSelection?.branch ?? null,
+      title: draftTitle(draft),
+      createdAt: input.now,
+      draftKey,
+      draft,
+    });
+  }
+  // Drafts are what the user is writing now, so they lead; queued tasks
+  // follow newest-first.
+  tasks.sort((left, right) => {
+    if (left.kind !== right.kind) {
+      return left.kind === "draft" ? -1 : 1;
+    }
+    return right.createdAt.localeCompare(left.createdAt) || left.key.localeCompare(right.key);
+  });
+  return tasks;
+}
```

## apps/mobile/src/state/use-pending-new-tasks.ts

Status: modified; +21; −27

```diff
@@ -1,35 +1,29 @@
+import { useAtomValue } from "@effect/atom-react";
 import { useMemo } from "react";
 
-import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
-import {
-  flattenQueuedThreadMessages,
-  type QueuedThreadCreation,
-  type QueuedThreadMessage,
+import { buildPendingNewTasks, type PendingNewTask } from "./pending-new-tasks-model";
+import { flattenQueuedThreadMessages } from "./thread-outbox-model";
-} from "./thread-outbox-model";
+import { composerDraftsAtom } from "./use-composer-drafts";
 import { useThreadOutboxMessages } from "./use-thread-outbox";
 
-/** A queued new-task creation, shaped for thread-list presentation. */
-export interface PendingNewTask {
-  readonly message: QueuedThreadMessage;
-  readonly creation: QueuedThreadCreation;
-  readonly title: string;
+export type {
+  PendingDraftTask,
-}
+  PendingNewTask,
+  PendingQueuedTask,
+} from "./pending-new-tasks-model";
 
 export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {
   const queuedMessagesByThreadKey = useThreadOutboxMessages();
-  return useMemo(() => {
-    const tasks: PendingNewTask[] = [];
-    for (const message of flattenQueuedThreadMessages(queuedMessagesByThreadKey)) {
-      if (!message.creation) {
-        continue;
-      }
-      tasks.push({
-        message,
-        creation: message.creation,
-        title: deriveThreadTitleFromPrompt(message.text),
-      });
-    }
-    tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt));
-    return tasks;
-  }, [queuedMessagesByThreadKey]);
+  const drafts = useAtomValue(composerDraftsAtom);
+  return useMemo(
+    () =>
+      buildPendingNewTasks({
+        queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
+        drafts,
+        // Stamped when the inputs change, not per render, so a draft keeps one
+        // sort position while the user is not typing in it.
+        now: new Date().toISOString(),
+      }),
+    [queuedMessagesByThreadKey, drafts],
+  );
 }
```
