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
242 changes: 79 additions & 163 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useFontFamily } from "../../lib/useFontFamily";

import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import {
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
resolveEnvironmentMachineKind,
Expand Down Expand Up @@ -52,7 +48,6 @@ import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
import { ShimmeringWorkContent } from "./thread-work-log";
import { ComposerCommandPopover } from "./ComposerCommandPopover";
import { useComposerCommandMenu } from "./use-composer-command-menu";
import {
Expand All @@ -78,7 +73,6 @@ import {
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
import {
clearComposerDraftContent,
flushComposerDrafts,
getComposerDraftSnapshot,
mergeComposerDraftContent,
restoreComposerDraftSnapshot,
Expand All @@ -91,15 +85,12 @@ import {
isModelSelectionUnavailable,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { resolveProviderInteractionMode } from "./legacy-plan-mode";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
import { useNewTaskFlow } from "./new-task-flow-provider";
import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
import { useCreateProjectThread } from "./use-project-actions";
import { resolveDraftProjectSelection } from "./new-task-project-selection";
import {
resolveNewTaskBranchLabel,
Expand Down Expand Up @@ -158,7 +149,6 @@ export function NewTaskDraftScreen(props: {
readonly incomingShareId?: string;
}) {
const projects = useProjects();
const createProjectThread = useCreateProjectThread();
const flow = useNewTaskFlow();
const navigation = useNavigation();
const {
Expand Down Expand Up @@ -942,16 +932,6 @@ export function NewTaskDraftScreen(props: {
) ?? flow.selectedModel;
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const selectedWorktreePath =
draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
const interactionMode = resolveProviderInteractionMode(
selectedEnvironmentServerConfig?.providers.find(
(provider) => provider.instanceId === modelSelection?.instanceId,
),
flow.planModeEnabled ? (draft.interactionMode ?? flow.interactionMode) : "default",
);
const initialMessageText = draft.text.trim();

if (
Expand Down Expand Up @@ -1000,122 +980,77 @@ export function NewTaskDraftScreen(props: {

const editingPendingTask = flow.editingPendingTask;

if (queuesInsteadOfStarting) {
// Offline, or an attachment is still uploading: park the task in the
// outbox and let the drain send it once the environment is reachable
// and the bytes are on the server. Editing an existing pending task
// re-queues it under its original identifiers.
const metadata = editingPendingTask
? {
threadId: editingPendingTask.threadId,
commandId: editingPendingTask.commandId,
messageId: editingPendingTask.messageId,
createdAt: editingPendingTask.createdAt,
}
: makeTurnCommandMetadata();
const message = flow.buildPendingTaskMessage(metadata);
if (!message) {
return;
}
flow.setSubmitting(true);
try {
await enqueueThreadOutboxMessage(message);
} catch (error) {
Alert.alert(
"Could not queue task",
error instanceof Error ? error.message : "The task could not be saved to the outbox.",
);
return;
} finally {
flow.setSubmitting(false);
}
if (editingPendingTask) {
flow.finishEditingPendingTask();
} else {
// Drop draft-local model/workspace selections with the content. The
// next task re-resolves project defaults before sticky app defaults.
clearComposerDraftContent(draftKey, {
clearModelSelection: true,
clearWorkspaceSelection: true,
});
}
setSubmitNavigationAction(CommonActions.goBack());
// Every submission goes through the outbox: the drain uploads the
// attachments and delivers the creation, retrying across reconnects.
// When it can send now the thread screen opens immediately with the
// queued prompt and reports setup progress there, like the web draft
// does. Offline, or with uploads still in flight, the task stays a
// pending task and the sheet closes. Editing an existing pending task
// re-queues it under its original identifiers.
const metadata = editingPendingTask
? {
threadId: editingPendingTask.threadId,
commandId: editingPendingTask.commandId,
messageId: editingPendingTask.messageId,
createdAt: editingPendingTask.createdAt,
}
: makeTurnCommandMetadata();
const message = flow.buildPendingTaskMessage(metadata, {
// A task that waits in the outbox cannot know the checkout it will
// drain against; one that sends now runs against the live one.
currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName,
});
if (!message) {
return;
}

if (!queuesInsteadOfStarting) {
// Arm the lock-screen card before the async thread creation: backgrounding
// the app right after tapping submit would otherwise reject the foreground
// -only Activity start. If creation fails, the token registration's replay
// finds no work and ends the card within seconds.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: selectedProject.environmentId,
threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
projectTitle: selectedProject.title,
});
}
// Persist before clearing the draft or leaving its editor. This only waits
// for the local outbox write; server and worktree setup run on the thread.
flow.setSubmitting(true);
// Arm the lock-screen card before the async thread creation: backgrounding
// the app right after tapping submit would otherwise reject the foreground
// -only Activity start. If creation fails, the token registration's replay
// finds no work and ends the card within seconds.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: selectedProject.environmentId,
threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
projectTitle: selectedProject.title,
});
const creationBranch = resolveProjectThreadCreationBranch({
workspaceMode,
selectedBranch: selectedBranchName,
currentCheckoutBranch: flow.currentCheckoutBranchName,
});
const result = await createProjectThread({
project: selectedProject,
modelSelection,
envMode: workspaceMode,
branch: creationBranch,
worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
startFromOrigin,
runtimeMode,
interactionMode,
initialMessageText,
initialAttachments: draft.attachments,
onAttachmentsUploaded: async (attachments) => {
flow.replaceAttachments(attachments);
await flushComposerDrafts();
},
...(editingPendingTask
? {
turnMetadata: {
threadId: editingPendingTask.threadId,
commandId: editingPendingTask.commandId,
messageId: editingPendingTask.messageId,
createdAt: editingPendingTask.createdAt,
},
}
: {}),
});
flow.setSubmitting(false);

if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Alert.alert(
"Could not start task",
error instanceof Error ? error.message : "The task could not be started.",
);
}
try {
await enqueueThreadOutboxMessage(message);
} catch (error) {
Alert.alert(
"Could not queue task",
error instanceof Error ? error.message : "The task could not be saved to the outbox.",
);
return;
} finally {
flow.setSubmitting(false);
}

const draftSnapshot = getComposerDraftSnapshot(draftKey);
if (editingPendingTask) {
try {
await removeThreadOutboxMessage(editingPendingTask);
} catch (error) {
console.warn("[new-task] failed to remove delivered pending task", error);
}
flow.finishEditingPendingTask();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
} else {
// Drop draft-local model/workspace selections with the content. The
// next task re-resolves project defaults before sticky app defaults.
// The queued message owns the attachments now, so the sweep is deferred
// until the write confirms it.
clearComposerDraftContent(draftKey, {
clearModelSelection: true,
clearWorkspaceSelection: true,
deferAttachmentCleanup: true,
});
}
setSubmitNavigationAction(
StackActions.replace("Thread", {
environmentId: String(result.value.environmentId),
threadId: String(result.value.threadId),
}),
queuesInsteadOfStarting
? CommonActions.goBack()
: StackActions.replace("Thread", {
environmentId: String(message.environmentId),
threadId: String(message.threadId),
}),
);
scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments);
}

if (!selectedProject) {
Expand Down Expand Up @@ -1269,50 +1204,31 @@ export function NewTaskDraftScreen(props: {

const workspaceControls = (
<View className="flex-row items-center gap-1 px-2">
{flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? (
<View
accessible
accessibilityLabel="Setting up worktree…"
className="h-11 w-full max-w-[260px] flex-row items-center px-2"
>
<ShimmeringWorkContent
icon="arrow.triangle.branch"
iconSubtleColor={theme["--color-icon-subtle"]}
label="Setting up worktree…"
showIcon
/>
</View>
) : (
<>
<ComposerInlineControl
accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
accessibilityLabel={workspaceLabel}
disabled={isComposerInteractionLocked || voiceInput.isBusy}
iconNode={
<NewTaskWorkspaceIcon
workspaceMode={flow.workspaceMode}
worktreePath={flow.selectedWorktreePath}
/>
}
label={workspaceLabel}
maxWidth={flow.workspaceMode === "local" ? 220 : 148}
onPress={() =>
flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")
}
showChevron={false}
<ComposerInlineControl
accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
accessibilityLabel={workspaceLabel}
disabled={isComposerInteractionLocked || voiceInput.isBusy}
iconNode={
<NewTaskWorkspaceIcon
workspaceMode={flow.workspaceMode}
worktreePath={flow.selectedWorktreePath}
/>
}
label={workspaceLabel}
maxWidth={flow.workspaceMode === "local" ? 220 : 148}
onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
showChevron={false}
/>

<ComposerInlineControl
accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
chevronDirection="right"
disabled={isComposerInteractionLocked}
icon="arrow.triangle.branch"
label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
maxWidth={190}
onPress={() => openContextPicker("NewTaskBranch")}
/>
</>
)}
<ComposerInlineControl
accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
chevronDirection="right"
disabled={isComposerInteractionLocked}
icon="arrow.triangle.branch"
label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
maxWidth={190}
onPress={() => openContextPicker("NewTaskBranch")}
/>
</View>
);

Expand Down
12 changes: 6 additions & 6 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export interface ThreadComposerProps {
readonly queueCount: number;
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
/** Why sending is blocked right now (shown as the send button's label), or null. */
readonly sendBlockedReason?: string | null;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
readonly onChangeDraftMessage: (value: string) => void;
readonly onPickDraftMedia: () => Promise<void>;
Expand Down Expand Up @@ -344,11 +346,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
serverConfig: props.serverConfig,
states: uploadStates,
});
const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason;
const canSend =
hasContent &&
!voiceInput.blocksSubmission &&
attachmentBlockReason === null &&
!modelUnavailable;
hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable;

// Keep the feed inset aligned with the card or compact dictation strip.
useEffect(() => {
Expand Down Expand Up @@ -701,7 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
/>
) : (
<ComposerActionButton
accessibilityLabel={attachmentBlockReason ?? sendLabel}
accessibilityLabel={sendBlockedReason ?? sendLabel}
icon="arrow.up"
variant="primary"
disabled={!canSend}
Expand Down Expand Up @@ -792,7 +792,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
/>
) : voicePresentation.showsSend ? (
<ComposerActionButton
accessibilityLabel={attachmentBlockReason ?? sendLabel}
accessibilityLabel={sendBlockedReason ?? sendLabel}
icon="arrow.up"
variant="primary"
disabled={!canSend}
Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/src/features/threads/ThreadCreationFailedCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Pressable, View } from "react-native";

import { AppText as Text } from "../../components/AppText";

/**
* Shown in place of the composer when the server rejected a new task. The
* prompt and attachments are already back in the project draft, so the only
* action is reopening it.
*/
export function ThreadCreationFailedCard(props: {
readonly reason: string;
readonly onEditTask: () => void;
}) {
return (
<View className="gap-2.5 rounded-[20px] border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 p-4">
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-adaptive-rose-700-300">
Could not start task
</Text>
<Text className="font-sans text-sm leading-normal text-adaptive-neutral-600-400">
{props.reason}
</Text>
<Text className="font-sans text-xs leading-normal text-adaptive-neutral-600-400">
Your prompt was kept in the project draft.
</Text>
<View className="flex-row">
<Pressable
accessibilityRole="button"
className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3"
onPress={props.onEditTask}
>
<Text className="text-sm font-t3-extrabold text-white">Edit task</Text>
</Pressable>
</View>
</View>
);
}
Loading
Loading