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) {
370370 ? props.pendingTasks
371371 : props.pendingTasks.filter((pendingTask) =>
372372 selectedProjectRefKeys.has(
373 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
373 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
374374 ),
375375 ),
376376 [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys],
......@@ -740,10 +740,10 @@ export function HomeScreen(props: HomeScreenProps) {
740740 props.pendingTasks.filter(
741741 (pendingTask) =>
742742 (props.selectedEnvironmentId === null ||
743 pendingTask.message.environmentId === props.selectedEnvironmentId) &&
743 pendingTask.environmentId === props.selectedEnvironmentId) &&
744744 (v2ScopedProjectKeys === null ||
745745 v2ScopedProjectKeys.has(
746 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
746 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
747747 )) &&
748748 (v2SearchQuery.length === 0 ||
749749 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
......@@ -774,8 +774,8 @@ export function HomeScreen(props: HomeScreenProps) {
774774 (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
775775 if (item.type === "v2-pending") {
776776 const pendingScopeKey = scopedProjectKey(
777 item.pendingTask.message.environmentId,
778 item.pendingTask.creation.projectId,
777 item.pendingTask.environmentId,
778 item.pendingTask.projectId,
779779 );
780780 return (
781781 <ThreadListV2PendingRow
......@@ -784,11 +784,11 @@ export function HomeScreen(props: HomeScreenProps) {
784784 projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)}
785785 environmentLabel={
786786 Object.keys(props.savedConnectionsById).length > 1
787 ? (props.savedConnectionsById[item.pendingTask.message.environmentId]
788 ?.environmentLabel ?? null)
787 ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ??
788 null)
789789 : null
790790 }
791 environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)}
791 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
792792 showPendingDivider={item.showPendingDivider}
793793 showTrailingDivider={showTrailingDivider}
794794 onSelectPendingTask={props.onSelectPendingTask}
......@@ -984,12 +984,9 @@ export function HomeScreen(props: HomeScreenProps) {
984984 variant="compact"
985985 pendingTask={item.pendingTask}
986986 environmentLabel={
987 props.savedConnectionsById[item.pendingTask.message.environmentId]
988 ?.environmentLabel ?? null
987 props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
989988 }
990 environmentMachine={machineByEnvironmentId.get(
991 item.pendingTask.message.environmentId,
992 )}
989 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
993990 isLast={item.isLast}
994991 onSelectPendingTask={props.onSelectPendingTask}
995992 onDeletePendingTask={props.onDeletePendingTask}
apps/mobile/src/features/home/homeListItems.ts+1-1
......@@ -173,7 +173,7 @@ export function buildHomeListLayout(input: {
173173 for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
174174 items.push({
175175 type: "pending-task",
176 key: `pending-task:${pendingTask.message.messageId}`,
176 key: pendingTask.key,
177177 pendingTask,
178178 isLast:
179179 pendingIndex === group.pendingTasks.length - 1 &&
apps/mobile/src/features/home/homeThreadList.ts+11-17
......@@ -105,10 +105,8 @@ export function sortHomeProjectScopes(input: {
105105 }
106106 for (const pendingTask of input.pendingTasks) {
107107 recordActivity(
108 scopeKeyByProjectRef.get(
109 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
110 ),
111 Date.parse(pendingTask.message.createdAt),
108 scopeKeyByProjectRef.get(scopedProjectKey(pendingTask.environmentId, pendingTask.projectId)),
109 Date.parse(pendingTask.createdAt),
112110 );
113111 }
114112
......@@ -177,7 +175,7 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr
177175 Number.NEGATIVE_INFINITY,
178176 );
179177 return group.pendingTasks.reduce((latest, pendingTask) => {
180 const timestamp = Date.parse(pendingTask.message.createdAt);
178 const timestamp = Date.parse(pendingTask.createdAt);
181179 return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp);
182180 }, latestThread);
183181}
......@@ -235,14 +233,11 @@ export function buildHomeThreadGroups(input: {
235233 }
236234
237235 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) {
239237 continue;
240238 }
241239
242 const physicalKey = scopedProjectKey(
243 pendingTask.message.environmentId,
244 pendingTask.creation.projectId,
245 );
240 const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId);
246241 let groupKey = groupKeyByProjectKey.get(physicalKey);
247242 if (!groupKey) {
248243 // The project shell is not loaded (environment offline / project gone).
......@@ -254,16 +249,15 @@ export function buildHomeThreadGroups(input: {
254249 key: groupKey,
255250 projects: [
256251 {
257 environmentId: pendingTask.message.environmentId,
258 id: pendingTask.creation.projectId,
259 title: pendingTask.creation.projectTitle ?? "Unknown project",
260 workspaceRoot:
261 pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId),
252 environmentId: pendingTask.environmentId,
253 id: pendingTask.projectId,
254 title: pendingTask.projectTitle ?? "Unknown project",
255 workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId),
262256 repositoryIdentity: null,
263257 defaultModelSelection: null,
264258 scripts: [],
265 createdAt: pendingTask.message.createdAt,
266 updatedAt: pendingTask.message.createdAt,
259 createdAt: pendingTask.createdAt,
260 updatedAt: pendingTask.createdAt,
267261 },
268262 ],
269263 pendingTasks: [],
apps/mobile/src/features/home/usePendingTaskListActions.ts+26-3
......@@ -3,6 +3,7 @@ import { useCallback } from "react";
33import { Alert } from "react-native";
44
55import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
6import { clearComposerDraftContent } from "../../state/use-composer-drafts";
67import type { PendingNewTask } from "../../state/use-pending-new-tasks";
78import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
89
......@@ -14,12 +15,16 @@ export function usePendingTaskListActions(): {
1415
1516 const openPendingTask = useCallback(
1617 (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.
1720 navigation.navigate("NewTaskSheet", {
1821 screen: "NewTaskDraft",
1922 params: {
20 environmentId: String(pendingTask.message.environmentId),
21 projectId: String(pendingTask.creation.projectId),
22 pendingTaskId: String(pendingTask.message.messageId),
23 environmentId: String(pendingTask.environmentId),
24 projectId: String(pendingTask.projectId),
25 ...(pendingTask.kind === "pending"
26 ? { pendingTaskId: String(pendingTask.message.messageId) }
27 : {}),
2328 },
2429 });
2530 },
......@@ -27,6 +32,24 @@ export function usePendingTaskListActions(): {
2732 );
2833
2934 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 }
3053 Alert.alert(
3154 "Delete pending task?",
3255 `“${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(
300300 ? pendingTasks
301301 : pendingTasks.filter((pendingTask) =>
302302 selectedProjectRefs.has(
303 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
303 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
304304 ),
305305 ),
306306 [threadListV2Enabled, pendingTasks, selectedProjectRefs],
......@@ -569,10 +569,10 @@ function ThreadNavigationSidebarPane(
569569 const v2PendingTasks = pendingTasks.filter(
570570 (pendingTask) =>
571571 (options.selectedEnvironmentId === null ||
572 pendingTask.message.environmentId === options.selectedEnvironmentId) &&
572 pendingTask.environmentId === options.selectedEnvironmentId) &&
573573 (selectedProjectRefs === null ||
574574 selectedProjectRefs.has(
575 scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
575 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
576576 )) &&
577577 (v2SearchQuery.length === 0 ||
578578 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
......@@ -852,8 +852,8 @@ function ThreadNavigationSidebarPane(
852852 switch (item.type) {
853853 case "v2-pending": {
854854 const pendingScopeKey = scopedProjectKey(
855 item.pendingTask.message.environmentId,
856 item.pendingTask.creation.projectId,
855 item.pendingTask.environmentId,
856 item.pendingTask.projectId,
857857 );
858858 return (
859859 <ThreadListV2PendingRow
......@@ -862,13 +862,10 @@ function ThreadNavigationSidebarPane(
862862 projectTitle={projectTitleByProjectKey.get(pendingScopeKey)}
863863 environmentLabel={
864864 Object.keys(savedConnectionsById).length > 1
865 ? (savedConnectionsById[item.pendingTask.message.environmentId]
866 ?.environmentLabel ?? null)
865 ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null)
867866 : null
868867 }
869 environmentMachine={machineByEnvironmentId.get(
870 item.pendingTask.message.environmentId,
871 )}
868 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
872869 pane="sidebar"
873870 showPendingDivider={item.showPendingDivider}
874871 onSelectPendingTask={openPendingTask}
......@@ -1006,12 +1003,9 @@ function ThreadNavigationSidebarPane(
10061003 variant="sidebar"
10071004 pendingTask={item.pendingTask}
10081005 environmentLabel={
1009 savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??
1010 null
1006 savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
10111007 }
1012 environmentMachine={machineByEnvironmentId.get(
1013 item.pendingTask.message.environmentId,
1014 )}
1008 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
10151009 isLast={item.isLast}
10161010 onSelectPendingTask={openPendingTask}
10171011 onDeletePendingTask={confirmDeletePendingTask}
apps/mobile/src/features/threads/thread-list-items.tsx+40-16
......@@ -267,10 +267,17 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
267267 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
268268];
269269
270const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
271 { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
272];
273
270274/**
271 * A queued new task waiting in the outbox for its environment to reconnect.
272 * Tapping reopens the new-task composer with everything prefilled; the row
273 * disappears once the task is delivered and the real thread arrives.
275 * Unsent work: a task queued in the outbox for its environment to reconnect,
276 * or a draft still sitting in the project's new-task composer. Tapping
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.
274281 */
275282export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
276283 readonly variant: ThreadListVariant;
......@@ -284,10 +291,15 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
284291 const compact = props.variant === "compact";
285292
286293 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
287 const timestamp = relativeTime(pendingTask.message.createdAt);
288 const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter(
289 (part): part is string => Boolean(part),
290 );
294 const isDraft = pendingTask.kind === "draft";
295 const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt);
296 // The pill only has room for one word, so what happens next goes in the
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
292304 const handleMenuAction = useCallback(
293305 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
......@@ -296,7 +308,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
296308 [onDeletePendingTask, pendingTask],
297309 );
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 ) : (
300316 <View className="rounded-full bg-subtle px-1.5 py-0.5">
301317 <Text className="text-3xs font-t3-bold text-foreground-muted">Pending</Text>
302318 </View>
......@@ -306,7 +322,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
306322 subtitleParts.length > 0 ? (
307323 <View className="mt-px flex-row items-center gap-1.5">
308324 <SymbolView
309 name="tray.and.arrow.up"
325 name={isDraft ? "square.and.pencil" : "tray.and.arrow.up"}
310326 size={10}
311327 tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"}
312328 type="monochrome"
......@@ -331,9 +347,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
331347 </View>
332348 ) : 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
334354 const rowContent = compact ? (
335355 <Pressable
336 accessibilityHint="Opens the queued task for editing"
356 accessibilityHint={accessibilityHint}
337357 accessibilityLabel={pendingTask.title}
338358 accessibilityRole="button"
339359 className="bg-screen active:opacity-70"
......@@ -347,7 +367,9 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
347367 </Text>
348368 <View className="flex-row items-center gap-2">
349369 {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}
351373 <SymbolView
352374 name="chevron.right"
353375 size={13}
......@@ -362,7 +384,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
362384 </Pressable>
363385 ) : (
364386 <Pressable
365 accessibilityHint="Opens the queued task for editing"
387 accessibilityHint={accessibilityHint}
366388 accessibilityLabel={pendingTask.title}
367389 accessibilityRole="button"
368390 className="active:bg-subtle"
......@@ -383,9 +405,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
383405 </Text>
384406 <View className="flex-row items-center gap-2">
385407 {statusPill}
386 <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}>
387 {timestamp}
388 </Text>
408 {timestamp !== null ? (
409 <Text className="text-xs tabular-nums text-foreground-muted" numberOfLines={1}>
410 {timestamp}
411 </Text>
412 ) : null}
389413 </View>
390414 </View>
391415 {subtitleRow}
......@@ -395,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
395419
396420 return (
397421 <ControlPillMenu
398 actions={PENDING_TASK_MENU_ACTIONS}
422 actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
399423 onPressAction={handleMenuAction}
400424 shouldOpenOnLongPress
401425 >
apps/mobile/src/features/threads/thread-list-v2-items.tsx+34-14
......@@ -186,12 +186,16 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
186186 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
187187];
188188
189const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
190 { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
191];
192
189193/**
190 * A queued new task, in the same idiom as an active v2 row: it is work the
191 * user wrote, so it reads like the threads it will become. "Queued" takes
192 * the status slot — the state is the one thing that differs — and stays
193 * uncolored because nothing is asked of the user; the environment is simply
194 * not reachable yet.
194 * Unsent work, in the same idiom as an active v2 row: it is work the user
195 * wrote, so it reads like the thread it will become. The status slot says
196 * what happens next, not where the item sits: "Sends on reconnect" stays
197 * uncolored because nothing is asked of the user; "Draft" takes the amber the
198 * web sidebar uses for drafts, because this one waits on the user.
195199 */
196200export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: {
197201 readonly pendingTask: PendingNewTask;
......@@ -201,7 +205,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
201205 /** Drawn beside the label; ignored while the label is null. */
202206 readonly environmentMachine?: EnvironmentMachineKind;
203207 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. */
205209 readonly showPendingDivider: boolean;
206210 /** Keeps row hairlines inside a section; section headers draw their own rule. */
207211 readonly showTrailingDivider?: boolean;
......@@ -210,9 +214,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
210214}) {
211215 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
212216 const sidebarPane = props.pane === "sidebar";
213 const projectTitle =
214 props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? "";
215 const branch = pendingTask.creation.branch;
217 const isDraft = pendingTask.kind === "draft";
218 const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? "";
219 const branch = pendingTask.branch;
216220
217221 const handleMenuAction = useCallback(
218222 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
......@@ -226,7 +230,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
226230 <View className="flex-row items-center gap-1.5">
227231 {props.project ? (
228232 <ProjectFavicon
229 environmentId={pendingTask.message.environmentId}
233 environmentId={pendingTask.environmentId}
230234 faviconPath={props.project.faviconPath}
231235 size={15}
232236 projectTitle={projectTitle}
......@@ -236,7 +240,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
236240 <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}>
237241 {projectTitle}
238242 </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 )}
240256 </View>
241257 {/* One line, unlike the two an active row allows: a queued title is
242258 derived from the whole prompt rather than written as a title, so the
......@@ -272,15 +288,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
272288 return (
273289 <>
274290 {props.showPendingDivider ? (
275 <ThreadListV2SectionDivider label="Pending" pane={props.pane} />
291 <ThreadListV2SectionDivider label="Unsent" pane={props.pane} />
276292 ) : null}
277293 <ControlPillMenu
278 actions={PENDING_TASK_MENU_ACTIONS}
294 actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
279295 onPressAction={handleMenuAction}
280296 shouldOpenOnLongPress
281297 >
282298 <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 }
284304 accessibilityLabel={pendingTask.title}
285305 accessibilityRole="button"
286306 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", () => {
866866});
867867
868868function 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 };
869875 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,
870885 message: {
871886 environmentId,
872887 threadId: ThreadId.make(`thread-${id}`),
......@@ -875,20 +890,9 @@ function makePendingTask(id: string): PendingNewTask {
875890 text: id,
876891 attachments: [],
877892 createdAt: NOW,
878 creation: {
879 projectId: ProjectId.make("project-1"),
880 workspaceMode: "worktree",
881 branch: null,
882 worktreePath: null,
883 },
893 creation,
884894 },
885 creation: {
886 projectId: ProjectId.make("project-1"),
887 workspaceMode: "worktree",
888 branch: null,
889 worktreePath: null,
890 },
891 title: id,
895 creation,
892896 };
893897}
894898
apps/mobile/src/features/threads/threadListV2.ts+1-1
......@@ -298,7 +298,7 @@ export function buildThreadListV2ListItems(input: {
298298 }));
299299 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
300300 type: "v2-pending",
301 key: `v2-pending:${pendingTask.message.messageId}`,
301 key: `v2-${pendingTask.key}`,
302302 pendingTask,
303303 showPendingDivider: index === 0,
304304 }));
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 @@
1import { useAtomValue } from "@effect/atom-react";
12import { useMemo } from "react";
23
3import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
4import {
5 flattenQueuedThreadMessages,
6 type QueuedThreadCreation,
7 type QueuedThreadMessage,
8} from "./thread-outbox-model";
4import { buildPendingNewTasks, type PendingNewTask } from "./pending-new-tasks-model";
5import { flattenQueuedThreadMessages } from "./thread-outbox-model";
6import { composerDraftsAtom } from "./use-composer-drafts";
97import { useThreadOutboxMessages } from "./use-thread-outbox";
108
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}
9export type {
10 PendingDraftTask,
11 PendingNewTask,
12 PendingQueuedTask,
13} from "./pending-new-tasks-model";
1714
1815export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {
1916 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]);
17 const drafts = useAtomValue(composerDraftsAtom);
18 return useMemo(
19 () =>
20 buildPendingNewTasks({
21 queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
22 drafts,
23 // Stamped when the inputs change, not per render, so a draft keeps one
24 // sort position while the user is not typing in it.
25 now: new Date().toISOString(),
26 }),
27 [queuedMessagesByThreadKey, drafts],
28 );
3529}