| author | |
| committer | |
| log | 8e129a0dfb35ac2c03a7320245b5bfb976f5d3bf |
| tree | bc3c43712284eec3fb532bfd0cc04d18051d11ce |
| parent | c0bf35466c8aa1862572dfa84b51b6fe30fc82d6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>15 files changed, 664 insertions(+), 345 deletions(-)
apps/mobile/src/features/home/usePendingTaskListActions.ts+1-3| ... | ... | @@ -15,8 +15,6 @@ export function usePendingTaskListActions(): { |
| 15 | 15 | |
| 16 | 16 | const openPendingTask = useCallback( |
| 17 | 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. | |
| 20 | 18 | navigation.navigate("NewTaskSheet", { |
| 21 | 19 | screen: "NewTaskDraft", |
| 22 | 20 | params: { |
| ... | ... | @@ -24,7 +22,7 @@ export function usePendingTaskListActions(): { |
| 24 | 22 | projectId: String(pendingTask.projectId), |
| 25 | 23 | ...(pendingTask.kind === "pending" |
| 26 | 24 | ? { pendingTaskId: String(pendingTask.message.messageId) } |
| 27 | : {}), | |
| 25 | : { draftId: pendingTask.draftKey }), | |
| 28 | 26 | }, |
| 29 | 27 | }); |
| 30 | 28 | }, |
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+2| ... | ... | @@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = { |
| 9 | 9 | readonly projectId?: string | string[]; |
| 10 | 10 | readonly title?: string | string[]; |
| 11 | 11 | readonly pendingTaskId?: string | string[]; |
| 12 | readonly draftId?: string | string[]; | |
| 12 | 13 | readonly incomingShareId?: string | string[]; |
| 13 | 14 | }; |
| 14 | 15 | |
| ... | ... | @@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf |
| 43 | 44 | pendingTaskId={ |
| 44 | 45 | Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId |
| 45 | 46 | } |
| 47 | draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId} | |
| 46 | 48 | /> |
| 47 | 49 | </> |
| 48 | 50 | ); |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+46-4| ... | ... | @@ -83,6 +83,7 @@ import { |
| 83 | 83 | restoreComposerDraftSnapshot, |
| 84 | 84 | scheduleUnusedComposerAttachmentCleanup, |
| 85 | 85 | type ComposerDraft, |
| 86 | waitForComposerDraftsLoaded, | |
| 86 | 87 | } from "../../state/use-composer-drafts"; |
| 87 | 88 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; |
| 88 | 89 | import { |
| ... | ... | @@ -150,6 +151,8 @@ export function NewTaskDraftScreen(props: { |
| 150 | 151 | }; |
| 151 | 152 | /** Queued outbox message id when editing an existing pending task. */ |
| 152 | 153 | readonly pendingTaskId?: string; |
| 154 | /** Existing new-task draft key to resume (a Draft row in the thread list). */ | |
| 155 | readonly draftId?: string; | |
| 153 | 156 | /** Durable native share inbox item to merge into this project draft. */ |
| 154 | 157 | readonly incomingShareId?: string; |
| 155 | 158 | }) { |
| ... | ... | @@ -420,7 +423,44 @@ export function NewTaskDraftScreen(props: { |
| 420 | 423 | }; |
| 421 | 424 | }, []); |
| 422 | 425 | |
| 423 | const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow; | |
| 426 | const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow; | |
| 427 | // A Draft row opens its own draft; a fresh New Task never reuses one. | |
| 428 | // Drafts hydrate from disk and projects arrive with the shell snapshot, so | |
| 429 | // on a cold launch the draft or its project can be missing for a moment; | |
| 430 | // wait for hydration and retry while projects load. Attempt each id once | |
| 431 | // after that so a draft discarded mid-session does not keep bouncing to | |
| 432 | // the picker. | |
| 433 | const attemptedDraftIdRef = useRef<string | null>(null); | |
| 434 | useEffect(() => { | |
| 435 | if (!props.draftId || props.pendingTaskId) { | |
| 436 | return; | |
| 437 | } | |
| 438 | const draftId = props.draftId; | |
| 439 | if (attemptedDraftIdRef.current === draftId) { | |
| 440 | return; | |
| 441 | } | |
| 442 | let cancelled = false; | |
| 443 | void waitForComposerDraftsLoaded().then(() => { | |
| 444 | if (cancelled || attemptedDraftIdRef.current === draftId) { | |
| 445 | return; | |
| 446 | } | |
| 447 | if (openDraft(draftId)) { | |
| 448 | attemptedDraftIdRef.current = draftId; | |
| 449 | return; | |
| 450 | } | |
| 451 | if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) { | |
| 452 | // The draft exists; its project has not arrived yet. Retry on the | |
| 453 | // next projects change instead of giving up. | |
| 454 | return; | |
| 455 | } | |
| 456 | attemptedDraftIdRef.current = draftId; | |
| 457 | navigation.dispatch(StackActions.replace("NewTask")); | |
| 458 | }); | |
| 459 | return () => { | |
| 460 | cancelled = true; | |
| 461 | }; | |
| 462 | }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]); | |
| 463 | ||
| 424 | 464 | const attemptedPendingTaskIdRef = useRef<string | null>(null); |
| 425 | 465 | useEffect(() => { |
| 426 | 466 | if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { |
| ... | ... | @@ -457,9 +497,10 @@ export function NewTaskDraftScreen(props: { |
| 457 | 497 | const lastInitialProjectRefRef = useRef(props.initialProjectRef); |
| 458 | 498 | |
| 459 | 499 | useEffect(() => { |
| 460 | // Pending-task editing owns project selection (and must not fall through | |
| 461 | // to the replace("NewTask") fallback while its hydration is in flight). | |
| 462 | if (props.pendingTaskId) { | |
| 500 | // Pending-task editing and draft resumption own project selection (and | |
| 501 | // must not fall through to the replace("NewTask") fallback while their | |
| 502 | // hydration is in flight). | |
| 503 | if (props.pendingTaskId || props.draftId) { | |
| 463 | 504 | return; |
| 464 | 505 | } |
| 465 | 506 | if (lastInitialProjectRefRef.current !== props.initialProjectRef) { |
| ... | ... | @@ -518,6 +559,7 @@ export function NewTaskDraftScreen(props: { |
| 518 | 559 | props.initialProjectRef, |
| 519 | 560 | props.incomingShareId, |
| 520 | 561 | props.pendingTaskId, |
| 562 | props.draftId, | |
| 521 | 563 | navigation, |
| 522 | 564 | selectedProject, |
| 523 | 565 | selectedProjectKey, |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+68-13| ... | ... | @@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query"; |
| 43 | 43 | import { |
| 44 | 44 | appendComposerDraftAttachments, |
| 45 | 45 | clearComposerDraft, |
| 46 | copyComposerDraftContentIfEmpty, | |
| 46 | composerDraftsAtom, | |
| 47 | createNewTaskDraft, | |
| 47 | 48 | getComposerDraftSnapshot, |
| 48 | 49 | isComposerDraftEmpty, |
| 50 | isNewTaskDraftKey, | |
| 49 | 51 | removeComposerDraftAttachment, |
| 50 | 52 | replaceComposerDraftAttachments, |
| 53 | retargetNewTaskDraft, | |
| 51 | 54 | scheduleUnusedComposerAttachmentCleanup, |
| 52 | 55 | setComposerDraftText, |
| 53 | 56 | setStickyComposerModelSelection, |
| ... | ... | @@ -169,6 +172,12 @@ type NewTaskFlowContextValue = { |
| 169 | 172 | readonly filteredBranches: ReadonlyArray<VcsRef>; |
| 170 | 173 | readonly reset: () => void; |
| 171 | 174 | readonly setProject: (project: EnvironmentProject) => void; |
| 175 | /** | |
| 176 | * Binds the composer to an existing new-task draft (a row in the thread | |
| 177 | * list). Returns false when the draft is gone, so the caller can fall back | |
| 178 | * to a fresh one. | |
| 179 | */ | |
| 180 | readonly openDraft: (draftKey: string) => boolean; | |
| 172 | 181 | readonly selectEnvironment: (environmentId: EnvironmentId) => void; |
| 173 | 182 | readonly setSelectedModelKey: ( |
| 174 | 183 | key: string | null, |
| ... | ... | @@ -232,6 +241,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 232 | 241 | ? selectedEnvironmentIdOverride |
| 233 | 242 | : (projects[0]?.environmentId ?? null); |
| 234 | 243 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); |
| 244 | // The new-task draft the composer is bound to. Null until a project is | |
| 245 | // chosen; each New Task entry mints its own, so a project can hold several. | |
| 246 | const [activeDraftKey, setActiveDraftKey] = useState<string | null>(null); | |
| 235 | 247 | const [submitting, setSubmitting] = useState(false); |
| 236 | 248 | const [branchQuery, setBranchQuery] = useState(""); |
| 237 | 249 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| ... | ... | @@ -247,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 247 | 259 | const reset = useCallback(() => { |
| 248 | 260 | setSelectedEnvironmentId(null); |
| 249 | 261 | setSelectedProjectKey(null); |
| 262 | setActiveDraftKey(null); | |
| 250 | 263 | setSubmitting(false); |
| 251 | 264 | setBranchQuery(""); |
| 252 | 265 | setExpandedProvider(null); |
| ... | ... | @@ -367,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 367 | 380 | selectedProject?.environmentId ?? null, |
| 368 | 381 | ); |
| 369 | 382 | // While a queued pending task is being edited its draft lives under a key |
| 370 | // scoped to the queued message, so per-project new-task drafts stay intact. | |
| 383 | // scoped to the queued message, so new-task drafts stay intact. | |
| 371 | 384 | const selectedProjectDraftKey = editingPendingTask |
| 372 | 385 | ? pendingTaskDraftKey(editingPendingTask.messageId) |
| 373 | 386 | : selectedProject |
| 374 | ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` | |
| 387 | ? activeDraftKey | |
| 375 | 388 | : null; |
| 389 | // selectedProject can resolve without setProject ever running (the | |
| 390 | // environment's first project is the fallback, and the draft screen skips | |
| 391 | // setProject when the route's project already matches it). The composer | |
| 392 | // still needs a draft to write into, so bind one the moment a project is | |
| 393 | // in view and nothing else owns the key. | |
| 394 | useEffect(() => { | |
| 395 | if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) { | |
| 396 | return; | |
| 397 | } | |
| 398 | setActiveDraftKey( | |
| 399 | createNewTaskDraft({ | |
| 400 | environmentId: selectedProject.environmentId, | |
| 401 | projectId: selectedProject.id, | |
| 402 | }), | |
| 403 | ); | |
| 404 | }, [activeDraftKey, editingPendingTask, selectedProject]); | |
| 376 | 405 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); |
| 377 | 406 | const prompt = selectedProjectDraft.text; |
| 378 | 407 | const attachments = selectedProjectDraft.attachments; |
| ... | ... | @@ -625,20 +654,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 625 | 654 | ); |
| 626 | 655 | }, [availableBranches, branchQuery]); |
| 627 | 656 | |
| 628 | // New-task drafts are keyed per (environment, project), so retargeting the | |
| 629 | // composer would otherwise show the target's empty draft and strand what the | |
| 630 | // user typed under the old key. | |
| 657 | // The composer's draft follows the project it will be sent to: switching | |
| 658 | // mid-compose keeps the same draft and moves it, so typed text follows the | |
| 659 | // user. A pending-task edit owns its own key and is untouched here. | |
| 631 | 660 | const carryDraftContentTo = useCallback( |
| 632 | 661 | (project: EnvironmentProject) => { |
| 633 | const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`; | |
| 634 | if ( | |
| 635 | selectedProjectDraftKey?.startsWith("new-task:") && | |
| 636 | selectedProjectDraftKey !== nextDraftKey | |
| 637 | ) { | |
| 638 | void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); | |
| 662 | const target = { environmentId: project.environmentId, projectId: project.id }; | |
| 663 | if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) { | |
| 664 | retargetNewTaskDraft(activeDraftKey, target); | |
| 665 | } else if (!editingPendingTaskRef.current) { | |
| 666 | setActiveDraftKey(createNewTaskDraft(target)); | |
| 639 | 667 | } |
| 640 | 668 | }, |
| 641 | [selectedProjectDraftKey], | |
| 669 | [activeDraftKey], | |
| 642 | 670 | ); |
| 643 | 671 | |
| 644 | 672 | const setProject = useCallback( |
| ... | ... | @@ -650,6 +678,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 650 | 678 | [carryDraftContentTo], |
| 651 | 679 | ); |
| 652 | 680 | |
| 681 | const openDraft = useCallback( | |
| 682 | (draftKey: string): boolean => { | |
| 683 | const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; | |
| 684 | const stamp = draft?.project; | |
| 685 | if (!isNewTaskDraftKey(draftKey) || !stamp) { | |
| 686 | return false; | |
| 687 | } | |
| 688 | // The stamped project must be loaded: selectedProject falls back to | |
| 689 | // the environment's first project otherwise, and the draft would be | |
| 690 | // sent somewhere the user never chose. | |
| 691 | const projectLoaded = projects.some( | |
| 692 | (project) => | |
| 693 | project.environmentId === stamp.environmentId && project.id === stamp.projectId, | |
| 694 | ); | |
| 695 | if (!projectLoaded) { | |
| 696 | return false; | |
| 697 | } | |
| 698 | setActiveDraftKey(draftKey); | |
| 699 | setSelectedEnvironmentId(stamp.environmentId); | |
| 700 | setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId)); | |
| 701 | return true; | |
| 702 | }, | |
| 703 | [projects], | |
| 704 | ); | |
| 705 | ||
| 653 | 706 | const selectEnvironment = useCallback( |
| 654 | 707 | (environmentId: EnvironmentId) => { |
| 655 | 708 | const match = resolveEnvironmentProjectMatch( |
| ... | ... | @@ -1085,6 +1138,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1085 | 1138 | filteredBranches, |
| 1086 | 1139 | reset, |
| 1087 | 1140 | setProject, |
| 1141 | openDraft, | |
| 1088 | 1142 | selectEnvironment, |
| 1089 | 1143 | setSelectedModelKey, |
| 1090 | 1144 | setWorkspaceMode, |
| ... | ... | @@ -1148,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1148 | 1202 | selectedProjectKey, |
| 1149 | 1203 | selectedWorktreePath, |
| 1150 | 1204 | setProject, |
| 1205 | openDraft, | |
| 1151 | 1206 | selectBranch, |
| 1152 | 1207 | selectEnvironment, |
| 1153 | 1208 | setInteractionMode, |
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+18| ... | ... | @@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => { |
| 230 | 230 | ); |
| 231 | 231 | }); |
| 232 | 232 | |
| 233 | it("reads an id-keyed new-task draft's environment from its project stamp", () => { | |
| 234 | const stamped = { | |
| 235 | project: { | |
| 236 | environmentId, | |
| 237 | projectId: "project" as never, | |
| 238 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 239 | }, | |
| 240 | }; | |
| 241 | expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId); | |
| 242 | // An id-keyed draft that lost its stamp belongs to nobody: uploads must | |
| 243 | // not start and sign-out must not sweep it into some other environment. | |
| 244 | expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull(); | |
| 245 | // The stamp wins over a legacy-looking key when both are present. | |
| 246 | expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe( | |
| 247 | environmentId, | |
| 248 | ); | |
| 249 | }); | |
| 250 | ||
| 233 | 251 | it("allows offline queuing while a connected composer waits for upload or retry", () => { |
| 234 | 252 | const key = composerAttachmentUploadKey(environmentId, "file"); |
| 235 | 253 | const input = { |
apps/mobile/src/lib/composerAttachmentUploadQueue.ts+17-3| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; |
| 2 | 2 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; |
| 3 | 3 | |
| 4 | import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key"; | |
| 4 | 5 | import type { DraftComposerAttachment } from "./composerImages"; |
| 5 | 6 | |
| 6 | 7 | export interface ComposerAttachmentUploadRequest { |
| ... | ... | @@ -20,12 +21,19 @@ export function composerAttachmentUploadKey( |
| 20 | 21 | return `${environmentId}:${attachmentId}`; |
| 21 | 22 | } |
| 22 | 23 | |
| 24 | /** | |
| 25 | * Which environment a composer draft belongs to. Thread drafts carry it in | |
| 26 | * the key; pending-task editor drafts borrow it from the queued message; | |
| 27 | * new-task drafts carry it in their project stamp (legacy project-keyed | |
| 28 | * new-task drafts still parse from the key until they are migrated on load). | |
| 29 | */ | |
| 23 | 30 | export function composerDraftEnvironmentId( |
| 24 | 31 | draftKey: string, |
| 25 | 32 | queuedMessages: ReadonlyArray<{ |
| 26 | 33 | readonly messageId: string; |
| 27 | 34 | readonly environmentId: EnvironmentId; |
| 28 | 35 | }>, |
| 36 | draft?: { readonly project?: { readonly environmentId: EnvironmentId } }, | |
| 29 | 37 | ): EnvironmentId | null { |
| 30 | 38 | if (draftKey.startsWith("pending-task:")) { |
| 31 | 39 | return ( |
| ... | ... | @@ -33,9 +41,15 @@ export function composerDraftEnvironmentId( |
| 33 | 41 | ?.environmentId ?? null |
| 34 | 42 | ); |
| 35 | 43 | } |
| 36 | const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey; | |
| 37 | const separator = scope.lastIndexOf(":"); | |
| 38 | return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null; | |
| 44 | if (draftKey.startsWith("new-task:")) { | |
| 45 | if (draft?.project) { | |
| 46 | return draft.project.environmentId; | |
| 47 | } | |
| 48 | const legacy = parseLegacyNewTaskDraftKey(draftKey); | |
| 49 | return legacy === null ? null : EnvironmentId.make(legacy.environmentId); | |
| 50 | } | |
| 51 | const separator = draftKey.lastIndexOf(":"); | |
| 52 | return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null; | |
| 39 | 53 | } |
| 40 | 54 | |
| 41 | 55 | type UploadServerConfig = { |
apps/mobile/src/state/composer-attachment-uploads.ts+2-2| ... | ... | @@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() { |
| 79 | 79 | let retained = false; |
| 80 | 80 | for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { |
| 81 | 81 | if ( |
| 82 | composerDraftEnvironmentId(key, queued) === environmentId && | |
| 82 | composerDraftEnvironmentId(key, queued, draft) === environmentId && | |
| 83 | 83 | draft.attachments.some((candidate) => candidate.id === attachment.id) |
| 84 | 84 | ) { |
| 85 | 85 | retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; |
| ... | ... | @@ -113,7 +113,7 @@ export function useComposerAttachmentUploadWorker() { |
| 113 | 113 | .map((environment) => environment.environmentId), |
| 114 | 114 | ); |
| 115 | 115 | const requests = Object.entries(drafts).flatMap(([key, draft]) => { |
| 116 | const environmentId = composerDraftEnvironmentId(key, queued); | |
| 116 | const environmentId = composerDraftEnvironmentId(key, queued, draft); | |
| 117 | 117 | if (environmentId === null || !connected.has(environmentId)) return []; |
| 118 | 118 | return draft.attachments |
| 119 | 119 | .filter((attachment) => |
apps/mobile/src/state/new-task-draft-key.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | const NEW_TASK_DRAFT_PREFIX = "new-task:"; | |
| 2 | ||
| 3 | /** Every new-task draft key: `new-task:<draftId>`. */ | |
| 4 | export function newTaskDraftKey(draftId: string): string { | |
| 5 | return `${NEW_TASK_DRAFT_PREFIX}${draftId}`; | |
| 6 | } | |
| 7 | ||
| 8 | export function isNewTaskDraftKey(draftKey: string): boolean { | |
| 9 | return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX); | |
| 10 | } | |
| 11 | ||
| 12 | /** | |
| 13 | * Builds before drafts were id-keyed used `new-task:<environmentId>:<projectId>`, | |
| 14 | * one slot per project. Ids are UUIDs and never contain a colon, so a colon | |
| 15 | * after the prefix marks the legacy shape. Returns the split scope, or null | |
| 16 | * when the key is not legacy. | |
| 17 | */ | |
| 18 | export function parseLegacyNewTaskDraftKey( | |
| 19 | draftKey: string, | |
| 20 | ): { readonly environmentId: string; readonly projectId: string } | null { | |
| 21 | if (!isNewTaskDraftKey(draftKey)) { | |
| 22 | return null; | |
| 23 | } | |
| 24 | const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length); | |
| 25 | const separator = scope.lastIndexOf(":"); | |
| 26 | if (separator <= 0 || separator === scope.length - 1) { | |
| 27 | return null; | |
| 28 | } | |
| 29 | return { environmentId: scope.slice(0, separator), projectId: scope.slice(separator + 1) }; | |
| 30 | } |
apps/mobile/src/state/pending-new-tasks-model.test.ts+30-36| ... | ... | @@ -3,11 +3,10 @@ import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3too |
| 3 | 3 | |
| 4 | 4 | import type { QueuedThreadMessage } from "./thread-outbox-model"; |
| 5 | 5 | import type { ComposerDraft } from "./use-composer-drafts"; |
| 6 | import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model"; | |
| 6 | import { buildPendingNewTasks } from "./pending-new-tasks-model"; | |
| 7 | 7 | |
| 8 | 8 | const environmentId = EnvironmentId.make("env-1"); |
| 9 | 9 | const projectId = ProjectId.make("project-1"); |
| 10 | const NOW = "2026-09-05T12:00:00.000Z"; | |
| 11 | 10 | |
| 12 | 11 | function queuedCreation(id: string, createdAt: string): QueuedThreadMessage { |
| 13 | 12 | return { |
| ... | ... | @@ -27,62 +26,57 @@ function queuedCreation(id: string, createdAt: string): QueuedThreadMessage { |
| 27 | 26 | }; |
| 28 | 27 | } |
| 29 | 28 | |
| 30 | function draft(text: string, overrides: Partial<ComposerDraft> = {}): ComposerDraft { | |
| 31 | return { text, attachments: [], ...overrides }; | |
| 29 | function draft( | |
| 30 | text: string, | |
| 31 | createdAt: string, | |
| 32 | overrides: Partial<ComposerDraft> = {}, | |
| 33 | ): ComposerDraft { | |
| 34 | return { | |
| 35 | text, | |
| 36 | attachments: [], | |
| 37 | project: { environmentId, projectId, createdAt }, | |
| 38 | ...overrides, | |
| 39 | }; | |
| 32 | 40 | } |
| 33 | 41 | |
| 34 | describe("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 | ||
| 50 | 42 | describe("buildPendingNewTasks", () => { |
| 51 | it("surfaces new-task drafts with content alongside queued creations", () => { | |
| 43 | it("surfaces every new-task draft with content alongside queued creations", () => { | |
| 52 | 44 | const tasks = buildPendingNewTasks({ |
| 53 | 45 | queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")], |
| 54 | 46 | drafts: { |
| 55 | [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", { | |
| 47 | "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", { | |
| 56 | 48 | workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null }, |
| 57 | 49 | }), |
| 50 | "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"), | |
| 58 | 51 | }, |
| 59 | now: NOW, | |
| 60 | 52 | }); |
| 61 | 53 | |
| 62 | 54 | expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([ |
| 63 | ["draft", "fix the offline outbox", "main"], | |
| 55 | ["draft", "second idea", null], | |
| 56 | ["draft", "first idea", "main"], | |
| 64 | 57 | ["pending", "queued a", "main"], |
| 65 | 58 | ]); |
| 66 | expect(tasks[0]).toMatchObject({ | |
| 67 | key: `draft-task:new-task:${environmentId}:${projectId}`, | |
| 59 | expect(tasks[1]).toMatchObject({ | |
| 60 | key: "draft-task:new-task:draft-old", | |
| 68 | 61 | environmentId, |
| 69 | 62 | projectId, |
| 70 | draftKey: `new-task:${environmentId}:${projectId}`, | |
| 63 | draftKey: "new-task:draft-old", | |
| 64 | createdAt: "2026-09-05T09:00:00.000Z", | |
| 71 | 65 | }); |
| 72 | 66 | }); |
| 73 | 67 | |
| 74 | it("hides settings-only drafts and drafts for other surfaces", () => { | |
| 68 | it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => { | |
| 75 | 69 | const tasks = buildPendingNewTasks({ |
| 76 | 70 | queuedMessages: [], |
| 77 | 71 | drafts: { |
| 78 | [`new-task:${environmentId}:${projectId}`]: draft("", { | |
| 72 | "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", { | |
| 79 | 73 | modelSelection: { instanceId: "codex" as never, model: "gpt" }, |
| 80 | 74 | }), |
| 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"), | |
| 75 | "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"), | |
| 76 | "new-task:unstamped": { text: "no project", attachments: [] }, | |
| 77 | [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] }, | |
| 78 | "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] }, | |
| 84 | 79 | }, |
| 85 | now: NOW, | |
| 86 | 80 | }); |
| 87 | 81 | |
| 88 | 82 | expect(tasks).toEqual([]); |
| ... | ... | @@ -102,9 +96,10 @@ describe("buildPendingNewTasks", () => { |
| 102 | 96 | const tasks = buildPendingNewTasks({ |
| 103 | 97 | queuedMessages: [], |
| 104 | 98 | drafts: { |
| 105 | [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }), | |
| 99 | "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", { | |
| 100 | attachments: [attachment], | |
| 101 | }), | |
| 106 | 102 | }, |
| 107 | now: NOW, | |
| 108 | 103 | }); |
| 109 | 104 | |
| 110 | 105 | expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]); |
| ... | ... | @@ -118,7 +113,6 @@ describe("buildPendingNewTasks", () => { |
| 118 | 113 | queuedCreation("new", "2026-09-05T10:00:00.000Z"), |
| 119 | 114 | ], |
| 120 | 115 | drafts: {}, |
| 121 | now: NOW, | |
| 122 | 116 | }); |
| 123 | 117 | |
| 124 | 118 | expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]); |
apps/mobile/src/state/pending-new-tasks-model.ts+11-34| ... | ... | @@ -1,15 +1,16 @@ |
| 1 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; |
| 4 | 4 | import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model"; |
| 5 | import { isNewTaskDraftKey } from "./new-task-draft-key"; | |
| 5 | 6 | import type { ComposerDraft } from "./use-composer-drafts"; |
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | 9 | * Unsent work that will become a thread, shaped for thread-list presentation. |
| 9 | 10 | * 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. | |
| 11 | * reconnects; a `draft` is new-task composer content, which only sends when | |
| 12 | * the user submits it. Both share the list slot so the user can find | |
| 13 | * everything they have written but not yet started in one place. | |
| 13 | 14 | */ |
| 14 | 15 | export type PendingNewTask = PendingQueuedTask | PendingDraftTask; |
| 15 | 16 | |
| ... | ... | @@ -36,32 +37,11 @@ export interface PendingDraftTask { |
| 36 | 37 | readonly projectCwd: undefined; |
| 37 | 38 | readonly branch: string | null; |
| 38 | 39 | readonly title: string; |
| 39 | /** Drafts have no creation timestamp; they sort as current work. */ | |
| 40 | 40 | readonly createdAt: string; |
| 41 | 41 | readonly draftKey: string; |
| 42 | 42 | readonly draft: ComposerDraft; |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | const NEW_TASK_DRAFT_PREFIX = "new-task:"; | |
| 46 | ||
| 47 | /** Parses a `new-task:<environmentId>:<projectId>` composer draft key. */ | |
| 48 | export 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 | 45 | /** |
| 66 | 46 | * Settings-only drafts (a model pick with no text) are not work the user |
| 67 | 47 | * would look for in the list; only text or attachments make a draft visible. |
| ... | ... | @@ -81,8 +61,6 @@ function draftTitle(draft: ComposerDraft): string { |
| 81 | 61 | export function buildPendingNewTasks(input: { |
| 82 | 62 | readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; |
| 83 | 63 | 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 | 64 | }): ReadonlyArray<PendingNewTask> { |
| 87 | 65 | const tasks: PendingNewTask[] = []; |
| 88 | 66 | for (const message of input.queuedMessages) { |
| ... | ... | @@ -104,26 +82,25 @@ export function buildPendingNewTasks(input: { |
| 104 | 82 | }); |
| 105 | 83 | } |
| 106 | 84 | for (const [draftKey, draft] of Object.entries(input.drafts)) { |
| 107 | const ref = parseNewTaskDraftKey(draftKey); | |
| 108 | if (ref === null || !composerDraftHasUserContent(draft)) { | |
| 85 | if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) { | |
| 109 | 86 | continue; |
| 110 | 87 | } |
| 111 | 88 | tasks.push({ |
| 112 | 89 | kind: "draft", |
| 113 | 90 | key: `draft-task:${draftKey}`, |
| 114 | environmentId: ref.environmentId, | |
| 115 | projectId: ref.projectId, | |
| 91 | environmentId: draft.project.environmentId, | |
| 92 | projectId: draft.project.projectId, | |
| 116 | 93 | projectTitle: undefined, |
| 117 | 94 | projectCwd: undefined, |
| 118 | 95 | branch: draft.workspaceSelection?.branch ?? null, |
| 119 | 96 | title: draftTitle(draft), |
| 120 | createdAt: input.now, | |
| 97 | createdAt: draft.project.createdAt, | |
| 121 | 98 | draftKey, |
| 122 | 99 | draft, |
| 123 | 100 | }); |
| 124 | 101 | } |
| 125 | // Drafts are what the user is writing now, so they lead; queued tasks | |
| 126 | // follow newest-first. | |
| 102 | // Drafts are what the user is writing now, so they lead; within each kind, | |
| 103 | // newest first. | |
| 127 | 104 | tasks.sort((left, right) => { |
| 128 | 105 | if (left.kind !== right.kind) { |
| 129 | 106 | return left.kind === "draft" ? -1 : 1; |
apps/mobile/src/state/use-composer-drafts.test.ts+203-120| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | CommandId, |
| 4 | 4 | EnvironmentId, |
| 5 | 5 | MessageId, |
| 6 | ProjectId, | |
| 6 | 7 | ProviderInstanceId, |
| 7 | 8 | ThreadId, |
| 8 | 9 | } from "@t3tools/contracts"; |
| ... | ... | @@ -157,20 +158,22 @@ import { |
| 157 | 158 | ComposerDraftPersistenceError, |
| 158 | 159 | composerDraftsAtom, |
| 159 | 160 | composerCloudDraftsAtom, |
| 160 | copyComposerDraftContentIfEmpty, | |
| 161 | copyComposerDraftContentState, | |
| 161 | createNewTaskDraft, | |
| 162 | 162 | decodePersistedComposerState, |
| 163 | 163 | ensureComposerDraftsLoaded, |
| 164 | 164 | type ComposerDraft, |
| 165 | findNewTaskDraftKeys, | |
| 165 | 166 | flushComposerDrafts, |
| 166 | 167 | getComposerDraftSnapshot, |
| 167 | 168 | mergeComposerDraftContentState, |
| 169 | migrateLegacyNewTaskDraft, | |
| 168 | 170 | releaseUnusedComposerAttachmentFiles, |
| 169 | 171 | removeComposerDraftsForEnvironment, |
| 170 | 172 | resetComposerDraftsLoadState, |
| 171 | 173 | retainComposerAttachmentFileForPreview, |
| 172 | 174 | restoreComposerDraftSnapshotState, |
| 173 | 175 | restoreCloudComposerDrafts, |
| 176 | retargetNewTaskDraft, | |
| 174 | 177 | setComposerDraftText, |
| 175 | 178 | setComposerDraftAttachmentUpload, |
| 176 | 179 | waitForComposerDraftsLoaded, |
| ... | ... | @@ -210,37 +213,6 @@ afterEach(() => { |
| 210 | 213 | describe("mobile composer drafts", () => { |
| 211 | 214 | // Hydration is one-shot per module instance and the attachment sweep now |
| 212 | 215 | // triggers it too, so this test must observe it before any sweep test runs. |
| 213 | it("waits for persisted drafts before copying content between projects", async () => { | |
| 214 | const sourceKey = "new-task:environment-1:project-1"; | |
| 215 | const targetKey = "new-task:environment-1:project-2"; | |
| 216 | const unrelatedKey = "environment-1:thread-1"; | |
| 217 | const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; | |
| 218 | const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; | |
| 219 | const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; | |
| 220 | ||
| 221 | composerDraftFileMocks.setDocument({ | |
| 222 | schemaVersion: 1, | |
| 223 | drafts: { | |
| 224 | [targetKey]: target, | |
| 225 | [unrelatedKey]: unrelated, | |
| 226 | }, | |
| 227 | }); | |
| 228 | composerDraftFileMocks.blockRead(); | |
| 229 | appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); | |
| 230 | ||
| 231 | const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); | |
| 232 | expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); | |
| 233 | ||
| 234 | composerDraftFileMocks.releaseRead(); | |
| 235 | await copy; | |
| 236 | ||
| 237 | expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ | |
| 238 | [sourceKey]: source, | |
| 239 | [targetKey]: target, | |
| 240 | [unrelatedKey]: unrelated, | |
| 241 | }); | |
| 242 | }); | |
| 243 | ||
| 244 | 216 | it("hydrates generic file attachments from their saved local paths", () => { |
| 245 | 217 | const file = { |
| 246 | 218 | id: "file-1", |
| ... | ... | @@ -1009,7 +981,7 @@ describe("mobile composer drafts", () => { |
| 1009 | 981 | }); |
| 1010 | 982 | |
| 1011 | 983 | it("hydrates selector state even when the message content is empty", () => { |
| 1012 | expect( | |
| 984 | const hydrated = Object.entries( | |
| 1013 | 985 | decodePersistedComposerState({ |
| 1014 | 986 | schemaVersion: 1, |
| 1015 | 987 | drafts: { |
| ... | ... | @@ -1031,26 +1003,33 @@ describe("mobile composer drafts", () => { |
| 1031 | 1003 | }, |
| 1032 | 1004 | }, |
| 1033 | 1005 | }).drafts, |
| 1034 | ).toEqual({ | |
| 1035 | "new-task:environment-1:project-1": { | |
| 1036 | text: "", | |
| 1037 | attachments: [], | |
| 1038 | modelSelection: { | |
| 1039 | instanceId: "codex", | |
| 1040 | model: "gpt-5.4", | |
| 1041 | options: [{ id: "reasoningEffort", value: "xhigh" }], | |
| 1042 | }, | |
| 1043 | runtimeMode: "approval-required", | |
| 1044 | interactionMode: "plan", | |
| 1045 | workspaceSelection: { | |
| 1046 | mode: "worktree", | |
| 1047 | branch: "main", | |
| 1048 | worktreePath: null, | |
| 1049 | }, | |
| 1006 | ); | |
| 1007 | expect(hydrated).toHaveLength(1); | |
| 1008 | const [key, draft] = hydrated[0]!; | |
| 1009 | // Legacy project keys are rewritten to id keys on load. | |
| 1010 | expect(key).toMatch(/^new-task:[0-9a-z-]+$/); | |
| 1011 | expect(draft).toEqual({ | |
| 1012 | text: "", | |
| 1013 | attachments: [], | |
| 1014 | modelSelection: { | |
| 1015 | instanceId: "codex", | |
| 1016 | model: "gpt-5.4", | |
| 1017 | options: [{ id: "reasoningEffort", value: "xhigh" }], | |
| 1018 | }, | |
| 1019 | runtimeMode: "approval-required", | |
| 1020 | interactionMode: "plan", | |
| 1021 | workspaceSelection: { | |
| 1022 | mode: "worktree", | |
| 1023 | branch: "main", | |
| 1024 | worktreePath: null, | |
| 1025 | }, | |
| 1026 | project: { | |
| 1027 | environmentId: "environment-1", | |
| 1028 | projectId: "project-1", | |
| 1029 | createdAt: expect.any(String), | |
| 1050 | 1030 | }, |
| 1051 | 1031 | }); |
| 1052 | 1032 | }); |
| 1053 | ||
| 1054 | 1033 | it("keeps legacy content-only drafts and rejects invalid selector state", () => { |
| 1055 | 1034 | expect( |
| 1056 | 1035 | decodePersistedComposerState({ |
| ... | ... | @@ -1085,7 +1064,7 @@ describe("mobile composer drafts", () => { |
| 1085 | 1064 | // The stale-model strip must not touch receipt-bearing drafts, and the |
| 1086 | 1065 | // empty filter must keep them — or the same share would re-import after |
| 1087 | 1066 | // restart. |
| 1088 | expect( | |
| 1067 | const stripped = Object.values( | |
| 1089 | 1068 | decodePersistedComposerState({ |
| 1090 | 1069 | schemaVersion: 1, |
| 1091 | 1070 | drafts: { |
| ... | ... | @@ -1098,20 +1077,146 @@ describe("mobile composer drafts", () => { |
| 1098 | 1077 | }, |
| 1099 | 1078 | }, |
| 1100 | 1079 | }).drafts, |
| 1101 | ).toEqual({ | |
| 1102 | "new-task:environment-1:project-1": { | |
| 1103 | text: "", | |
| 1104 | attachments: [], | |
| 1105 | importedShareIds: ["share-1"], | |
| 1106 | }, | |
| 1080 | ); | |
| 1081 | expect(stripped).toHaveLength(1); | |
| 1082 | expect(stripped[0]).toMatchObject({ | |
| 1083 | text: "", | |
| 1084 | attachments: [], | |
| 1085 | importedShareIds: ["share-1"], | |
| 1086 | project: { environmentId: "environment-1", projectId: "project-1" }, | |
| 1107 | 1087 | }); |
| 1088 | expect(stripped[0]?.modelSelection).toBeUndefined(); | |
| 1108 | 1089 | |
| 1109 | expect( | |
| 1090 | const kept = Object.values( | |
| 1110 | 1091 | decodePersistedComposerState({ |
| 1111 | 1092 | schemaVersion: 1, |
| 1112 | 1093 | drafts: { "new-task:environment-1:project-1": receiptDraft }, |
| 1113 | 1094 | }).drafts, |
| 1114 | ).toEqual({ "new-task:environment-1:project-1": receiptDraft }); | |
| 1095 | ); | |
| 1096 | expect(kept).toHaveLength(1); | |
| 1097 | expect(kept[0]).toMatchObject(receiptDraft); | |
| 1098 | }); | |
| 1099 | ||
| 1100 | it("migrates archived signed-out new-task drafts the same way as live ones", () => { | |
| 1101 | const decoded = decodePersistedComposerState({ | |
| 1102 | schemaVersion: 1, | |
| 1103 | drafts: {}, | |
| 1104 | cloudAccountId: "account-1", | |
| 1105 | signedOutDrafts: { | |
| 1106 | "account-1": { | |
| 1107 | drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } }, | |
| 1108 | queuedMessages: [], | |
| 1109 | }, | |
| 1110 | }, | |
| 1111 | }); | |
| 1112 | const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {}); | |
| 1113 | expect(archived).toHaveLength(1); | |
| 1114 | expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/); | |
| 1115 | expect(archived[0]?.[1]).toMatchObject({ | |
| 1116 | text: "archived", | |
| 1117 | project: { environmentId: "environment-1", projectId: "project-1" }, | |
| 1118 | }); | |
| 1119 | }); | |
| 1120 | ||
| 1121 | it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => { | |
| 1122 | const now = "2026-09-05T12:00:00.000Z"; | |
| 1123 | const [key, draft] = migrateLegacyNewTaskDraft( | |
| 1124 | "new-task:environment-1:project-1", | |
| 1125 | { text: "keep me", attachments: [] }, | |
| 1126 | now, | |
| 1127 | ); | |
| 1128 | // The new key has no colon after the prefix, so it can never be | |
| 1129 | // mistaken for the legacy shape on the next load. | |
| 1130 | expect(key).toMatch(/^new-task:[0-9a-z-]+$/); | |
| 1131 | expect(draft).toEqual({ | |
| 1132 | text: "keep me", | |
| 1133 | attachments: [], | |
| 1134 | project: { | |
| 1135 | environmentId: EnvironmentId.make("environment-1"), | |
| 1136 | projectId: ProjectId.make("project-1"), | |
| 1137 | createdAt: now, | |
| 1138 | }, | |
| 1139 | }); | |
| 1140 | ||
| 1141 | // Already-migrated, thread, and pending-task keys pass through untouched. | |
| 1142 | const stamped: ComposerDraft = { | |
| 1143 | text: "x", | |
| 1144 | attachments: [], | |
| 1145 | project: { | |
| 1146 | environmentId: EnvironmentId.make("environment-1"), | |
| 1147 | projectId: ProjectId.make("project-1"), | |
| 1148 | createdAt: now, | |
| 1149 | }, | |
| 1150 | }; | |
| 1151 | expect(migrateLegacyNewTaskDraft("new-task:some-id", stamped, now)).toEqual([ | |
| 1152 | "new-task:some-id", | |
| 1153 | stamped, | |
| 1154 | ]); | |
| 1155 | expect(migrateLegacyNewTaskDraft("environment-1:thread-1", DRAFT, now)).toEqual([ | |
| 1156 | "environment-1:thread-1", | |
| 1157 | DRAFT, | |
| 1158 | ]); | |
| 1159 | expect(migrateLegacyNewTaskDraft("pending-task:message-1", DRAFT, now)).toEqual([ | |
| 1160 | "pending-task:message-1", | |
| 1161 | DRAFT, | |
| 1162 | ]); | |
| 1163 | }); | |
| 1164 | ||
| 1165 | it("keeps a freshly minted new-task draft bound until content arrives, then lists it per project", () => { | |
| 1166 | const project = { | |
| 1167 | environmentId: EnvironmentId.make("environment-1"), | |
| 1168 | projectId: ProjectId.make("project-1"), | |
| 1169 | }; | |
| 1170 | const first = createNewTaskDraft(project); | |
| 1171 | const second = createNewTaskDraft(project); | |
| 1172 | expect(first).not.toBe(second); | |
| 1173 | // Empty stamped drafts stay in memory so the composer has a key to write | |
| 1174 | // to, but the persisted document leaves them out. | |
| 1175 | expect(appAtomRegistry.get(composerDraftsAtom)[first]?.project).toMatchObject(project); | |
| 1176 | ||
| 1177 | setComposerDraftText(first, "first idea"); | |
| 1178 | setComposerDraftText(second, "second idea"); | |
| 1179 | expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), project)).toEqual( | |
| 1180 | expect.arrayContaining([first, second]), | |
| 1181 | ); | |
| 1182 | ||
| 1183 | // Clearing content on the way out drops the stamp with it. | |
| 1184 | clearComposerDraftContent(first, { clearModelSelection: true, clearWorkspaceSelection: true }); | |
| 1185 | expect(appAtomRegistry.get(composerDraftsAtom)[first]).toBeUndefined(); | |
| 1186 | expect(getComposerDraftSnapshot(second).text).toBe("second idea"); | |
| 1187 | }); | |
| 1188 | ||
| 1189 | it("retargets a new-task draft to another project without losing its text", () => { | |
| 1190 | const from = { | |
| 1191 | environmentId: EnvironmentId.make("environment-1"), | |
| 1192 | projectId: ProjectId.make("project-1"), | |
| 1193 | }; | |
| 1194 | const to = { | |
| 1195 | environmentId: EnvironmentId.make("environment-2"), | |
| 1196 | projectId: ProjectId.make("project-2"), | |
| 1197 | }; | |
| 1198 | const key = createNewTaskDraft(from); | |
| 1199 | setComposerDraftText(key, "moving house"); | |
| 1200 | appAtomRegistry.set(composerDraftsAtom, { | |
| 1201 | ...appAtomRegistry.get(composerDraftsAtom), | |
| 1202 | [key]: { | |
| 1203 | ...getComposerDraftSnapshot(key), | |
| 1204 | runtimeMode: "approval-required", | |
| 1205 | workspaceSelection: { mode: "worktree", branch: "feature/a", worktreePath: null }, | |
| 1206 | }, | |
| 1207 | }); | |
| 1208 | const createdAt = getComposerDraftSnapshot(key).project?.createdAt; | |
| 1209 | ||
| 1210 | retargetNewTaskDraft(key, to); | |
| 1211 | ||
| 1212 | const moved = getComposerDraftSnapshot(key); | |
| 1213 | expect(moved.text).toBe("moving house"); | |
| 1214 | expect(moved.runtimeMode).toBe("approval-required"); | |
| 1215 | // Branch and worktree belong to the old repo. | |
| 1216 | expect(moved.workspaceSelection).toBeUndefined(); | |
| 1217 | expect(moved.project).toEqual({ ...to, createdAt }); | |
| 1218 | expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]); | |
| 1219 | expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); | |
| 1115 | 1220 | }); |
| 1116 | 1221 | |
| 1117 | 1222 | it("hydrates the global sticky model selection", () => { |
| ... | ... | @@ -1387,56 +1492,7 @@ describe("mobile composer drafts", () => { |
| 1387 | 1492 | expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); |
| 1388 | 1493 | }); |
| 1389 | 1494 | |
| 1390 | it("carries unfinished content to a newly selected project without overwriting its settings", () => { | |
| 1391 | const sourceKey = "new-task:environment-1:project-1"; | |
| 1392 | const targetKey = "new-task:environment-1:project-2"; | |
| 1393 | const source: ComposerDraft = { | |
| 1394 | text: "Keep this task", | |
| 1395 | attachments: [], | |
| 1396 | importedShareIds: ["share-1"], | |
| 1397 | workspaceSelection: { | |
| 1398 | mode: "worktree", | |
| 1399 | branch: "feature/source", | |
| 1400 | worktreePath: null, | |
| 1401 | }, | |
| 1402 | }; | |
| 1403 | const target: ComposerDraft = { | |
| 1404 | text: "", | |
| 1405 | attachments: [], | |
| 1406 | runtimeMode: "approval-required", | |
| 1407 | }; | |
| 1408 | ||
| 1409 | expect( | |
| 1410 | copyComposerDraftContentState( | |
| 1411 | { [sourceKey]: source, [targetKey]: target }, | |
| 1412 | sourceKey, | |
| 1413 | targetKey, | |
| 1414 | ), | |
| 1415 | ).toEqual({ | |
| 1416 | [sourceKey]: source, | |
| 1417 | [targetKey]: { | |
| 1418 | ...target, | |
| 1419 | text: source.text, | |
| 1420 | attachments: source.attachments, | |
| 1421 | importedShareIds: source.importedShareIds, | |
| 1422 | }, | |
| 1423 | }); | |
| 1424 | }); | |
| 1425 | ||
| 1426 | it("does not overwrite unfinished content already stored for the selected project", () => { | |
| 1427 | const sourceKey = "new-task:environment-1:project-1"; | |
| 1428 | const targetKey = "new-task:environment-1:project-2"; | |
| 1429 | const drafts: Record<string, ComposerDraft> = { | |
| 1430 | [sourceKey]: { text: "Source task", attachments: [] }, | |
| 1431 | [targetKey]: { text: "Target task", attachments: [] }, | |
| 1432 | }; | |
| 1433 | ||
| 1434 | expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); | |
| 1435 | }); | |
| 1436 | ||
| 1437 | it("drops another environment's upload stamp when carrying attachments across machines", () => { | |
| 1438 | const sourceKey = "new-task:environment-1:project-1"; | |
| 1439 | const targetKey = "new-task:environment-2:project-2"; | |
| 1495 | it("drops another environment's upload stamp when a draft moves across machines", () => { | |
| 1440 | 1496 | const uploadedElsewhere: DraftComposerAttachment = { |
| 1441 | 1497 | id: "image-1", |
| 1442 | 1498 | type: "image", |
| ... | ... | @@ -1454,14 +1510,25 @@ describe("mobile composer drafts", () => { |
| 1454 | 1510 | uploadedAttachmentId: "upload-2", |
| 1455 | 1511 | uploadEnvironmentId: EnvironmentId.make("environment-2"), |
| 1456 | 1512 | }; |
| 1513 | const key = createNewTaskDraft({ | |
| 1514 | environmentId: EnvironmentId.make("environment-1"), | |
| 1515 | projectId: ProjectId.make("project-1"), | |
| 1516 | }); | |
| 1517 | appAtomRegistry.set(composerDraftsAtom, { | |
| 1518 | ...appAtomRegistry.get(composerDraftsAtom), | |
| 1519 | [key]: { | |
| 1520 | ...getComposerDraftSnapshot(key), | |
| 1521 | text: "Ship it", | |
| 1522 | attachments: [uploadedElsewhere, uploadedOnTarget], | |
| 1523 | }, | |
| 1524 | }); | |
| 1457 | 1525 | |
| 1458 | const next = copyComposerDraftContentState( | |
| 1459 | { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } }, | |
| 1460 | sourceKey, | |
| 1461 | targetKey, | |
| 1462 | ); | |
| 1526 | retargetNewTaskDraft(key, { | |
| 1527 | environmentId: EnvironmentId.make("environment-2"), | |
| 1528 | projectId: ProjectId.make("project-2"), | |
| 1529 | }); | |
| 1463 | 1530 | |
| 1464 | expect(next[targetKey]?.attachments).toEqual([ | |
| 1531 | expect(getComposerDraftSnapshot(key).attachments).toEqual([ | |
| 1465 | 1532 | { |
| 1466 | 1533 | id: "image-1", |
| 1467 | 1534 | type: "image", |
| ... | ... | @@ -1473,7 +1540,6 @@ describe("mobile composer drafts", () => { |
| 1473 | 1540 | }, |
| 1474 | 1541 | uploadedOnTarget, |
| 1475 | 1542 | ]); |
| 1476 | expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]); | |
| 1477 | 1543 | }); |
| 1478 | 1544 | |
| 1479 | 1545 | it("merges shared content into a project draft without duplicating retries", () => { |
| ... | ... | @@ -1564,19 +1630,36 @@ describe("mobile composer drafts", () => { |
| 1564 | 1630 | const environmentId = EnvironmentId.make("environment-cloud"); |
| 1565 | 1631 | const retainedEnvironmentId = EnvironmentId.make("environment-local"); |
| 1566 | 1632 | |
| 1633 | const cloudDraft: ComposerDraft = { | |
| 1634 | ...DRAFT, | |
| 1635 | project: { | |
| 1636 | environmentId, | |
| 1637 | projectId: ProjectId.make("project-cloud"), | |
| 1638 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 1639 | }, | |
| 1640 | }; | |
| 1641 | const localDraft: ComposerDraft = { | |
| 1642 | ...DRAFT, | |
| 1643 | project: { | |
| 1644 | environmentId: retainedEnvironmentId, | |
| 1645 | projectId: ProjectId.make("project-local"), | |
| 1646 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 1647 | }, | |
| 1648 | }; | |
| 1649 | ||
| 1567 | 1650 | expect( |
| 1568 | 1651 | removeComposerDraftsForEnvironment( |
| 1569 | 1652 | { |
| 1570 | 1653 | [`${environmentId}:thread-cloud`]: DRAFT, |
| 1571 | [`new-task:${environmentId}:project-cloud`]: DRAFT, | |
| 1654 | "new-task:cloud-draft": cloudDraft, | |
| 1572 | 1655 | [`${retainedEnvironmentId}:thread-local`]: DRAFT, |
| 1573 | [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, | |
| 1656 | "new-task:local-draft": localDraft, | |
| 1574 | 1657 | }, |
| 1575 | 1658 | environmentId, |
| 1576 | 1659 | ), |
| 1577 | 1660 | ).toEqual({ |
| 1578 | 1661 | [`${retainedEnvironmentId}:thread-local`]: DRAFT, |
| 1579 | [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, | |
| 1662 | "new-task:local-draft": localDraft, | |
| 1580 | 1663 | }); |
| 1581 | 1664 | }); |
| 1582 | 1665 |
apps/mobile/src/state/use-composer-drafts.ts+204-121| ... | ... | @@ -1,11 +1,14 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | EnvironmentId as EnvironmentIdSchema, | |
| 3 | 4 | ModelSelection as ModelSelectionSchema, |
| 4 | 5 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 6 | ProjectId as ProjectIdSchema, | |
| 5 | 7 | ProviderInteractionMode as ProviderInteractionModeSchema, |
| 6 | 8 | RuntimeMode as RuntimeModeSchema, |
| 7 | 9 | type EnvironmentId, |
| 8 | 10 | type ModelSelection, |
| 11 | type ProjectId, | |
| 9 | 12 | type ProviderInteractionMode, |
| 10 | 13 | type RuntimeMode, |
| 11 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -23,6 +26,11 @@ import { |
| 23 | 26 | import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; |
| 24 | 27 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 25 | 28 | import { appAtomRegistry } from "./atom-registry"; |
| 29 | import { | |
| 30 | isNewTaskDraftKey, | |
| 31 | newTaskDraftKey, | |
| 32 | parseLegacyNewTaskDraftKey, | |
| 33 | } from "./new-task-draft-key"; | |
| 26 | 34 | import { |
| 27 | 35 | decodeQueuedThreadMessage, |
| 28 | 36 | encodeQueuedThreadMessage, |
| ... | ... | @@ -59,6 +67,18 @@ export interface ComposerDraft { |
| 59 | 67 | readonly runtimeMode?: RuntimeMode; |
| 60 | 68 | readonly interactionMode?: ProviderInteractionMode; |
| 61 | 69 | readonly workspaceSelection?: ComposerDraftWorkspaceSelection; |
| 70 | /** | |
| 71 | * Set on new-task drafts only. The project is stored here rather than in | |
| 72 | * the key so a project can hold any number of drafts and a draft can be | |
| 73 | * retargeted to another project without changing identity. | |
| 74 | */ | |
| 75 | readonly project?: ComposerDraftProject; | |
| 76 | } | |
| 77 | ||
| 78 | export interface ComposerDraftProject { | |
| 79 | readonly environmentId: EnvironmentId; | |
| 80 | readonly projectId: ProjectId; | |
| 81 | readonly createdAt: string; | |
| 62 | 82 | } |
| 63 | 83 | |
| 64 | 84 | export interface ComposerDraftContent { |
| ... | ... | @@ -76,7 +96,7 @@ export interface ComposerDraftWorkspaceSelection { |
| 76 | 96 | |
| 77 | 97 | export type ComposerDraftSettingsUpdate = Pick< |
| 78 | 98 | ComposerDraft, |
| 79 | "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | |
| 99 | "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project" | |
| 80 | 100 | >; |
| 81 | 101 | |
| 82 | 102 | const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ |
| ... | ... | @@ -86,6 +106,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ |
| 86 | 106 | startFromOrigin: Schema.optional(Schema.Boolean), |
| 87 | 107 | }); |
| 88 | 108 | |
| 109 | const ComposerDraftProjectSchema = Schema.Struct({ | |
| 110 | environmentId: EnvironmentIdSchema, | |
| 111 | projectId: ProjectIdSchema, | |
| 112 | createdAt: Schema.String, | |
| 113 | }); | |
| 114 | ||
| 89 | 115 | const ComposerDraftSchema = Schema.Struct({ |
| 90 | 116 | text: Schema.String, |
| 91 | 117 | attachments: Schema.Array(DraftComposerAttachmentSchema), |
| ... | ... | @@ -94,6 +120,7 @@ const ComposerDraftSchema = Schema.Struct({ |
| 94 | 120 | runtimeMode: Schema.optional(RuntimeModeSchema), |
| 95 | 121 | interactionMode: Schema.optional(ProviderInteractionModeSchema), |
| 96 | 122 | workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), |
| 123 | project: Schema.optional(ComposerDraftProjectSchema), | |
| 97 | 124 | }); |
| 98 | 125 | |
| 99 | 126 | const PersistedComposerDraftsSchema = Schema.Struct({ |
| ... | ... | @@ -176,6 +203,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean { |
| 176 | 203 | return isEmptyDraft(draft); |
| 177 | 204 | } |
| 178 | 205 | |
| 206 | // The project stamp is identity, not content: a new-task draft with nothing | |
| 207 | // else in it is still empty and gets dropped like any other. | |
| 179 | 208 | function isEmptyDraft(draft: ComposerDraft): boolean { |
| 180 | 209 | return ( |
| 181 | 210 | draft.text.length === 0 && |
| ... | ... | @@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean { |
| 187 | 216 | ); |
| 188 | 217 | } |
| 189 | 218 | |
| 219 | /** | |
| 220 | * Writes a draft back, dropping it once empty. A new-task draft keeps its | |
| 221 | * entry while the composer is bound to it (the project stamp is what the | |
| 222 | * composer binds to); the persist sweep still leaves empty ones off disk. | |
| 223 | */ | |
| 224 | function withComposerDraft( | |
| 225 | current: Record<string, ComposerDraft>, | |
| 226 | draftKey: string, | |
| 227 | draft: ComposerDraft, | |
| 228 | ): Record<string, ComposerDraft> { | |
| 229 | if (isEmptyDraft(draft) && draft.project === undefined) { | |
| 230 | const next = { ...current }; | |
| 231 | delete next[draftKey]; | |
| 232 | return next; | |
| 233 | } | |
| 234 | return { ...current, [draftKey]: draft }; | |
| 235 | } | |
| 236 | ||
| 237 | export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key"; | |
| 238 | ||
| 239 | // Draft ids only need to be unique within this device's draft file. Deriving | |
| 240 | // them from time plus randomness keeps this module free of native imports, | |
| 241 | // which the persistence tests rely on. | |
| 242 | function newDraftId(): string { | |
| 243 | return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Project-keyed new-task drafts from earlier builds are rewritten on load into | |
| 248 | * id-keyed drafts with the project stamped in, so existing drafts survive the | |
| 249 | * switch to many-per-project. | |
| 250 | */ | |
| 251 | export function migrateLegacyNewTaskDraft( | |
| 252 | key: string, | |
| 253 | draft: ComposerDraft, | |
| 254 | now: string, | |
| 255 | ): readonly [key: string, draft: ComposerDraft] { | |
| 256 | const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; | |
| 257 | if (legacy === null) { | |
| 258 | return [key, draft]; | |
| 259 | } | |
| 260 | return [ | |
| 261 | newTaskDraftKey(newDraftId()), | |
| 262 | { | |
| 263 | ...draft, | |
| 264 | project: { | |
| 265 | environmentId: EnvironmentIdSchema.make(legacy.environmentId), | |
| 266 | projectId: ProjectIdSchema.make(legacy.projectId), | |
| 267 | createdAt: now, | |
| 268 | }, | |
| 269 | }, | |
| 270 | ]; | |
| 271 | } | |
| 272 | ||
| 190 | 273 | export function decodePersistedComposerState(value: unknown): { |
| 191 | 274 | readonly drafts: Record<string, ComposerDraft>; |
| 192 | 275 | readonly stickyModelSelection: ModelSelection | null; |
| 193 | 276 | readonly cloudDrafts: ComposerCloudDraftState; |
| 194 | 277 | } { |
| 195 | 278 | const parsed = decodePersistedComposerDraftsDocument(value); |
| 279 | const now = new Date().toISOString(); | |
| 196 | 280 | return { |
| 197 | 281 | drafts: Object.fromEntries( |
| 198 | 282 | Object.entries(parsed.drafts) |
| 199 | .map( | |
| 200 | ([key, draft]) => | |
| 201 | [ | |
| 202 | key, | |
| 203 | // Stale new-task drafts left on disk by builds before the | |
| 204 | // model-precedence fix carry a bare modelSelection with no | |
| 205 | // other selector settings. Strip it so the next compose pass | |
| 206 | // re-resolves project → sticky → provider defaults. Drafts | |
| 207 | // with runtime/interaction/workspace settings or actual text / | |
| 208 | // attachments were deliberately configured and are left alone. | |
| 209 | key.startsWith("new-task:") && | |
| 283 | .map(([key, draft]) => | |
| 284 | migrateLegacyNewTaskDraft( | |
| 285 | key, | |
| 286 | // Stale new-task drafts left on disk by builds before the | |
| 287 | // model-precedence fix carry a bare modelSelection with no | |
| 288 | // other selector settings. Strip it so the next compose pass | |
| 289 | // re-resolves project → sticky → provider defaults. Drafts | |
| 290 | // with runtime/interaction/workspace settings or actual text / | |
| 291 | // attachments were deliberately configured and are left alone. | |
| 292 | isNewTaskDraftKey(key) && | |
| 210 | 293 | draft.modelSelection && |
| 211 | 294 | draft.text.length === 0 && |
| 212 | 295 | draft.attachments.length === 0 && |
| 213 | 296 | draft.runtimeMode === undefined && |
| 214 | 297 | draft.interactionMode === undefined && |
| 215 | 298 | draft.workspaceSelection === undefined |
| 216 | ? { ...draft, modelSelection: undefined } | |
| 217 | : draft, | |
| 218 | ] as const, | |
| 299 | ? { ...draft, modelSelection: undefined } | |
| 300 | : draft, | |
| 301 | now, | |
| 302 | ), | |
| 219 | 303 | ) |
| 220 | 304 | // importedShareIds are share-import receipts: a contentless draft |
| 221 | 305 | // carrying one is not empty, or the same native share would be |
| ... | ... | @@ -229,7 +313,13 @@ export function decodePersistedComposerState(value: unknown): { |
| 229 | 313 | Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ |
| 230 | 314 | id, |
| 231 | 315 | { |
| 232 | drafts: saved.drafts, | |
| 316 | // Archived drafts come back through restoreCloudComposerDrafts | |
| 317 | // without another decode, so they get the same key migration. | |
| 318 | drafts: Object.fromEntries( | |
| 319 | Object.entries(saved.drafts).map(([key, draft]) => | |
| 320 | migrateLegacyNewTaskDraft(key, draft, now), | |
| 321 | ), | |
| 322 | ), | |
| 233 | 323 | queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), |
| 234 | 324 | }, |
| 235 | 325 | ]), |
| ... | ... | @@ -622,7 +712,7 @@ export async function archiveCloudComposerDrafts( |
| 622 | 712 | const remaining = { ...current }; |
| 623 | 713 | const savedDrafts = { ...cloud.signedOut[owner]?.drafts }; |
| 624 | 714 | for (const [key, draft] of Object.entries(current)) { |
| 625 | const environmentId = composerDraftEnvironmentId(key, queued); | |
| 715 | const environmentId = composerDraftEnvironmentId(key, queued, draft); | |
| 626 | 716 | if (environmentId !== null && environmentIds.has(environmentId)) { |
| 627 | 717 | savedDrafts[key] = draft; |
| 628 | 718 | delete remaining[key]; |
| ... | ... | @@ -808,15 +898,7 @@ export function setComposerDraftText(draftKey: string, value: string): void { |
| 808 | 898 | ...normalizeDraft(current[draftKey]), |
| 809 | 899 | text: value, |
| 810 | 900 | }; |
| 811 | if (isEmptyDraft(draft)) { | |
| 812 | const next = { ...current }; | |
| 813 | delete next[draftKey]; | |
| 814 | return next; | |
| 815 | } | |
| 816 | return { | |
| 817 | ...current, | |
| 818 | [draftKey]: draft, | |
| 819 | }; | |
| 901 | return withComposerDraft(current, draftKey, draft); | |
| 820 | 902 | }); |
| 821 | 903 | } |
| 822 | 904 | |
| ... | ... | @@ -881,15 +963,7 @@ export function replaceComposerDraftAttachments( |
| 881 | 963 | ...normalizeDraft(current[draftKey]), |
| 882 | 964 | attachments, |
| 883 | 965 | }; |
| 884 | if (isEmptyDraft(draft)) { | |
| 885 | const next = { ...current }; | |
| 886 | delete next[draftKey]; | |
| 887 | return next; | |
| 888 | } | |
| 889 | return { | |
| 890 | ...current, | |
| 891 | [draftKey]: draft, | |
| 892 | }; | |
| 966 | return withComposerDraft(current, draftKey, draft); | |
| 893 | 967 | }); |
| 894 | 968 | const retainedIds = new Set(attachments.map((attachment) => attachment.id)); |
| 895 | 969 | scheduleUnusedComposerAttachmentCleanup( |
| ... | ... | @@ -905,15 +979,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) |
| 905 | 979 | ...existing, |
| 906 | 980 | attachments: existing.attachments.filter((image) => image.id !== imageId), |
| 907 | 981 | }; |
| 908 | if (isEmptyDraft(draft)) { | |
| 909 | const next = { ...current }; | |
| 910 | delete next[draftKey]; | |
| 911 | return next; | |
| 912 | } | |
| 913 | return { | |
| 914 | ...current, | |
| 915 | [draftKey]: draft, | |
| 916 | }; | |
| 982 | return withComposerDraft(current, draftKey, draft); | |
| 917 | 983 | }); |
| 918 | 984 | scheduleUnusedComposerAttachmentCleanup( |
| 919 | 985 | previousAttachments.filter((attachment) => attachment.id === imageId), |
| ... | ... | @@ -964,15 +1030,7 @@ export function updateComposerDraftSettings( |
| 964 | 1030 | ...normalizeDraft(current[draftKey]), |
| 965 | 1031 | ...settings, |
| 966 | 1032 | }; |
| 967 | if (isEmptyDraft(draft)) { | |
| 968 | const next = { ...current }; | |
| 969 | delete next[draftKey]; | |
| 970 | return next; | |
| 971 | } | |
| 972 | return { | |
| 973 | ...current, | |
| 974 | [draftKey]: draft, | |
| 975 | }; | |
| 1033 | return withComposerDraft(current, draftKey, draft); | |
| 976 | 1034 | }); |
| 977 | 1035 | } |
| 978 | 1036 | |
| ... | ... | @@ -988,10 +1046,14 @@ export function clearComposerDraftContentState( |
| 988 | 1046 | if (!existing) { |
| 989 | 1047 | return current; |
| 990 | 1048 | } |
| 1049 | // Clearing content is the "this draft is done" moment (sent, queued, or | |
| 1050 | // discarded), so the project stamp goes too and an otherwise-empty new-task | |
| 1051 | // draft leaves the store rather than lingering as a blank row. | |
| 991 | 1052 | const { |
| 992 | 1053 | importedShareIds: _importedShareIds, |
| 993 | 1054 | modelSelection, |
| 994 | 1055 | workspaceSelection, |
| 1056 | project: _project, | |
| 995 | 1057 | ...retained |
| 996 | 1058 | } = existing; |
| 997 | 1059 | const draft = { |
| ... | ... | @@ -1028,49 +1090,6 @@ export function restoreComposerDraftSnapshotState( |
| 1028 | 1090 | return next; |
| 1029 | 1091 | } |
| 1030 | 1092 | |
| 1031 | export function copyComposerDraftContentState( | |
| 1032 | current: Record<string, ComposerDraft>, | |
| 1033 | sourceDraftKey: string, | |
| 1034 | targetDraftKey: string, | |
| 1035 | ): Record<string, ComposerDraft> { | |
| 1036 | if (sourceDraftKey === targetDraftKey) { | |
| 1037 | return current; | |
| 1038 | } | |
| 1039 | const source = normalizeDraft(current[sourceDraftKey]); | |
| 1040 | const target = normalizeDraft(current[targetDraftKey]); | |
| 1041 | const sourceHasContent = | |
| 1042 | source.text.length > 0 || | |
| 1043 | source.attachments.length > 0 || | |
| 1044 | (source.importedShareIds?.length ?? 0) > 0; | |
| 1045 | const targetHasContent = | |
| 1046 | target.text.length > 0 || | |
| 1047 | target.attachments.length > 0 || | |
| 1048 | (target.importedShareIds?.length ?? 0) > 0; | |
| 1049 | if (!sourceHasContent || targetHasContent) { | |
| 1050 | return current; | |
| 1051 | } | |
| 1052 | // Pending uploads live on one server. Crossing environments keeps the local | |
| 1053 | // bytes (the upload worker re-sends them to the new key's environment) but | |
| 1054 | // drops the old stamp, so it cannot pin the source environment's pending | |
| 1055 | // upload alive from the copy. | |
| 1056 | const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []); | |
| 1057 | const attachments = source.attachments.map((attachment) => | |
| 1058 | attachment.uploadEnvironmentId !== undefined && | |
| 1059 | attachment.uploadEnvironmentId !== targetEnvironmentId | |
| 1060 | ? stripAttachmentUploadReference(attachment) | |
| 1061 | : attachment, | |
| 1062 | ); | |
| 1063 | return { | |
| 1064 | ...current, | |
| 1065 | [targetDraftKey]: { | |
| 1066 | ...target, | |
| 1067 | text: source.text, | |
| 1068 | attachments, | |
| 1069 | ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), | |
| 1070 | }, | |
| 1071 | }; | |
| 1072 | } | |
| 1073 | ||
| 1074 | 1093 | function stripAttachmentUploadReference( |
| 1075 | 1094 | attachment: DraftComposerAttachment, |
| 1076 | 1095 | ): DraftComposerAttachment { |
| ... | ... | @@ -1078,19 +1097,6 @@ function stripAttachmentUploadReference( |
| 1078 | 1097 | return rest; |
| 1079 | 1098 | } |
| 1080 | 1099 | |
| 1081 | export async function copyComposerDraftContentIfEmpty( | |
| 1082 | sourceDraftKey: string, | |
| 1083 | targetDraftKey: string, | |
| 1084 | ): Promise<void> { | |
| 1085 | ensureComposerDraftsLoaded(); | |
| 1086 | if (loadPromise !== null) { | |
| 1087 | await loadPromise; | |
| 1088 | } | |
| 1089 | updateComposerDrafts((current) => | |
| 1090 | copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), | |
| 1091 | ); | |
| 1092 | } | |
| 1093 | ||
| 1094 | 1100 | function mergeComposerDraftText(existing: string, incoming: string): string { |
| 1095 | 1101 | if (incoming.length === 0) { |
| 1096 | 1102 | return existing; |
| ... | ... | @@ -1275,15 +1281,7 @@ export function undoComposerDraftMergeState( |
| 1275 | 1281 | interactionMode: undoSetting("interactionMode"), |
| 1276 | 1282 | workspaceSelection: undoSetting("workspaceSelection"), |
| 1277 | 1283 | }; |
| 1278 | if (isEmptyDraft(draft)) { | |
| 1279 | const next = { ...current }; | |
| 1280 | delete next[draftKey]; | |
| 1281 | return next; | |
| 1282 | } | |
| 1283 | return { | |
| 1284 | ...current, | |
| 1285 | [draftKey]: draft, | |
| 1286 | }; | |
| 1284 | return withComposerDraft(current, draftKey, draft); | |
| 1287 | 1285 | } |
| 1288 | 1286 | |
| 1289 | 1287 | /** Applies undoComposerDraftMergeState and lands it durably. */ |
| ... | ... | @@ -1355,15 +1353,100 @@ export function removeComposerDraftsForEnvironment( |
| 1355 | 1353 | environmentId: EnvironmentId, |
| 1356 | 1354 | ): Record<string, ComposerDraft> { |
| 1357 | 1355 | const environmentPrefix = `${environmentId}:`; |
| 1358 | const newTaskPrefix = `new-task:${environmentId}:`; | |
| 1359 | 1356 | return Object.fromEntries( |
| 1360 | 1357 | Object.entries(drafts).filter( |
| 1361 | ([draftKey]) => | |
| 1362 | !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), | |
| 1358 | ([draftKey, draft]) => | |
| 1359 | !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId, | |
| 1363 | 1360 | ), |
| 1364 | 1361 | ); |
| 1365 | 1362 | } |
| 1366 | 1363 | |
| 1364 | /** | |
| 1365 | * Mints a new-task draft for a project. The entry is published immediately so | |
| 1366 | * the composer can bind to its key before the user types; it stays out of the | |
| 1367 | * list until it has content, and the empty-draft sweep drops it on persist if | |
| 1368 | * nothing is ever written. | |
| 1369 | */ | |
| 1370 | export function createNewTaskDraft(project: { | |
| 1371 | readonly environmentId: EnvironmentId; | |
| 1372 | readonly projectId: ProjectId; | |
| 1373 | }): string { | |
| 1374 | const draftKey = newTaskDraftKey(newDraftId()); | |
| 1375 | const stamp: ComposerDraftProject = { | |
| 1376 | environmentId: project.environmentId, | |
| 1377 | projectId: project.projectId, | |
| 1378 | createdAt: new Date().toISOString(), | |
| 1379 | }; | |
| 1380 | updateComposerDrafts((current) => ({ | |
| 1381 | ...current, | |
| 1382 | [draftKey]: { ...EMPTY_DRAFT, project: stamp }, | |
| 1383 | })); | |
| 1384 | return draftKey; | |
| 1385 | } | |
| 1386 | ||
| 1387 | /** | |
| 1388 | * Points an existing new-task draft at a different project, keeping its | |
| 1389 | * content and identity. Workspace selection is project-specific (branch, | |
| 1390 | * worktree), so it is cleared; model and mode choices carry over. | |
| 1391 | */ | |
| 1392 | export function retargetNewTaskDraft( | |
| 1393 | draftKey: string, | |
| 1394 | project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, | |
| 1395 | ): void { | |
| 1396 | updateComposerDrafts((current) => { | |
| 1397 | const existing = current[draftKey]; | |
| 1398 | const stamp = existing?.project; | |
| 1399 | if ( | |
| 1400 | stamp !== undefined && | |
| 1401 | stamp.environmentId === project.environmentId && | |
| 1402 | stamp.projectId === project.projectId | |
| 1403 | ) { | |
| 1404 | return current; | |
| 1405 | } | |
| 1406 | const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing); | |
| 1407 | // Pending uploads live on one server. Crossing environments keeps the | |
| 1408 | // local bytes (the upload worker re-sends them to the new environment) | |
| 1409 | // but drops the old stamp, so it cannot pin the source environment's | |
| 1410 | // pending upload alive from the moved draft. | |
| 1411 | const attachments = retained.attachments.map((attachment) => | |
| 1412 | attachment.uploadEnvironmentId !== undefined && | |
| 1413 | attachment.uploadEnvironmentId !== project.environmentId | |
| 1414 | ? stripAttachmentUploadReference(attachment) | |
| 1415 | : attachment, | |
| 1416 | ); | |
| 1417 | return { | |
| 1418 | ...current, | |
| 1419 | [draftKey]: { | |
| 1420 | ...retained, | |
| 1421 | attachments, | |
| 1422 | project: { | |
| 1423 | environmentId: project.environmentId, | |
| 1424 | projectId: project.projectId, | |
| 1425 | createdAt: stamp?.createdAt ?? new Date().toISOString(), | |
| 1426 | }, | |
| 1427 | }, | |
| 1428 | }; | |
| 1429 | }); | |
| 1430 | } | |
| 1431 | ||
| 1432 | /** New-task drafts for a project, newest first. */ | |
| 1433 | export function findNewTaskDraftKeys( | |
| 1434 | drafts: Readonly<Record<string, ComposerDraft>>, | |
| 1435 | project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, | |
| 1436 | ): ReadonlyArray<string> { | |
| 1437 | return Object.entries(drafts) | |
| 1438 | .filter( | |
| 1439 | ([key, draft]) => | |
| 1440 | isNewTaskDraftKey(key) && | |
| 1441 | draft.project?.environmentId === project.environmentId && | |
| 1442 | draft.project.projectId === project.projectId, | |
| 1443 | ) | |
| 1444 | .sort(([, left], [, right]) => | |
| 1445 | (right.project?.createdAt ?? "").localeCompare(left.project?.createdAt ?? ""), | |
| 1446 | ) | |
| 1447 | .map(([key]) => key); | |
| 1448 | } | |
| 1449 | ||
| 1367 | 1450 | export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> { |
| 1368 | 1451 | ensureComposerDraftsLoaded(); |
| 1369 | 1452 | if (loadPromise !== null) { |
apps/mobile/src/state/use-pending-new-tasks.ts-3| ... | ... | @@ -20,9 +20,6 @@ export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> { |
| 20 | 20 | buildPendingNewTasks({ |
| 21 | 21 | queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey), |
| 22 | 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 | 23 | }), |
| 27 | 24 | [queuedMessagesByThreadKey, drafts], |
| 28 | 25 | ); |
apps/mobile/src/state/use-thread-outbox-drain.test.ts+9-4| ... | ... | @@ -582,7 +582,7 @@ describe("thread outbox delivered creation recovery", () => { |
| 582 | 582 | }); |
| 583 | 583 | |
| 584 | 584 | describe("thread outbox recovery rollback", () => { |
| 585 | it("restores a rejected new task into its durable project draft", async () => { | |
| 585 | it("restores a rejected new task as its own draft for the project", async () => { | |
| 586 | 586 | const message: QueuedThreadMessage = { |
| 587 | 587 | ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }), |
| 588 | 588 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, |
| ... | ... | @@ -599,14 +599,19 @@ describe("thread outbox recovery rollback", () => { |
| 599 | 599 | "restored", |
| 600 | 600 | ); |
| 601 | 601 | |
| 602 | // The draft is keyed by the message so a retry lands on the same one, and | |
| 603 | // stamped with the project so it shows up as a Draft row for that project. | |
| 602 | 604 | expect( |
| 603 | composerDrafts.getComposerDraftSnapshot( | |
| 604 | `new-task:${message.environmentId}:${message.creation!.projectId}`, | |
| 605 | ), | |
| 605 | composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`), | |
| 606 | 606 | ).toMatchObject({ |
| 607 | 607 | text: message.text, |
| 608 | 608 | attachments: message.attachments, |
| 609 | 609 | modelSelection: message.modelSelection, |
| 610 | project: { | |
| 611 | environmentId: message.environmentId, | |
| 612 | projectId: message.creation!.projectId, | |
| 613 | createdAt: message.createdAt, | |
| 614 | }, | |
| 610 | 615 | }); |
| 611 | 616 | expect(remainingMessages()).toEqual([]); |
| 612 | 617 | expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server"); |
apps/mobile/src/state/use-thread-outbox-drain.ts+23-2| ... | ... | @@ -17,7 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; |
| 17 | 17 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 18 | 18 | import { Alert } from "react-native"; |
| 19 | 19 | |
| 20 | import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; | |
| 20 | import { scopedThreadKey } from "../lib/scopedEntities"; | |
| 21 | 21 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; |
| 22 | 22 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; |
| 23 | 23 | import { randomHex } from "../lib/uuid"; |
| ... | ... | @@ -53,6 +53,7 @@ import { |
| 53 | 53 | type ComposerDraft, |
| 54 | 54 | getComposerDraftSnapshot, |
| 55 | 55 | mergeComposerDraftContent, |
| 56 | newTaskDraftKey, | |
| 56 | 57 | replaceComposerDraftAttachments, |
| 57 | 58 | removeDeliveredCloudQueuedMessage, |
| 58 | 59 | undoComposerDraftMerge, |
| ... | ... | @@ -369,6 +370,7 @@ export async function restoreRejectedQueuedMessage( |
| 369 | 370 | |
| 370 | 371 | let mergedDraft: ComposerDraft; |
| 371 | 372 | try { |
| 373 | stampRecoveryDraftProject(queuedMessage, draftKey); | |
| 372 | 374 | await mergeComposerDraftContent(draftKey, { |
| 373 | 375 | text: queuedMessage.text, |
| 374 | 376 | attachments: queuedMessage.attachments, |
| ... | ... | @@ -451,12 +453,31 @@ export async function restoreRejectedQueuedMessage( |
| 451 | 453 | } |
| 452 | 454 | } |
| 453 | 455 | |
| 456 | /** | |
| 457 | * A rejected creation becomes its own new-task draft rather than merging into | |
| 458 | * whatever the user is typing for that project. The key derives from the | |
| 459 | * message id so a retry after a mid-recovery failure lands on the same draft | |
| 460 | * instead of minting another. | |
| 461 | */ | |
| 454 | 462 | function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string { |
| 455 | 463 | return queuedMessage.creation |
| 456 | ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}` | |
| 464 | ? newTaskDraftKey(`restored-${queuedMessage.messageId}`) | |
| 457 | 465 | : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); |
| 458 | 466 | } |
| 459 | 467 | |
| 468 | function stampRecoveryDraftProject(queuedMessage: QueuedThreadMessage, draftKey: string): void { | |
| 469 | if (!queuedMessage.creation) { | |
| 470 | return; | |
| 471 | } | |
| 472 | updateComposerDraftSettings(draftKey, { | |
| 473 | project: { | |
| 474 | environmentId: queuedMessage.environmentId, | |
| 475 | projectId: queuedMessage.creation.projectId, | |
| 476 | createdAt: queuedMessage.createdAt, | |
| 477 | }, | |
| 478 | }); | |
| 479 | } | |
| 480 | ||
| 460 | 481 | async function preserveUploadedAttachmentsForEditor( |
| 461 | 482 | originalMessage: QueuedThreadMessage, |
| 462 | 483 | uploadedMessage: QueuedThreadMessage, |