Skip to content
Open
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
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" />
</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 @@ -101,6 +102,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 @@ -310,9 +312,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 @@ -62,6 +63,7 @@ export interface ThreadDetailScreenProps {
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
readonly activeThreadBusy: boolean;
readonly activeTurnMessageBehavior: ActiveTurnMessageBehavior;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
Expand Down Expand Up @@ -430,6 +432,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 @@ -767,6 +767,7 @@ function ThreadRouteContent(
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
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 @@ -74,6 +76,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 @@ -87,6 +90,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {
threadListV2Enabled?: 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);
});
});
}
32 changes: 29 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,27 @@ 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 activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType;
}): boolean {
return (
input.deliveryAction === "send" &&
!input.isCreation &&
input.threadBusy &&
input.activeTurnMessageBehavior !== "steer"
);
}

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

/**
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
resolveThreadOutboxDeliveryAction,
resolveThreadOutboxFailureAction,
resolveQueuedThreadSettings,
shouldDeferConfirmedThreadOutboxDelivery,
shouldRetryThreadOutboxDelivery,
threadOutboxRetryDelayMs,
type QueuedThreadMessage,
Expand Down Expand Up @@ -487,6 +488,53 @@ describe("thread outbox", () => {
).toBe("send");
});

it("waits behind active work in queue mode and dispatches into it in steer mode", () => {
const input = {
isCreation: false,
threadExists: true,
shellStatus: "live" as const,
environmentConnected: true,
threadBusy: true,
};

// Omitted preserves the behavior of outbox entries written by older mobile builds.
expect(resolveThreadOutboxDeliveryAction(input)).toBe("wait");
expect(
resolveThreadOutboxDeliveryAction({
...input,
activeTurnMessageBehavior: "queue",
}),
).toBe("wait");
expect(
resolveThreadOutboxDeliveryAction({
...input,
activeTurnMessageBehavior: "steer",
}),
).toBe("send");
});

it("keeps steer delivery eligible when a thread becomes busy during persistence", () => {
const input = {
deliveryAction: "send" as const,
isCreation: false,
threadBusy: true,
};

expect(shouldDeferConfirmedThreadOutboxDelivery(input)).toBe(true);
expect(
shouldDeferConfirmedThreadOutboxDelivery({
...input,
activeTurnMessageBehavior: "queue",
}),
).toBe(true);
expect(
shouldDeferConfirmedThreadOutboxDelivery({
...input,
activeTurnMessageBehavior: "steer",
}),
).toBe(false);
});

it("sends queued creations once connected and live, removing already-created ones", () => {
expect(
resolveThreadOutboxDeliveryAction({
Expand Down
Loading
Loading