Skip to content
29 changes: 29 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { SymbolView } from "../../components/AppSymbol";
import * as Effect from "effect/Effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR } from "@t3tools/contracts/settings";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -530,8 +531,36 @@ function ConfiguredSettingsRouteScreen() {
}

function GeneralSettingsSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const activeTurnMessageBehavior = AsyncResult.isSuccess(preferencesResult)
? (preferencesResult.value.activeTurnMessageBehavior ?? DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR)
: DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR;

return (
<SettingsSection title="General">
<SettingsRow
icon="arrow.triangle.branch"
label="Messages While Working"
value={activeTurnMessageBehavior === "queue" ? "Queue" : "Steer"}
onPress={() =>
Alert.alert(
"Messages while working",
"Steer adds the message to the active turn. Queue waits and sends messages one at a time after the current turn finishes.",
[
{
text: "Steer",
onPress: () => savePreferences({ activeTurnMessageBehavior: "steer" }),
},
{
text: "Queue",
onPress: () => savePreferences({ activeTurnMessageBehavior: "queue" }),
},
{ text: "Cancel", style: "cancel" },
],
)
}
/>
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
</SettingsSection>
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
RuntimeMode,
ServerConfig as T3ServerConfig,
} from "@t3tools/contracts";
import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings";
import {
detectComposerTrigger,
replaceTextRange,
Expand Down Expand Up @@ -99,6 +100,7 @@ export interface ThreadComposerProps {
readonly serverConfig: T3ServerConfig | null;
readonly queueCount: number;
readonly activeThreadBusy: boolean;
readonly activeTurnMessageBehavior: ActiveTurnMessageBehavior;
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
Expand Down Expand Up @@ -319,9 +321,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
props.selectedThread.session?.status === "starting";

const sendLabel =
props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0
props.connectionState !== "connected" || props.queueCount > 0
? "Queue"
: "Send";
: props.activeThreadBusy
? props.activeTurnMessageBehavior === "queue"
? "Queue"
: "Steer"
: "Send";
const currentModelSelection = props.selectedThread.modelSelection;
const currentRuntimeMode = props.selectedThread.runtimeMode;
const currentInteractionMode = props.selectedThread.interactionMode ?? "default";
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ServerConfig as T3ServerConfig,
ThreadId,
} from "@t3tools/contracts";
import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface ThreadDetailScreenProps {
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
readonly activeThreadBusy: boolean;
readonly activeTurnMessageBehavior: ActiveTurnMessageBehavior;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
Expand Down Expand Up @@ -433,6 +435,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
activeTurnMessageBehavior={props.activeTurnMessageBehavior}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
bottomInset={composerBottomInset}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ function ThreadRouteContent(
threadSyncStatus={selectedThreadDetailState.status}
loadEarlier={loadEarlierTurns}
activeThreadBusy={composer.activeThreadBusy}
activeTurnMessageBehavior={composer.activeTurnMessageBehavior}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings";

import * as MobileDatabase from "./mobile-database";
import * as MobileSecureStorage from "./mobile-secure-storage";
Expand All @@ -15,6 +16,7 @@ const PREFERENCES_KEY = "t3code.preferences";
const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";

export interface Preferences {
readonly activeTurnMessageBehavior?: ActiveTurnMessageBehavior;
readonly liveActivitiesEnabled?: boolean;
readonly baseFontSize?: number;
readonly terminalFontSize?: number | null;
Expand Down Expand Up @@ -75,6 +77,7 @@ export class MobilePreferencesStore extends Context.Service<

function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
activeTurnMessageBehavior?: ActiveTurnMessageBehavior;
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
terminalFontSize?: number | null;
Expand All @@ -88,6 +91,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {
legacyThreadListEnabled?: boolean;
} = {};

if (
parsed.activeTurnMessageBehavior === "steer" ||
parsed.activeTurnMessageBehavior === "queue"
) {
preferences.activeTurnMessageBehavior = parsed.activeTurnMessageBehavior;
}
if (typeof parsed.liveActivitiesEnabled === "boolean") {
preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
}
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/state/preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("../lib/runtime", async () => {

import type { Preferences } from "../persistence/mobile-preferences";
import {
awaitActiveTurnMessageBehavior,
createMobilePreferencesState,
MobilePreferencesLoadError,
MobilePreferencesSaveError,
Expand Down Expand Up @@ -62,6 +63,32 @@ function makePreferencesState(
}

describe("mobile preferences state", () => {
it("waits for the persisted active-turn behavior before sending", async () => {
const pendingLoad = deferred<Preferences>();
const state = makePreferencesState({
load: Effect.promise(() => pendingLoad.promise),
savePatch: (patch) => Effect.succeed(patch),
});
const registry = AtomRegistry.make();
const unmount = registry.mount(state.preferencesAtom);

let settled = false;
const behaviorPromise = awaitActiveTurnMessageBehavior(registry, state.preferencesAtom).then(
(behavior) => {
settled = true;
return behavior;
},
);
await Promise.resolve();
expect(settled).toBe(false);

pendingLoad.resolve({ activeTurnMessageBehavior: "queue" });
await expect(behaviorPromise).resolves.toBe("queue");

unmount();
registry.dispose();
});

it.effect("shares one preference load across consumers", () =>
Effect.gen(function* () {
const load = vi.fn(() => Promise.resolve<Preferences>({ baseFontSize: 17 }));
Expand Down
41 changes: 40 additions & 1 deletion apps/mobile/src/state/preferences.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as Effect from "effect/Effect";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";

import { DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR } from "@t3tools/contracts/settings";
import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings";
import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences";
import * as Runtime from "../lib/runtime";

Expand Down Expand Up @@ -122,3 +124,40 @@ export const mobilePreferencesState = createMobilePreferencesState(mobilePrefere

export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom;
export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom;

function settledActiveTurnMessageBehavior<E>(
result: AsyncResult.AsyncResult<Preferences, E>,
): ActiveTurnMessageBehavior | null {
if (result.waiting) {
return null;
}
return AsyncResult.isSuccess(result)
? (result.value.activeTurnMessageBehavior ?? DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR)
: DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR;
}

/**
* Reads the send behavior from the settled preference snapshot. A composer can
* render before the device preference read finishes, so capturing its
* render-time fallback would steer a message that the user intended to queue.
*/
export function awaitActiveTurnMessageBehavior<E>(
registry: AtomRegistry.AtomRegistry,
preferencesAtom: Atom.Atom<AsyncResult.AsyncResult<Preferences, E>>,
): Promise<ActiveTurnMessageBehavior> {
const current = settledActiveTurnMessageBehavior(registry.get(preferencesAtom));
if (current !== null) {
return Promise.resolve(current);
}

return new Promise((resolve) => {
const unsubscribe = registry.subscribe(preferencesAtom, (result) => {
const behavior = settledActiveTurnMessageBehavior(result);
if (behavior === null) {
return;
}
unsubscribe();
resolve(behavior);
});
});
}
35 changes: 32 additions & 3 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ import {
type RuntimeMode as RuntimeModeType,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import {
ActiveTurnMessageBehavior,
type ActiveTurnMessageBehavior as ActiveTurnMessageBehaviorType,
} from "@t3tools/contracts/settings";

import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";

const THREAD_OUTBOX_SCHEMA_VERSION = 3;
const THREAD_OUTBOX_SCHEMA_VERSION = 4;
const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;

const QueuedThreadCreationSchema = Schema.Struct({
Expand All @@ -37,7 +41,7 @@ const QueuedThreadCreationSchema = Schema.Struct({
});

export const QueuedThreadMessageSchema = Schema.Struct({
schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),
schemaVersion: Schema.Literals([1, 2, 3, THREAD_OUTBOX_SCHEMA_VERSION]),
environmentId: EnvironmentId,
threadId: ThreadId,
messageId: MessageId,
Expand All @@ -47,6 +51,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
modelSelection: Schema.optional(ModelSelection),
runtimeMode: Schema.optional(RuntimeMode),
interactionMode: Schema.optional(ProviderInteractionMode),
activeTurnMessageBehavior: Schema.optional(ActiveTurnMessageBehavior),
// Present when the queued item creates a brand-new thread (pending task)
// instead of appending a turn to an existing one.
creation: Schema.optional(QueuedThreadCreationSchema),
Expand Down Expand Up @@ -76,6 +81,11 @@ export interface QueuedThreadMessage {
readonly modelSelection?: ModelSelectionType;
readonly runtimeMode?: RuntimeModeType;
readonly interactionMode?: ProviderInteractionModeType;
/**
* Snapshot of the send preference at enqueue time. Older persisted mobile
* outbox entries omit this and retain the historical queue behavior.
*/
readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType;
readonly creation?: QueuedThreadCreation;
readonly createdAt: string;
}
Expand Down Expand Up @@ -148,12 +158,29 @@ export function threadOutboxRetryDelayMs(attempt: number): number {

export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";

export function shouldDeferConfirmedThreadOutboxDelivery(input: {
readonly deliveryAction: ThreadOutboxDeliveryAction;
readonly isCreation: boolean;
readonly threadBusy: boolean;
readonly threadSteerable: boolean;
readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType;
}): boolean {
return (
input.deliveryAction === "send" &&
!input.isCreation &&
input.threadBusy &&
!(input.activeTurnMessageBehavior === "steer" && input.threadSteerable)
);
}

export function resolveThreadOutboxDeliveryAction(input: {
readonly isCreation: boolean;
readonly threadExists: boolean;
readonly shellStatus: EnvironmentShellStatus;
readonly environmentConnected: boolean;
readonly threadBusy: boolean;
readonly threadSteerable: boolean;
readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType;
}): ThreadOutboxDeliveryAction {
if (input.isCreation) {
// A pending task creates its thread on delivery. If the thread already
Expand All @@ -169,7 +196,9 @@ export function resolveThreadOutboxDeliveryAction(input: {
if (!input.threadExists) {
return input.shellStatus === "live" ? "remove" : "wait";
}
return input.environmentConnected && !input.threadBusy ? "send" : "wait";
const canSendWhileBusy =
!input.threadBusy || (input.activeTurnMessageBehavior === "steer" && input.threadSteerable);
return input.environmentConnected && canSendWhileBusy ? "send" : "wait";
}

/**
Expand Down
Loading
Loading