Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/mobile/src/features/home/usePendingTaskListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,14 @@ export function usePendingTaskListActions(): {

const openPendingTask = useCallback(
(pendingTask: PendingNewTask) => {
// A draft is the project's own new-task composer content, so opening
// the project's new-task screen lands on it without extra params.
navigation.navigate("NewTaskSheet", {
screen: "NewTaskDraft",
params: {
environmentId: String(pendingTask.environmentId),
projectId: String(pendingTask.projectId),
...(pendingTask.kind === "pending"
? { pendingTaskId: String(pendingTask.message.messageId) }
: {}),
: { draftId: pendingTask.draftKey }),
},
});
},
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {
readonly projectId?: string | string[];
readonly title?: string | string[];
readonly pendingTaskId?: string | string[];
readonly draftId?: string | string[];
readonly incomingShareId?: string | string[];
};

Expand Down Expand Up @@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
pendingTaskId={
Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId
}
draftId={Array.isArray(params.draftId) ? params.draftId[0] : params.draftId}
/>
</>
);
Expand Down
50 changes: 46 additions & 4 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
restoreComposerDraftSnapshot,
scheduleUnusedComposerAttachmentCleanup,
type ComposerDraft,
waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import {
Expand Down Expand Up @@ -150,6 +151,8 @@ export function NewTaskDraftScreen(props: {
};
/** Queued outbox message id when editing an existing pending task. */
readonly pendingTaskId?: string;
/** Existing new-task draft key to resume (a Draft row in the thread list). */
readonly draftId?: string;
/** Durable native share inbox item to merge into this project draft. */
readonly incomingShareId?: string;
}) {
Expand Down Expand Up @@ -420,7 +423,44 @@ export function NewTaskDraftScreen(props: {
};
}, []);

const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow;
const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow;
// A Draft row opens its own draft; a fresh New Task never reuses one.
// Drafts hydrate from disk and projects arrive with the shell snapshot, so
// on a cold launch the draft or its project can be missing for a moment;
// wait for hydration and retry while projects load. Attempt each id once
// after that so a draft discarded mid-session does not keep bouncing to
// the picker.
const attemptedDraftIdRef = useRef<string | null>(null);
useEffect(() => {
if (!props.draftId || props.pendingTaskId) {
return;
}
const draftId = props.draftId;
if (attemptedDraftIdRef.current === draftId) {
return;
}
let cancelled = false;
void waitForComposerDraftsLoaded().then(() => {
if (cancelled || attemptedDraftIdRef.current === draftId) {
return;
}
if (openDraft(draftId)) {
attemptedDraftIdRef.current = draftId;
return;
}
if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) {
// The draft exists; its project has not arrived yet. Retry on the
// next projects change instead of giving up.
return;
}
attemptedDraftIdRef.current = draftId;
navigation.dispatch(StackActions.replace("NewTask"));
});
return () => {
cancelled = true;
};
}, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]);

const attemptedPendingTaskIdRef = useRef<string | null>(null);
useEffect(() => {
if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
Expand Down Expand Up @@ -457,9 +497,10 @@ export function NewTaskDraftScreen(props: {
const lastInitialProjectRefRef = useRef(props.initialProjectRef);

useEffect(() => {
// Pending-task editing owns project selection (and must not fall through
// to the replace("NewTask") fallback while its hydration is in flight).
if (props.pendingTaskId) {
// Pending-task editing and draft resumption own project selection (and
// must not fall through to the replace("NewTask") fallback while their
// hydration is in flight).
if (props.pendingTaskId || props.draftId) {
return;
}
if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
Expand Down Expand Up @@ -518,6 +559,7 @@ export function NewTaskDraftScreen(props: {
props.initialProjectRef,
props.incomingShareId,
props.pendingTaskId,
props.draftId,
navigation,
selectedProject,
selectedProjectKey,
Expand Down
81 changes: 68 additions & 13 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query";
import {
appendComposerDraftAttachments,
clearComposerDraft,
copyComposerDraftContentIfEmpty,
composerDraftsAtom,
createNewTaskDraft,
getComposerDraftSnapshot,
isComposerDraftEmpty,
isNewTaskDraftKey,
removeComposerDraftAttachment,
replaceComposerDraftAttachments,
retargetNewTaskDraft,
scheduleUnusedComposerAttachmentCleanup,
setComposerDraftText,
setStickyComposerModelSelection,
Expand Down Expand Up @@ -169,6 +172,12 @@ type NewTaskFlowContextValue = {
readonly filteredBranches: ReadonlyArray<VcsRef>;
readonly reset: () => void;
readonly setProject: (project: EnvironmentProject) => void;
/**
* Binds the composer to an existing new-task draft (a row in the thread
* list). Returns false when the draft is gone, so the caller can fall back
* to a fresh one.
*/
readonly openDraft: (draftKey: string) => boolean;
readonly selectEnvironment: (environmentId: EnvironmentId) => void;
readonly setSelectedModelKey: (
key: string | null,
Expand Down Expand Up @@ -232,6 +241,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
? selectedEnvironmentIdOverride
: (projects[0]?.environmentId ?? null);
const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
// The new-task draft the composer is bound to. Null until a project is
// chosen; each New Task entry mints its own, so a project can hold several.
const [activeDraftKey, setActiveDraftKey] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [branchQuery, setBranchQuery] = useState("");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
Expand All @@ -247,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const reset = useCallback(() => {
setSelectedEnvironmentId(null);
setSelectedProjectKey(null);
setActiveDraftKey(null);
setSubmitting(false);
setBranchQuery("");
setExpandedProvider(null);
Expand Down Expand Up @@ -367,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProject?.environmentId ?? null,
);
// While a queued pending task is being edited its draft lives under a key
// scoped to the queued message, so per-project new-task drafts stay intact.
// scoped to the queued message, so new-task drafts stay intact.
const selectedProjectDraftKey = editingPendingTask
? pendingTaskDraftKey(editingPendingTask.messageId)
: selectedProject
? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
? activeDraftKey
: null;
// selectedProject can resolve without setProject ever running (the
// environment's first project is the fallback, and the draft screen skips
// setProject when the route's project already matches it). The composer
// still needs a draft to write into, so bind one the moment a project is
// in view and nothing else owns the key.
useEffect(() => {
if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) {
return;
}
setActiveDraftKey(
createNewTaskDraft({
environmentId: selectedProject.environmentId,
projectId: selectedProject.id,
}),
);
}, [activeDraftKey, editingPendingTask, selectedProject]);
const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
const prompt = selectedProjectDraft.text;
const attachments = selectedProjectDraft.attachments;
Expand Down Expand Up @@ -625,20 +654,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
}, [availableBranches, branchQuery]);

// New-task drafts are keyed per (environment, project), so retargeting the
// composer would otherwise show the target's empty draft and strand what the
// user typed under the old key.
// The composer's draft follows the project it will be sent to: switching
// mid-compose keeps the same draft and moves it, so typed text follows the
// user. A pending-task edit owns its own key and is untouched here.
const carryDraftContentTo = useCallback(
(project: EnvironmentProject) => {
const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;
if (
selectedProjectDraftKey?.startsWith("new-task:") &&
selectedProjectDraftKey !== nextDraftKey
) {
void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
const target = { environmentId: project.environmentId, projectId: project.id };
if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
retargetNewTaskDraft(activeDraftKey, target);
} else if (!editingPendingTaskRef.current) {
setActiveDraftKey(createNewTaskDraft(target));
}
},
[selectedProjectDraftKey],
[activeDraftKey],
);

const setProject = useCallback(
Expand All @@ -650,6 +678,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
[carryDraftContentTo],
);

const openDraft = useCallback(
(draftKey: string): boolean => {
const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
const stamp = draft?.project;
if (!isNewTaskDraftKey(draftKey) || !stamp) {
return false;
}
// The stamped project must be loaded: selectedProject falls back to
// the environment's first project otherwise, and the draft would be
// sent somewhere the user never chose.
const projectLoaded = projects.some(
(project) =>
project.environmentId === stamp.environmentId && project.id === stamp.projectId,
);
if (!projectLoaded) {
return false;
}
setActiveDraftKey(draftKey);
setSelectedEnvironmentId(stamp.environmentId);
setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId));
return true;
},
[projects],
);

const selectEnvironment = useCallback(
(environmentId: EnvironmentId) => {
const match = resolveEnvironmentProjectMatch(
Expand Down Expand Up @@ -1085,6 +1138,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
filteredBranches,
reset,
setProject,
openDraft,
selectEnvironment,
setSelectedModelKey,
setWorkspaceMode,
Expand Down Expand Up @@ -1148,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProjectKey,
selectedWorktreePath,
setProject,
openDraft,
selectBranch,
selectEnvironment,
setInteractionMode,
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => {
);
});

it("reads an id-keyed new-task draft's environment from its project stamp", () => {
const stamped = {
project: {
environmentId,
projectId: "project" as never,
createdAt: "2026-09-05T00:00:00.000Z",
},
};
expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId);
// An id-keyed draft that lost its stamp belongs to nobody: uploads must
// not start and sign-out must not sweep it into some other environment.
expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull();
// The stamp wins over a legacy-looking key when both are present.
expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe(
environmentId,
);
});

it("allows offline queuing while a connected composer waits for upload or retry", () => {
const key = composerAttachmentUploadKey(environmentId, "file");
const input = {
Expand Down
20 changes: 17 additions & 3 deletions apps/mobile/src/lib/composerAttachmentUploadQueue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";

import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key";
import type { DraftComposerAttachment } from "./composerImages";

export interface ComposerAttachmentUploadRequest {
Expand All @@ -20,22 +21,35 @@ export function composerAttachmentUploadKey(
return `${environmentId}:${attachmentId}`;
}

/**
* Which environment a composer draft belongs to. Thread drafts carry it in
* the key; pending-task editor drafts borrow it from the queued message;
* new-task drafts carry it in their project stamp (legacy project-keyed
* new-task drafts still parse from the key until they are migrated on load).
*/
export function composerDraftEnvironmentId(
draftKey: string,
queuedMessages: ReadonlyArray<{
readonly messageId: string;
readonly environmentId: EnvironmentId;
}>,
draft?: { readonly project?: { readonly environmentId: EnvironmentId } },
): EnvironmentId | null {
if (draftKey.startsWith("pending-task:")) {
return (
queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey)
?.environmentId ?? null
);
}
const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey;
const separator = scope.lastIndexOf(":");
return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null;
if (draftKey.startsWith("new-task:")) {
if (draft?.project) {
return draft.project.environmentId;
}
const legacy = parseLegacyNewTaskDraftKey(draftKey);
return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
}
const separator = draftKey.lastIndexOf(":");
return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null;
}

type UploadServerConfig = {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/state/composer-attachment-uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() {
let retained = false;
for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {
if (
composerDraftEnvironmentId(key, queued) === environmentId &&
composerDraftEnvironmentId(key, queued, draft) === environmentId &&
draft.attachments.some((candidate) => candidate.id === attachment.id)
) {
retained = setComposerDraftAttachmentUpload(key, uploaded) || retained;
Expand Down Expand Up @@ -113,7 +113,7 @@ export function useComposerAttachmentUploadWorker() {
.map((environment) => environment.environmentId),
);
const requests = Object.entries(drafts).flatMap(([key, draft]) => {
const environmentId = composerDraftEnvironmentId(key, queued);
const environmentId = composerDraftEnvironmentId(key, queued, draft);
if (environmentId === null || !connected.has(environmentId)) return [];
return draft.attachments
.filter((attachment) =>
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/state/new-task-draft-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const NEW_TASK_DRAFT_PREFIX = "new-task:";

/** Every new-task draft key: `new-task:<draftId>`. */
export function newTaskDraftKey(draftId: string): string {
return `${NEW_TASK_DRAFT_PREFIX}${draftId}`;
}

export function isNewTaskDraftKey(draftKey: string): boolean {
return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX);
}

/**
* Builds before drafts were id-keyed used `new-task:<environmentId>:<projectId>`,
* one slot per project. Ids are UUIDs and never contain a colon, so a colon
* after the prefix marks the legacy shape. Returns the split scope, or null
* when the key is not legacy.
*/
export function parseLegacyNewTaskDraftKey(
draftKey: string,
): { readonly environmentId: string; readonly projectId: string } | null {
if (!isNewTaskDraftKey(draftKey)) {
return null;
}
const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
const separator = scope.lastIndexOf(":");
if (separator <= 0 || separator === scope.length - 1) {
return null;
}
return { environmentId: scope.slice(0, separator), projectId: scope.slice(separator + 1) };
}
Loading
Loading