authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 12:31:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 12:31:03-07:00
logc0bf35466c8aa1862572dfa84b51b6fe30fc82d6
tree746778d4a3fc18035496aad81d8397dd429f7a1f
parent36c48a6b7c03c9a9adbc3e55d4d9ae77eae6f816
signature Signed by PGP key B5690EEEBB952194

feat(mobile): show new-task drafts alongside pending tasks in the thread list (#10260)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

12 files changed, 430 insertions(+), 120 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+10-13
...@@ -370,7 +370,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -370,7 +370,7 @@ export function HomeScreen(props: HomeScreenProps) {
370 ? props.pendingTasks370 ? props.pendingTasks
371 : props.pendingTasks.filter((pendingTask) =>371 : props.pendingTasks.filter((pendingTask) =>
372 selectedProjectRefKeys.has(372 selectedProjectRefKeys.has(
373 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),373 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
374 ),374 ),
375 ),375 ),
376 [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys],376 [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys],
...@@ -740,10 +740,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -740,10 +740,10 @@ export function HomeScreen(props: HomeScreenProps) {
740 props.pendingTasks.filter(740 props.pendingTasks.filter(
741 (pendingTask) =>741 (pendingTask) =>
742 (props.selectedEnvironmentId === null ||742 (props.selectedEnvironmentId === null ||
743 pendingTask.message.environmentId === props.selectedEnvironmentId) &&743 pendingTask.environmentId === props.selectedEnvironmentId) &&
744 (v2ScopedProjectKeys === null ||744 (v2ScopedProjectKeys === null ||
745 v2ScopedProjectKeys.has(745 v2ScopedProjectKeys.has(
746 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),746 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
747 )) &&747 )) &&
748 (v2SearchQuery.length === 0 ||748 (v2SearchQuery.length === 0 ||
749 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),749 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
...@@ -774,8 +774,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -774,8 +774,8 @@ export function HomeScreen(props: HomeScreenProps) {
774 (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);774 (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
775 if (item.type === "v2-pending") {775 if (item.type === "v2-pending") {
776 const pendingScopeKey = scopedProjectKey(776 const pendingScopeKey = scopedProjectKey(
777 item.pendingTask.message.environmentId,777 item.pendingTask.environmentId,
778 item.pendingTask.creation.projectId,778 item.pendingTask.projectId,
779 );779 );
780 return (780 return (
781 <ThreadListV2PendingRow781 <ThreadListV2PendingRow
...@@ -784,11 +784,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -784,11 +784,11 @@ export function HomeScreen(props: HomeScreenProps) {
784 projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)}784 projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)}
785 environmentLabel={785 environmentLabel={
786 Object.keys(props.savedConnectionsById).length > 1786 Object.keys(props.savedConnectionsById).length > 1
787 ? (props.savedConnectionsById[item.pendingTask.message.environmentId]787 ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ??
788 ?.environmentLabel ?? null)788 null)
789 : null789 : null
790 }790 }
791 environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)}791 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
792 showPendingDivider={item.showPendingDivider}792 showPendingDivider={item.showPendingDivider}
793 showTrailingDivider={showTrailingDivider}793 showTrailingDivider={showTrailingDivider}
794 onSelectPendingTask={props.onSelectPendingTask}794 onSelectPendingTask={props.onSelectPendingTask}
...@@ -984,12 +984,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -984,12 +984,9 @@ export function HomeScreen(props: HomeScreenProps) {
984 variant="compact"984 variant="compact"
985 pendingTask={item.pendingTask}985 pendingTask={item.pendingTask}
986 environmentLabel={986 environmentLabel={
987 props.savedConnectionsById[item.pendingTask.message.environmentId]987 props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
988 ?.environmentLabel ?? null
989 }988 }
990 environmentMachine={machineByEnvironmentId.get(989 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
991 item.pendingTask.message.environmentId,
992 )}
993 isLast={item.isLast}990 isLast={item.isLast}
994 onSelectPendingTask={props.onSelectPendingTask}991 onSelectPendingTask={props.onSelectPendingTask}
995 onDeletePendingTask={props.onDeletePendingTask}992 onDeletePendingTask={props.onDeletePendingTask}
apps/mobile/src/features/home/homeListItems.ts+1-1
...@@ -173,7 +173,7 @@ export function buildHomeListLayout(input: {...@@ -173,7 +173,7 @@ export function buildHomeListLayout(input: {
173 for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {173 for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
174 items.push({174 items.push({
175 type: "pending-task",175 type: "pending-task",
176 key: `pending-task:${pendingTask.message.messageId}`,176 key: pendingTask.key,
177 pendingTask,177 pendingTask,
178 isLast:178 isLast:
179 pendingIndex === group.pendingTasks.length - 1 &&179 pendingIndex === group.pendingTasks.length - 1 &&
apps/mobile/src/features/home/homeThreadList.ts+11-17
...@@ -105,10 +105,8 @@ export function sortHomeProjectScopes(input: {...@@ -105,10 +105,8 @@ export function sortHomeProjectScopes(input: {
105 }105 }
106 for (const pendingTask of input.pendingTasks) {106 for (const pendingTask of input.pendingTasks) {
107 recordActivity(107 recordActivity(
108 scopeKeyByProjectRef.get(108 scopeKeyByProjectRef.get(scopedProjectKey(pendingTask.environmentId, pendingTask.projectId)),
109 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),109 Date.parse(pendingTask.createdAt),
110 ),
111 Date.parse(pendingTask.message.createdAt),
112 );110 );
113 }111 }
114112
...@@ -177,7 +175,7 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr...@@ -177,7 +175,7 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr
177 Number.NEGATIVE_INFINITY,175 Number.NEGATIVE_INFINITY,
178 );176 );
179 return group.pendingTasks.reduce((latest, pendingTask) => {177 return group.pendingTasks.reduce((latest, pendingTask) => {
180 const timestamp = Date.parse(pendingTask.message.createdAt);178 const timestamp = Date.parse(pendingTask.createdAt);
181 return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp);179 return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp);
182 }, latestThread);180 }, latestThread);
183}181}
...@@ -235,14 +233,11 @@ export function buildHomeThreadGroups(input: {...@@ -235,14 +233,11 @@ export function buildHomeThreadGroups(input: {
235 }233 }
236234
237 for (const pendingTask of input.pendingTasks ?? []) {235 for (const pendingTask of input.pendingTasks ?? []) {
238 if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) {236 if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) {
239 continue;237 continue;
240 }238 }
241239
242 const physicalKey = scopedProjectKey(240 const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId);
243 pendingTask.message.environmentId,
244 pendingTask.creation.projectId,
245 );
246 let groupKey = groupKeyByProjectKey.get(physicalKey);241 let groupKey = groupKeyByProjectKey.get(physicalKey);
247 if (!groupKey) {242 if (!groupKey) {
248 // The project shell is not loaded (environment offline / project gone).243 // The project shell is not loaded (environment offline / project gone).
...@@ -254,16 +249,15 @@ export function buildHomeThreadGroups(input: {...@@ -254,16 +249,15 @@ export function buildHomeThreadGroups(input: {
254 key: groupKey,249 key: groupKey,
255 projects: [250 projects: [
256 {251 {
257 environmentId: pendingTask.message.environmentId,252 environmentId: pendingTask.environmentId,
258 id: pendingTask.creation.projectId,253 id: pendingTask.projectId,
259 title: pendingTask.creation.projectTitle ?? "Unknown project",254 title: pendingTask.projectTitle ?? "Unknown project",
260 workspaceRoot:255 workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId),
261 pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId),
262 repositoryIdentity: null,256 repositoryIdentity: null,
263 defaultModelSelection: null,257 defaultModelSelection: null,
264 scripts: [],258 scripts: [],
265 createdAt: pendingTask.message.createdAt,259 createdAt: pendingTask.createdAt,
266 updatedAt: pendingTask.message.createdAt,260 updatedAt: pendingTask.createdAt,
267 },261 },
268 ],262 ],
269 pendingTasks: [],263 pendingTasks: [],
apps/mobile/src/features/home/usePendingTaskListActions.ts+26-3
...@@ -3,6 +3,7 @@ import { useCallback } from "react";...@@ -3,6 +3,7 @@ import { useCallback } from "react";
3import { Alert } from "react-native";3import { Alert } from "react-native";
44
5import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";5import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
6import { clearComposerDraftContent } from "../../state/use-composer-drafts";
6import type { PendingNewTask } from "../../state/use-pending-new-tasks";7import type { PendingNewTask } from "../../state/use-pending-new-tasks";
7import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";8import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
89
...@@ -14,12 +15,16 @@ export function usePendingTaskListActions(): {...@@ -14,12 +15,16 @@ export function usePendingTaskListActions(): {
1415
15 const openPendingTask = useCallback(16 const openPendingTask = useCallback(
16 (pendingTask: PendingNewTask) => {17 (pendingTask: PendingNewTask) => {
18 // A draft is the project's own new-task composer content, so opening
19 // the project's new-task screen lands on it without extra params.
17 navigation.navigate("NewTaskSheet", {20 navigation.navigate("NewTaskSheet", {
18 screen: "NewTaskDraft",21 screen: "NewTaskDraft",
19 params: {22 params: {
20 environmentId: String(pendingTask.message.environmentId),23 environmentId: String(pendingTask.environmentId),
21 projectId: String(pendingTask.creation.projectId),24 projectId: String(pendingTask.projectId),
22 pendingTaskId: String(pendingTask.message.messageId),25 ...(pendingTask.kind === "pending"
26 ? { pendingTaskId: String(pendingTask.message.messageId) }
27 : {}),
23 },28 },
24 });29 });
25 },30 },
...@@ -27,6 +32,24 @@ export function usePendingTaskListActions(): {...@@ -27,6 +32,24 @@ export function usePendingTaskListActions(): {
27 );32 );
2833
29 const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => {34 const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => {
35 if (pendingTask.kind === "draft") {
36 Alert.alert("Discard draft?", `“${pendingTask.title}” will be removed.`, [
37 { text: "Cancel", style: "cancel" },
38 {
39 text: "Discard",
40 style: "destructive",
41 onPress: () => {
42 // Same reset a submit performs: the next task in this project
43 // re-resolves project defaults instead of inheriting the pick.
44 clearComposerDraftContent(pendingTask.draftKey, {
45 clearModelSelection: true,
46 clearWorkspaceSelection: true,
47 });
48 },
49 },
50 ]);
51 return;
52 }
30 Alert.alert(53 Alert.alert(
31 "Delete pending task?",54 "Delete pending task?",
32 `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`,55 `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+9-15
...@@ -300,7 +300,7 @@ function ThreadNavigationSidebarPane(...@@ -300,7 +300,7 @@ function ThreadNavigationSidebarPane(
300 ? pendingTasks300 ? pendingTasks
301 : pendingTasks.filter((pendingTask) =>301 : pendingTasks.filter((pendingTask) =>
302 selectedProjectRefs.has(302 selectedProjectRefs.has(
303 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),303 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
304 ),304 ),
305 ),305 ),
306 [threadListV2Enabled, pendingTasks, selectedProjectRefs],306 [threadListV2Enabled, pendingTasks, selectedProjectRefs],
...@@ -569,10 +569,10 @@ function ThreadNavigationSidebarPane(...@@ -569,10 +569,10 @@ function ThreadNavigationSidebarPane(
569 const v2PendingTasks = pendingTasks.filter(569 const v2PendingTasks = pendingTasks.filter(
570 (pendingTask) =>570 (pendingTask) =>
571 (options.selectedEnvironmentId === null ||571 (options.selectedEnvironmentId === null ||
572 pendingTask.message.environmentId === options.selectedEnvironmentId) &&572 pendingTask.environmentId === options.selectedEnvironmentId) &&
573 (selectedProjectRefs === null ||573 (selectedProjectRefs === null ||
574 selectedProjectRefs.has(574 selectedProjectRefs.has(
575 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),575 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
576 )) &&576 )) &&
577 (v2SearchQuery.length === 0 ||577 (v2SearchQuery.length === 0 ||
578 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),578 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
...@@ -852,8 +852,8 @@ function ThreadNavigationSidebarPane(...@@ -852,8 +852,8 @@ function ThreadNavigationSidebarPane(
852 switch (item.type) {852 switch (item.type) {
853 case "v2-pending": {853 case "v2-pending": {
854 const pendingScopeKey = scopedProjectKey(854 const pendingScopeKey = scopedProjectKey(
855 item.pendingTask.message.environmentId,855 item.pendingTask.environmentId,
856 item.pendingTask.creation.projectId,856 item.pendingTask.projectId,
857 );857 );
858 return (858 return (
859 <ThreadListV2PendingRow859 <ThreadListV2PendingRow
...@@ -862,13 +862,10 @@ function ThreadNavigationSidebarPane(...@@ -862,13 +862,10 @@ function ThreadNavigationSidebarPane(
862 projectTitle={projectTitleByProjectKey.get(pendingScopeKey)}862 projectTitle={projectTitleByProjectKey.get(pendingScopeKey)}
863 environmentLabel={863 environmentLabel={
864 Object.keys(savedConnectionsById).length > 1864 Object.keys(savedConnectionsById).length > 1
865 ? (savedConnectionsById[item.pendingTask.message.environmentId]865 ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null)
866 ?.environmentLabel ?? null)
867 : null866 : null
868 }867 }
869 environmentMachine={machineByEnvironmentId.get(868 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
870 item.pendingTask.message.environmentId,
871 )}
872 pane="sidebar"869 pane="sidebar"
873 showPendingDivider={item.showPendingDivider}870 showPendingDivider={item.showPendingDivider}
874 onSelectPendingTask={openPendingTask}871 onSelectPendingTask={openPendingTask}
...@@ -1006,12 +1003,9 @@ function ThreadNavigationSidebarPane(...@@ -1006,12 +1003,9 @@ function ThreadNavigationSidebarPane(
1006 variant="sidebar"1003 variant="sidebar"
1007 pendingTask={item.pendingTask}1004 pendingTask={item.pendingTask}
1008 environmentLabel={1005 environmentLabel={
1009 savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??1006 savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
1010 null
1011 }1007 }
1012 environmentMachine={machineByEnvironmentId.get(1008 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
1013 item.pendingTask.message.environmentId,
1014 )}
1015 isLast={item.isLast}1009 isLast={item.isLast}
1016 onSelectPendingTask={openPendingTask}1010 onSelectPendingTask={openPendingTask}
1017 onDeletePendingTask={confirmDeletePendingTask}1011 onDeletePendingTask={confirmDeletePendingTask}
apps/mobile/src/features/threads/thread-list-items.tsx+40-16
...@@ -267,10 +267,17 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [...@@ -267,10 +267,17 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
267 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },267 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
268];268];
269269
270const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
271 { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
272];
273
270/**274/**
271 * A queued new task waiting in the outbox for its environment to reconnect.275 * Unsent work: a task queued in the outbox for its environment to reconnect,
272 * Tapping reopens the new-task composer with everything prefilled; the row276 * or a draft still sitting in the project's new-task composer. Tapping
273 * disappears once the task is delivered and the real thread arrives.277 * reopens the composer with everything prefilled; the row disappears once
278 * the work is sent and the real thread arrives. The two kinds differ in what
279 * happens next, so the pill and icon say which one this is: a queued task
280 * sends itself, a draft waits for the user.
274 */281 */
275export const PendingTaskListRow = memo(function PendingTaskListRow(props: {282export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
276 readonly variant: ThreadListVariant;283 readonly variant: ThreadListVariant;
...@@ -284,10 +291,15 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -284,10 +291,15 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
284 const compact = props.variant === "compact";291 const compact = props.variant === "compact";
285292
286 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;293 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
287 const timestamp = relativeTime(pendingTask.message.createdAt);294 const isDraft = pendingTask.kind === "draft";
288 const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter(295 const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt);
289 (part): part is string => Boolean(part),296 // The pill only has room for one word, so what happens next goes in the
290 );297 // subtitle: a queued task sends itself, a draft waits for the user.
298 const subtitleParts = [
299 isDraft ? null : "Sends on reconnect",
300 props.environmentLabel,
301 pendingTask.branch,
302 ].filter((part): part is string => Boolean(part));
291303
292 const handleMenuAction = useCallback(304 const handleMenuAction = useCallback(
293 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {305 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
...@@ -296,7 +308,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -296,7 +308,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
296 [onDeletePendingTask, pendingTask],308 [onDeletePendingTask, pendingTask],
297 );309 );
298310
299 const statusPill = (311 const statusPill = isDraft ? (
312 <View className="rounded-full bg-adaptive-amber-500-a12-a16 px-1.5 py-0.5">
313 <Text className="text-3xs font-t3-bold text-adaptive-amber-700-300">Draft</Text>
314 </View>
315 ) : (
300 <View className="rounded-full bg-subtle px-1.5 py-0.5">316 <View className="rounded-full bg-subtle px-1.5 py-0.5">
301 <Text className="text-3xs font-t3-bold text-foreground-muted">Pending</Text>317 <Text className="text-3xs font-t3-bold text-foreground-muted">Pending</Text>
302 </View>318 </View>
...@@ -306,7 +322,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -306,7 +322,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
306 subtitleParts.length > 0 ? (322 subtitleParts.length > 0 ? (
307 <View className="mt-px flex-row items-center gap-1.5">323 <View className="mt-px flex-row items-center gap-1.5">
308 <SymbolView324 <SymbolView
309 name="tray.and.arrow.up"325 name={isDraft ? "square.and.pencil" : "tray.and.arrow.up"}
310 size={10}326 size={10}
311 tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"}327 tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"}
312 type="monochrome"328 type="monochrome"
...@@ -331,9 +347,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -331,9 +347,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
331 </View>347 </View>
332 ) : null;348 ) : null;
333349
350 const accessibilityHint = isDraft
351 ? "Opens the draft in the new task composer"
352 : "Sends when the environment reconnects. Opens the task for editing";
353
334 const rowContent = compact ? (354 const rowContent = compact ? (
335 <Pressable355 <Pressable
336 accessibilityHint="Opens the queued task for editing"356 accessibilityHint={accessibilityHint}
337 accessibilityLabel={pendingTask.title}357 accessibilityLabel={pendingTask.title}
338 accessibilityRole="button"358 accessibilityRole="button"
339 className="bg-screen active:opacity-70"359 className="bg-screen active:opacity-70"
...@@ -347,7 +367,9 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -347,7 +367,9 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
347 </Text>367 </Text>
348 <View className="flex-row items-center gap-2">368 <View className="flex-row items-center gap-2">
349 {statusPill}369 {statusPill}
350 <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text>370 {timestamp !== null ? (
371 <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text>
372 ) : null}
351 <SymbolView373 <SymbolView
352 name="chevron.right"374 name="chevron.right"
353 size={13}375 size={13}
...@@ -362,7 +384,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -362,7 +384,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
362 </Pressable>384 </Pressable>
363 ) : (385 ) : (
364 <Pressable386 <Pressable
365 accessibilityHint="Opens the queued task for editing"387 accessibilityHint={accessibilityHint}
366 accessibilityLabel={pendingTask.title}388 accessibilityLabel={pendingTask.title}
367 accessibilityRole="button"389 accessibilityRole="button"
368 className="active:bg-subtle"390 className="active:bg-subtle"
...@@ -383,9 +405,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -383,9 +405,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
383 </Text>405 </Text>
384 <View className="flex-row items-center gap-2">406 <View className="flex-row items-center gap-2">
385 {statusPill}407 {statusPill}
386 <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}>408 {timestamp !== null ? (
387 {timestamp}409 <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}>
388 </Text>410 {timestamp}
411 </Text>
412 ) : null}
389 </View>413 </View>
390 </View>414 </View>
391 {subtitleRow}415 {subtitleRow}
...@@ -395,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -395,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
395419
396 return (420 return (
397 <ControlPillMenu421 <ControlPillMenu
398 actions={PENDING_TASK_MENU_ACTIONS}422 actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
399 onPressAction={handleMenuAction}423 onPressAction={handleMenuAction}
400 shouldOpenOnLongPress424 shouldOpenOnLongPress
401 >425 >
apps/mobile/src/features/threads/thread-list-v2-items.tsx+34-14
...@@ -186,12 +186,16 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [...@@ -186,12 +186,16 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
186 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },186 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
187];187];
188188
189const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
190 { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
191];
192
189/**193/**
190 * A queued new task, in the same idiom as an active v2 row: it is work the194 * Unsent work, in the same idiom as an active v2 row: it is work the user
191 * user wrote, so it reads like the threads it will become. "Queued" takes195 * wrote, so it reads like the thread it will become. The status slot says
192 * the status slot — the state is the one thing that differs — and stays196 * what happens next, not where the item sits: "Sends on reconnect" stays
193 * uncolored because nothing is asked of the user; the environment is simply197 * uncolored because nothing is asked of the user; "Draft" takes the amber the
194 * not reachable yet.198 * web sidebar uses for drafts, because this one waits on the user.
195 */199 */
196export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: {200export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: {
197 readonly pendingTask: PendingNewTask;201 readonly pendingTask: PendingNewTask;
...@@ -201,7 +205,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -201,7 +205,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
201 /** Drawn beside the label; ignored while the label is null. */205 /** Drawn beside the label; ignored while the label is null. */
202 readonly environmentMachine?: EnvironmentMachineKind;206 readonly environmentMachine?: EnvironmentMachineKind;
203 readonly pane?: "screen" | "sidebar";207 readonly pane?: "screen" | "sidebar";
204 /** Draws the "Pending" divider above the first queued row. */208 /** Draws the "Unsent" divider above the first draft or queued row. */
205 readonly showPendingDivider: boolean;209 readonly showPendingDivider: boolean;
206 /** Keeps row hairlines inside a section; section headers draw their own rule. */210 /** Keeps row hairlines inside a section; section headers draw their own rule. */
207 readonly showTrailingDivider?: boolean;211 readonly showTrailingDivider?: boolean;
...@@ -210,9 +214,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -210,9 +214,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
210}) {214}) {
211 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;215 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
212 const sidebarPane = props.pane === "sidebar";216 const sidebarPane = props.pane === "sidebar";
213 const projectTitle =217 const isDraft = pendingTask.kind === "draft";
214 props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? "";218 const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? "";
215 const branch = pendingTask.creation.branch;219 const branch = pendingTask.branch;
216220
217 const handleMenuAction = useCallback(221 const handleMenuAction = useCallback(
218 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {222 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
...@@ -226,7 +230,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -226,7 +230,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
226 <View className="flex-row items-center gap-1.5">230 <View className="flex-row items-center gap-1.5">
227 {props.project ? (231 {props.project ? (
228 <ProjectFavicon232 <ProjectFavicon
229 environmentId={pendingTask.message.environmentId}233 environmentId={pendingTask.environmentId}
230 faviconPath={props.project.faviconPath}234 faviconPath={props.project.faviconPath}
231 size={15}235 size={15}
232 projectTitle={projectTitle}236 projectTitle={projectTitle}
...@@ -236,7 +240,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -236,7 +240,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
236 <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}>240 <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}>
237 {projectTitle}241 {projectTitle}
238 </Text>242 </Text>
239 <Text className="text-xs text-foreground-tertiary">Queued</Text>243 {isDraft ? (
244 <View className="flex-row items-center gap-1">
245 <SymbolView
246 name="square.and.pencil"
247 size={10}
248 tintColorClassName="accent-adaptive-amber-700-300"
249 type="monochrome"
250 />
251 <Text className="text-xs text-adaptive-amber-700-300">Draft</Text>
252 </View>
253 ) : (
254 <Text className="text-xs text-foreground-tertiary">Sends on reconnect</Text>
255 )}
240 </View>256 </View>
241 {/* One line, unlike the two an active row allows: a queued title is257 {/* One line, unlike the two an active row allows: a queued title is
242 derived from the whole prompt rather than written as a title, so the258 derived from the whole prompt rather than written as a title, so the
...@@ -272,15 +288,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -272,15 +288,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
272 return (288 return (
273 <>289 <>
274 {props.showPendingDivider ? (290 {props.showPendingDivider ? (
275 <ThreadListV2SectionDivider label="Pending" pane={props.pane} />291 <ThreadListV2SectionDivider label="Unsent" pane={props.pane} />
276 ) : null}292 ) : null}
277 <ControlPillMenu293 <ControlPillMenu
278 actions={PENDING_TASK_MENU_ACTIONS}294 actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
279 onPressAction={handleMenuAction}295 onPressAction={handleMenuAction}
280 shouldOpenOnLongPress296 shouldOpenOnLongPress
281 >297 >
282 <Pressable298 <Pressable
283 accessibilityHint="Opens the queued task for editing"299 accessibilityHint={
300 isDraft
301 ? "Opens the draft in the new task composer"
302 : "Sends when the environment reconnects. Opens the task for editing"
303 }
284 accessibilityLabel={pendingTask.title}304 accessibilityLabel={pendingTask.title}
285 accessibilityRole="button"305 accessibilityRole="button"
286 className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined}306 className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined}
apps/mobile/src/features/threads/threadListV2.test.ts+17-13
...@@ -866,7 +866,22 @@ describe("buildThreadListV2Items settled paging", () => {...@@ -866,7 +866,22 @@ describe("buildThreadListV2Items settled paging", () => {
866});866});
867867
868function makePendingTask(id: string): PendingNewTask {868function makePendingTask(id: string): PendingNewTask {
869 const creation = {
870 projectId: ProjectId.make("project-1"),
871 workspaceMode: "worktree" as const,
872 branch: null,
873 worktreePath: null,
874 };
869 return {875 return {
876 kind: "pending",
877 key: `pending-task:${id}`,
878 environmentId,
879 projectId: creation.projectId,
880 projectTitle: undefined,
881 projectCwd: undefined,
882 branch: null,
883 title: id,
884 createdAt: NOW,
870 message: {885 message: {
871 environmentId,886 environmentId,
872 threadId: ThreadId.make(`thread-${id}`),887 threadId: ThreadId.make(`thread-${id}`),
...@@ -875,20 +890,9 @@ function makePendingTask(id: string): PendingNewTask {...@@ -875,20 +890,9 @@ function makePendingTask(id: string): PendingNewTask {
875 text: id,890 text: id,
876 attachments: [],891 attachments: [],
877 createdAt: NOW,892 createdAt: NOW,
878 creation: {893 creation,
879 projectId: ProjectId.make("project-1"),
880 workspaceMode: "worktree",
881 branch: null,
882 worktreePath: null,
883 },
884 },894 },
885 creation: {895 creation,
886 projectId: ProjectId.make("project-1"),
887 workspaceMode: "worktree",
888 branch: null,
889 worktreePath: null,
890 },
891 title: id,
892 };896 };
893}897}
894898
apps/mobile/src/features/threads/threadListV2.ts+1-1
...@@ -298,7 +298,7 @@ export function buildThreadListV2ListItems(input: {...@@ -298,7 +298,7 @@ export function buildThreadListV2ListItems(input: {
298 }));298 }));
299 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({299 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
300 type: "v2-pending",300 type: "v2-pending",
301 key: `v2-pending:${pendingTask.message.messageId}`,301 key: `v2-${pendingTask.key}`,
302 pendingTask,302 pendingTask,
303 showPendingDivider: index === 0,303 showPendingDivider: index === 0,
304 }));304 }));
apps/mobile/src/state/pending-new-tasks-model.test.ts created+126
...@@ -0,0 +1,126 @@
1import { describe, expect, it } from "@effect/vitest";
2import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
3
4import type { QueuedThreadMessage } from "./thread-outbox-model";
5import type { ComposerDraft } from "./use-composer-drafts";
6import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model";
7
8const environmentId = EnvironmentId.make("env-1");
9const projectId = ProjectId.make("project-1");
10const NOW = "2026-09-05T12:00:00.000Z";
11
12function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
13 return {
14 environmentId,
15 threadId: ThreadId.make(`thread-${id}`),
16 messageId: MessageId.make(id),
17 commandId: CommandId.make(`command-${id}`),
18 text: `queued ${id}`,
19 attachments: [],
20 createdAt,
21 creation: {
22 projectId,
23 workspaceMode: "local",
24 branch: "main",
25 worktreePath: null,
26 },
27 };
28}
29
30function draft(text: string, overrides: Partial<ComposerDraft> = {}): ComposerDraft {
31 return { text, attachments: [], ...overrides };
32}
33
34describe("parseNewTaskDraftKey", () => {
35 it("splits the environment and project ids", () => {
36 expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({
37 environmentId,
38 projectId,
39 });
40 });
41
42 it("ignores thread drafts and pending-task editor drafts", () => {
43 expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull();
44 expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull();
45 expect(parseNewTaskDraftKey("new-task:")).toBeNull();
46 expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull();
47 });
48});
49
50describe("buildPendingNewTasks", () => {
51 it("surfaces new-task drafts with content alongside queued creations", () => {
52 const tasks = buildPendingNewTasks({
53 queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
54 drafts: {
55 [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", {
56 workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
57 }),
58 },
59 now: NOW,
60 });
61
62 expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([
63 ["draft", "fix the offline outbox", "main"],
64 ["pending", "queued a", "main"],
65 ]);
66 expect(tasks[0]).toMatchObject({
67 key: `draft-task:new-task:${environmentId}:${projectId}`,
68 environmentId,
69 projectId,
70 draftKey: `new-task:${environmentId}:${projectId}`,
71 });
72 });
73
74 it("hides settings-only drafts and drafts for other surfaces", () => {
75 const tasks = buildPendingNewTasks({
76 queuedMessages: [],
77 drafts: {
78 [`new-task:${environmentId}:${projectId}`]: draft("", {
79 modelSelection: { instanceId: "codex" as never, model: "gpt" },
80 }),
81 [`new-task:${environmentId}:${projectId}-2`]: draft(" "),
82 [`${environmentId}:thread-1`]: draft("thread composer text"),
83 "pending-task:message-1": draft("editor copy of a queued task"),
84 },
85 now: NOW,
86 });
87
88 expect(tasks).toEqual([]);
89 });
90
91 it("titles an attachment-only draft by its attachment count", () => {
92 const attachment = {
93 type: "image",
94 id: "image-1",
95 uri: "file:///image-1.png",
96 mimeType: "image/png",
97 name: "image-1.png",
98 width: 1,
99 height: 1,
100 sizeBytes: 1,
101 } as unknown as ComposerDraft["attachments"][number];
102 const tasks = buildPendingNewTasks({
103 queuedMessages: [],
104 drafts: {
105 [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }),
106 },
107 now: NOW,
108 });
109
110 expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
111 });
112
113 it("orders queued creations newest first and skips existing-thread messages", () => {
114 const tasks = buildPendingNewTasks({
115 queuedMessages: [
116 queuedCreation("old", "2026-09-05T08:00:00.000Z"),
117 { ...queuedCreation("follow-up", "2026-09-05T11:00:00.000Z"), creation: undefined },
118 queuedCreation("new", "2026-09-05T10:00:00.000Z"),
119 ],
120 drafts: {},
121 now: NOW,
122 });
123
124 expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
125 });
126});
apps/mobile/src/state/pending-new-tasks-model.ts created+134
...@@ -0,0 +1,134 @@
1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
2
3import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
4import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
5import type { ComposerDraft } from "./use-composer-drafts";
6
7/**
8 * Unsent work that will become a thread, shaped for thread-list presentation.
9 * A `pending` task sits in the outbox and sends itself when its environment
10 * reconnects; a `draft` is the project's new-task composer content, which
11 * only sends when the user submits it. Both share the list slot so the user
12 * can find everything they have written but not yet started in one place.
13 */
14export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
15
16export interface PendingQueuedTask {
17 readonly kind: "pending";
18 readonly key: string;
19 readonly environmentId: EnvironmentId;
20 readonly projectId: ProjectId;
21 readonly projectTitle: string | undefined;
22 readonly projectCwd: string | undefined;
23 readonly branch: string | null;
24 readonly title: string;
25 readonly createdAt: string;
26 readonly message: QueuedThreadMessage;
27 readonly creation: QueuedThreadCreation;
28}
29
30export interface PendingDraftTask {
31 readonly kind: "draft";
32 readonly key: string;
33 readonly environmentId: EnvironmentId;
34 readonly projectId: ProjectId;
35 readonly projectTitle: undefined;
36 readonly projectCwd: undefined;
37 readonly branch: string | null;
38 readonly title: string;
39 /** Drafts have no creation timestamp; they sort as current work. */
40 readonly createdAt: string;
41 readonly draftKey: string;
42 readonly draft: ComposerDraft;
43}
44
45const NEW_TASK_DRAFT_PREFIX = "new-task:";
46
47/** Parses a `new-task:<environmentId>:<projectId>` composer draft key. */
48export function parseNewTaskDraftKey(
49 draftKey: string,
50): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null {
51 if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) {
52 return null;
53 }
54 const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
55 const separator = scope.lastIndexOf(":");
56 if (separator <= 0 || separator === scope.length - 1) {
57 return null;
58 }
59 return {
60 environmentId: EnvironmentId.make(scope.slice(0, separator)),
61 projectId: ProjectId.make(scope.slice(separator + 1)),
62 };
63}
64
65/**
66 * Settings-only drafts (a model pick with no text) are not work the user
67 * would look for in the list; only text or attachments make a draft visible.
68 */
69export function composerDraftHasUserContent(draft: ComposerDraft): boolean {
70 return draft.text.trim().length > 0 || draft.attachments.length > 0;
71}
72
73function draftTitle(draft: ComposerDraft): string {
74 if (draft.text.trim().length > 0) {
75 return deriveThreadTitleFromPrompt(draft.text);
76 }
77 const count = draft.attachments.length;
78 return count === 1 ? "1 attachment" : `${count} attachments`;
79}
80
81export function buildPendingNewTasks(input: {
82 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
83 readonly drafts: Readonly<Record<string, ComposerDraft>>;
84 /** ISO timestamp drafts sort by; they carry no creation time of their own. */
85 readonly now: string;
86}): ReadonlyArray<PendingNewTask> {
87 const tasks: PendingNewTask[] = [];
88 for (const message of input.queuedMessages) {
89 if (!message.creation) {
90 continue;
91 }
92 tasks.push({
93 kind: "pending",
94 key: `pending-task:${message.messageId}`,
95 environmentId: message.environmentId,
96 projectId: message.creation.projectId,
97 projectTitle: message.creation.projectTitle,
98 projectCwd: message.creation.projectCwd,
99 branch: message.creation.branch,
100 title: deriveThreadTitleFromPrompt(message.text),
101 createdAt: message.createdAt,
102 message,
103 creation: message.creation,
104 });
105 }
106 for (const [draftKey, draft] of Object.entries(input.drafts)) {
107 const ref = parseNewTaskDraftKey(draftKey);
108 if (ref === null || !composerDraftHasUserContent(draft)) {
109 continue;
110 }
111 tasks.push({
112 kind: "draft",
113 key: `draft-task:${draftKey}`,
114 environmentId: ref.environmentId,
115 projectId: ref.projectId,
116 projectTitle: undefined,
117 projectCwd: undefined,
118 branch: draft.workspaceSelection?.branch ?? null,
119 title: draftTitle(draft),
120 createdAt: input.now,
121 draftKey,
122 draft,
123 });
124 }
125 // Drafts are what the user is writing now, so they lead; queued tasks
126 // follow newest-first.
127 tasks.sort((left, right) => {
128 if (left.kind !== right.kind) {
129 return left.kind === "draft" ? -1 : 1;
130 }
131 return right.createdAt.localeCompare(left.createdAt) || left.key.localeCompare(right.key);
132 });
133 return tasks;
134}
apps/mobile/src/state/use-pending-new-tasks.ts+21-27
...@@ -1,35 +1,29 @@...@@ -1,35 +1,29 @@
1import { useAtomValue } from "@effect/atom-react";
1import { useMemo } from "react";2import { useMemo } from "react";
23
3import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";4import { buildPendingNewTasks, type PendingNewTask } from "./pending-new-tasks-model";
4import {5import { flattenQueuedThreadMessages } from "./thread-outbox-model";
5 flattenQueuedThreadMessages,6import { composerDraftsAtom } from "./use-composer-drafts";
6 type QueuedThreadCreation,
7 type QueuedThreadMessage,
8} from "./thread-outbox-model";
9import { useThreadOutboxMessages } from "./use-thread-outbox";7import { useThreadOutboxMessages } from "./use-thread-outbox";
108
11/** A queued new-task creation, shaped for thread-list presentation. */9export type {
12export interface PendingNewTask {10 PendingDraftTask,
13 readonly message: QueuedThreadMessage;11 PendingNewTask,
14 readonly creation: QueuedThreadCreation;12 PendingQueuedTask,
15 readonly title: string;13} from "./pending-new-tasks-model";
16}
1714
18export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {15export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {
19 const queuedMessagesByThreadKey = useThreadOutboxMessages();16 const queuedMessagesByThreadKey = useThreadOutboxMessages();
20 return useMemo(() => {17 const drafts = useAtomValue(composerDraftsAtom);
21 const tasks: PendingNewTask[] = [];18 return useMemo(
22 for (const message of flattenQueuedThreadMessages(queuedMessagesByThreadKey)) {19 () =>
23 if (!message.creation) {20 buildPendingNewTasks({
24 continue;21 queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
25 }22 drafts,
26 tasks.push({23 // Stamped when the inputs change, not per render, so a draft keeps one
27 message,24 // sort position while the user is not typing in it.
28 creation: message.creation,25 now: new Date().toISOString(),
29 title: deriveThreadTitleFromPrompt(message.text),26 }),
30 });27 [queuedMessagesByThreadKey, drafts],
31 }28 );
32 tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt));
33 return tasks;
34 }, [queuedMessagesByThreadKey]);
35}29}