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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2025]
os: [ubuntu-24.04]
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down
90 changes: 65 additions & 25 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useFontFamily } from "../../lib/useFontFamily";

import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
Expand All @@ -38,6 +39,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
import { ComposerCommandPopover } from "./ComposerCommandPopover";
import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice";
import { useComposerCommandMenu } from "./use-composer-command-menu";
import {
ComposerDictationCancelAction,
Expand Down Expand Up @@ -158,6 +160,15 @@ export function NewTaskDraftScreen(props: {
connectedEnvironments.find(
(environment) => environment.environmentId === selectedProject.environmentId,
)?.connectionState === "connected";
const providerAdmissionReason = getProviderAdmissionUnavailableReason({
provider: flow.selectedProviderStatus,
instanceId: flow.selectedModel ? String(flow.selectedModel.instanceId) : undefined,
providerSnapshotKnown: selectedEnvironmentServerConfig != null,
});
const providerUnavailable =
providerAdmissionReason === null
? null
: { headline: "Unavailable" as const, detail: providerAdmissionReason };
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
Expand Down Expand Up @@ -794,18 +805,20 @@ export function NewTaskDraftScreen(props: {
if (voiceInput.blocksSubmission) return;
const selectedProject = flow.selectedProject;
const draftKey = flow.draftKey;
if (!selectedProject || !draftKey) {
if (!selectedProject || !draftKey || providerUnavailable) {
return;
}
const draft = getComposerDraftSnapshot(draftKey);
// Snapshot read keeps just-typed selector state; the availability gate
// still applies so a stored selection on a disabled provider falls back
// to the flow's resolved model.
// Snapshot read keeps just-typed selector state. Ambient stale defaults
// may fall back, but a human/recovered exact provider choice must remain
// blocked rather than silently switch accounts.
const modelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? flow.selectedModel;
draft.providerSelectionExplicit === true && draft.modelSelection !== undefined
? resolveSelectableModelSelection(selectedEnvironmentServerConfig, draft.modelSelection)
: (resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? flow.selectedModel);
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const selectedWorktreePath =
Expand Down Expand Up @@ -837,26 +850,43 @@ export function NewTaskDraftScreen(props: {
}

const editingPendingTask = flow.editingPendingTask;
const retryTurnMetadata =
editingPendingTask?.deliveryHold === undefined ? null : makeTurnCommandMetadata();

if (!environmentConnected) {
// Offline: park the task in the outbox; the drain sends it when the
// environment reconnects. 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();
// environment reconnects. Ordinary edits preserve their identifiers;
// explicitly submitting a held retarget uses the fresh metadata above.
const metadata =
retryTurnMetadata ??
(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);
if (
editingPendingTask !== null &&
editingPendingTask.deliveryHold !== undefined &&
editingPendingTask.messageId !== message.messageId
) {
try {
await removeThreadOutboxMessage(editingPendingTask);
} catch (error) {
// The replacement is already durable and the old entry remains
// held, so neither copy can lose or double-send the content.
console.warn("[new-task] failed to remove retargeted held task", error);
}
}
} catch (error) {
Alert.alert(
"Could not queue task",
Expand Down Expand Up @@ -912,7 +942,7 @@ export function NewTaskDraftScreen(props: {
},
...(editingPendingTask
? {
turnMetadata: {
turnMetadata: retryTurnMetadata ?? {
threadId: editingPendingTask.threadId,
commandId: editingPendingTask.commandId,
messageId: editingPendingTask.messageId,
Expand Down Expand Up @@ -972,8 +1002,9 @@ export function NewTaskDraftScreen(props: {

const isAndroid = Platform.OS === "android";
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedProject?.workspaceRoot?.trim()) &&
Boolean(flow.selectedModel) &&
providerUnavailable === null &&
flow.prompt.trim().length > 0 &&
isIncomingShareReady &&
!isImportingShare &&
Expand Down Expand Up @@ -1140,6 +1171,11 @@ export function NewTaskDraftScreen(props: {
) : null}
<View className="pb-1">{workspaceControls}</View>

<ProviderUnavailableNotice
provider={flow.selectedProviderStatus}
reason={providerAdmissionReason}
/>

<ComposerSurface
style={{
borderRadius: 26,
Expand Down Expand Up @@ -1247,11 +1283,15 @@ export function NewTaskDraftScreen(props: {
{voicePresentation.showsSend ? (
<ComposerActionButton
accessibilityLabel={
flow.submitting
? "Starting task"
: environmentConnected
? "Start task"
: "Queue task"
providerUnavailable
? `Start unavailable. ${providerUnavailable.detail}`
: flow.submitting
? "Starting task"
: !canStart
? "Start unavailable. Add a message and complete the task setup."
: environmentConnected
? "Start task"
: "Queue task. The environment is disconnected; this task will remain queued."
}
disabled={!canStart}
icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/features/threads/ProviderUnavailableNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getProviderUnavailablePresentation } from "@t3tools/client-runtime/providerAvailability";
import type { ServerProvider } from "@t3tools/contracts";
import { View } from "react-native";

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

export function ProviderUnavailableNotice(props: {
readonly provider: ServerProvider | null | undefined;
readonly reason?: string | null;
readonly title?: string;
}) {
const presentation = getProviderUnavailablePresentation(props.provider);
const detail = props.reason?.trim() || presentation?.detail;
if (!detail) return null;

const providerName = props.provider?.displayName?.trim() || "Provider";
const title = props.title ?? `${providerName} is unavailable`;

return (
<View
accessible
accessibilityLabel={`${title}. ${detail}`}
accessibilityLiveRegion="polite"
accessibilityRole="alert"
className="mb-2 gap-1 rounded-2xl border border-red-500/30 bg-red-500/10 px-3.5 py-3"
>
<Text className="text-sm font-t3-bold text-foreground">{title}</Text>
<Text className="text-sm leading-snug text-foreground-muted">{detail}</Text>
</View>
);
}
80 changes: 80 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability";
import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation";
import type {
ModelSelection,
OrchestrationSession,
ServerConfig,
ServerProvider,
} from "@t3tools/contracts";

/** Resolve every composer surface against the persisted session binding first. */
export function resolveThreadComposerAuthority(input: {
readonly serverConfig: Pick<ServerConfig, "providers"> | null | undefined;
readonly modelSelection: ModelSelection;
readonly sessionProviderInstanceId?: ModelSelection["instanceId"] | undefined;
}): {
readonly modelSelection: ModelSelection | null;
readonly provider: ServerProvider | null;
readonly providerAdmissionAvailable: boolean;
readonly providerAdmissionReason: string | null;
readonly providerBindingMismatch: boolean;
} {
const providers = input.serverConfig?.providers ?? [];
const instanceId = input.modelSelection.instanceId;
const selectedProvider =
providers.find((candidate) => candidate.instanceId === instanceId) ?? null;
const transition = input.sessionProviderInstanceId
? resolveProviderContinuationTransition({
providers,
currentInstanceId: input.sessionProviderInstanceId,
targetInstanceId: instanceId,
})
: ({ compatible: true } as const);
const providerBindingMismatch = !transition.compatible;
const provider = providerBindingMismatch
? (providers.find((candidate) => candidate.instanceId === input.sessionProviderInstanceId) ??
null)
: selectedProvider;
const providerAdmissionReason = transition.compatible
? getProviderAdmissionUnavailableReason({
provider,
instanceId: String(instanceId),
providerSnapshotKnown: input.serverConfig !== null && input.serverConfig !== undefined,
})
: transition.reason;
return {
modelSelection: providerBindingMismatch ? null : input.modelSelection,
provider,
providerAdmissionAvailable: providerAdmissionReason === null,
providerAdmissionReason,
providerBindingMismatch,
};
}

/** Describe why a turn cannot be admitted immediately, even when it can be saved to the outbox. */
export function resolveThreadComposerAdmissionReason(input: {
readonly providerReason: string | null;
readonly projectCwd: string | null;
readonly connectionState: EnvironmentConnectionPhase;
}): string | null {
if (input.providerReason !== null) return input.providerReason;
if (input.projectCwd === null) return "This thread's project workspace is unavailable.";
if (input.connectionState !== "connected") {
if (input.connectionState === "connecting" || input.connectionState === "reconnecting") {
return "The environment is still connecting. This send will remain queued.";
}
if (input.connectionState === "error") {
return "The environment connection failed. This send will remain queued.";
}
return "The environment is offline. This send will remain queued.";
}
return null;
}

/** Provider unavailability must never remove the active turn's escape hatch. */
export function threadComposerShowsStopAction(
status: OrchestrationSession["status"] | null | undefined,
): boolean {
return status === "running" || status === "starting";
}
Loading
Loading