Skip to content
Closed
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
13 changes: 7 additions & 6 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
import { useThreadModeSyncDrain } from "./state/use-thread-mode-sync-drain";

const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);

Expand Down Expand Up @@ -293,12 +294,12 @@ function workspacePathFromState(state: NavigationState): string {
return path.startsWith("/") ? path : `/${path}`;
}

// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
// each enqueue, shell change, or reconnect.
function ThreadOutboxDrainWorker() {
// The sync hooks subscribe to queued work, thread shells, and connection
// statuses. Hosting them in a null-rendering leaf keeps those updates from
// re-rendering RootStackLayout (and with it every screen).
function BackgroundSyncWorker() {
useThreadOutboxDrain();
useThreadModeSyncDrain();
return null;
}

Expand Down Expand Up @@ -337,7 +338,7 @@ function RootStackLayout(props: {

return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<BackgroundSyncWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
Expand Down
86 changes: 85 additions & 1 deletion apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "@effect/vitest";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import { CommandId, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";

import { appAtomRegistry } from "./atom-registry";
import {
Expand Down Expand Up @@ -66,6 +66,29 @@ describe("mobile composer drafts", () => {
});
});

it("preserves modes when hydrating an edited pending task", () => {
expect(
decodePersistedComposerDrafts({
schemaVersion: 1,
drafts: {
"pending-task:message-1": {
text: "edit this queued task",
attachments: [],
runtimeMode: "approval-required",
interactionMode: "plan",
},
},
}),
).toEqual({
"pending-task:message-1": {
text: "edit this queued task",
attachments: [],
runtimeMode: "approval-required",
interactionMode: "plan",
},
});
});

it("keeps legacy content-only drafts and rejects invalid selector state", () => {
expect(
decodePersistedComposerDrafts({
Expand All @@ -91,6 +114,67 @@ describe("mobile composer drafts", () => {
).toThrow();
});

it("drops server-owned modes when hydrating an existing thread draft", () => {
expect(
decodePersistedComposerDrafts({
schemaVersion: 1,
drafts: {
"environment-1:thread-1": {
text: "keep this draft",
attachments: [],
runtimeMode: "approval-required",
interactionMode: "plan",
},
},
}),
).toEqual({
"environment-1:thread-1": {
text: "keep this draft",
attachments: [],
},
});
});

it("hydrates pending existing-thread mode changes for retry", () => {
const runtimeModeSync = {
value: "approval-required" as const,
commandId: CommandId.make("command-runtime"),
createdAt: "2026-08-06T10:00:00.000Z",
dispatchSequence: null,
};
const interactionModeSync = {
value: "plan" as const,
commandId: CommandId.make("command-interaction"),
createdAt: "2026-08-06T10:00:01.000Z",
dispatchSequence: 12,
};

expect(
decodePersistedComposerDrafts({
schemaVersion: 1,
drafts: {
"environment-1:thread-1": {
text: "keep this draft",
attachments: [],
runtimeMode: "full-access",
interactionMode: "default",
runtimeModeSync,
interactionModeSync,
},
},
}),
).toEqual({
"environment-1:thread-1": {
text: "keep this draft",
attachments: [],
runtimeMode: runtimeModeSync.value,
interactionMode: interactionModeSync.value,
runtimeModeSync,
interactionModeSync,
},
});
});

it("clears sent content without clearing the selected model or workspace", () => {
const draftKey = "environment-1:thread-1";
const draft: ComposerDraft = {
Expand Down
58 changes: 54 additions & 4 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useAtomValue } from "@effect/atom-react";
import {
CommandId,
IsoDateTime,
ModelSelection as ModelSelectionSchema,
NonNegativeInt,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
ProviderInteractionMode as ProviderInteractionModeSchema,
RuntimeMode as RuntimeModeSchema,
Expand All @@ -9,6 +12,7 @@ import {
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
import type { PendingThreadModeSync } from "@t3tools/client-runtime/state/thread-mode-sync";
import * as Schema from "effect/Schema";
import { useEffect } from "react";
import { Atom } from "effect/unstable/reactivity";
Expand Down Expand Up @@ -44,6 +48,8 @@ export interface ComposerDraft {
readonly modelSelection?: ModelSelection;
readonly runtimeMode?: RuntimeMode;
readonly interactionMode?: ProviderInteractionMode;
readonly runtimeModeSync?: PendingThreadModeSync<RuntimeMode>;
readonly interactionModeSync?: PendingThreadModeSync<ProviderInteractionMode>;
readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
}

Expand All @@ -62,7 +68,12 @@ export interface ComposerDraftWorkspaceSelection {

export type ComposerDraftSettingsUpdate = Pick<
ComposerDraft,
"modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection"
| "modelSelection"
| "runtimeMode"
| "interactionMode"
| "runtimeModeSync"
| "interactionModeSync"
| "workspaceSelection"
>;

const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
Expand All @@ -72,13 +83,29 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
startFromOrigin: Schema.optional(Schema.Boolean),
});

const PendingRuntimeModeSyncSchema = Schema.Struct({
value: RuntimeModeSchema,
commandId: CommandId,
createdAt: IsoDateTime,
dispatchSequence: Schema.NullOr(NonNegativeInt),
});

const PendingInteractionModeSyncSchema = Schema.Struct({
value: ProviderInteractionModeSchema,
commandId: CommandId,
createdAt: IsoDateTime,
dispatchSequence: Schema.NullOr(NonNegativeInt),
});

const ComposerDraftSchema = Schema.Struct({
text: Schema.String,
attachments: Schema.Array(DraftComposerImageAttachmentSchema),
importedShareIds: Schema.optional(Schema.Array(Schema.String)),
modelSelection: Schema.optional(ModelSelectionSchema),
runtimeMode: Schema.optional(RuntimeModeSchema),
interactionMode: Schema.optional(ProviderInteractionModeSchema),
runtimeModeSync: Schema.optional(PendingRuntimeModeSyncSchema),
interactionModeSync: Schema.optional(PendingInteractionModeSyncSchema),
workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
});

Expand Down Expand Up @@ -131,15 +158,38 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
draft.modelSelection === undefined &&
draft.runtimeMode === undefined &&
draft.interactionMode === undefined &&
draft.runtimeModeSync === undefined &&
draft.interactionModeSync === undefined &&
draft.workspaceSelection === undefined
);
}

export function decodePersistedComposerDrafts(value: unknown): Record<string, ComposerDraft> {
const parsed = decodePersistedComposerDraftsDocument(value);
return Object.fromEntries(
Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)),
);
const drafts: Record<string, ComposerDraft> = {};
for (const [draftKey, draft] of Object.entries(parsed.drafts)) {
let decodedDraft: ComposerDraft = draft;
if (!draftKey.startsWith("new-task:") && !draftKey.startsWith("pending-task:")) {
const {
runtimeMode: _runtimeMode,
interactionMode: _interactionMode,
runtimeModeSync,
interactionModeSync,
...threadDraft
} = draft;
decodedDraft = {
...threadDraft,
...(runtimeModeSync ? { runtimeMode: runtimeModeSync.value, runtimeModeSync } : {}),
...(interactionModeSync
? { interactionMode: interactionModeSync.value, interactionModeSync }
: {}),
};
}
if (!isEmptyDraft(decodedDraft)) {
drafts[draftKey] = decodedDraft;
}
}
return drafts;
}

async function getComposerDraftsFile() {
Expand Down
30 changes: 25 additions & 5 deletions apps/mobile/src/state/use-remote-environment-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,33 @@ const connectionPairingUrlAtom = Atom.make("").pipe(
Atom.withLabel("mobile:connection-pairing-url"),
);

const pendingConnectionErrorAtom = Atom.make<string | null>(null).pipe(
interface PendingConnectionError {
readonly id: number;
readonly message: string;
}

let nextPendingConnectionErrorId = 0;

const pendingConnectionErrorAtom = Atom.make<PendingConnectionError | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:pending-connection-error"),
);

export function setPendingConnectionError(message: string | null): void {
appAtomRegistry.set(pendingConnectionErrorAtom, message);
export function setPendingConnectionError(message: string | null): number | null {
if (message === null) {
appAtomRegistry.set(pendingConnectionErrorAtom, null);
return null;
}
const id = ++nextPendingConnectionErrorId;
appAtomRegistry.set(pendingConnectionErrorAtom, { id, message });
return id;
}

export function clearPendingConnectionError(id: number): void {
const pendingError = appAtomRegistry.get(pendingConnectionErrorAtom);
if (pendingError?.id === id) {
appAtomRegistry.set(pendingConnectionErrorAtom, null);
}
}

function toSavedConnection(
Expand Down Expand Up @@ -147,7 +167,7 @@ export function useRemoteConnectionStatus() {
return {
connectedEnvironments,
connectionState: workspace.state.connectionState,
connectionError: pendingConnectionError ?? workspace.state.connectionError,
connectionError: pendingConnectionError?.message ?? workspace.state.connectionError,
};
}

Expand Down Expand Up @@ -221,7 +241,7 @@ export function useRemoteConnections() {
connectionPairingUrl,
connectionState,
connectionError,
pairingConnectionError: pendingConnectionError,
pairingConnectionError: pendingConnectionError?.message ?? null,
connectedEnvironments,
connectedEnvironmentCount: connectedEnvironments.length,
onChangeConnectionPairingUrl,
Expand Down
28 changes: 22 additions & 6 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import { beginThreadModeSync } from "@t3tools/client-runtime/state/thread-mode-sync";
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";

import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
Expand All @@ -22,6 +23,7 @@ import {
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";
import { buildThreadFeed } from "../lib/threadActivity";
import { uuidv4 } from "../lib/uuid";
import { appAtomRegistry } from "../state/atom-registry";
import {
appendComposerDraftAttachments,
Expand Down Expand Up @@ -281,22 +283,36 @@ export function useThreadComposerState() {

const onUpdateRuntimeMode = useCallback(
(value: RuntimeMode) => {
if (!selectedThreadKey) {
if (!selectedThreadKey || !selectedThreadShell || value === runtimeMode) {
return;
}
updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value });
updateComposerDraftSettings(selectedThreadKey, {
runtimeMode: value,
runtimeModeSync: beginThreadModeSync(
value,
CommandId.make(uuidv4()),
new Date().toISOString(),
),
});
},
[selectedThreadKey],
[runtimeMode, selectedThreadKey, selectedThreadShell],
);

const onUpdateInteractionMode = useCallback(
(value: ProviderInteractionMode) => {
if (!selectedThreadKey) {
if (!selectedThreadKey || !selectedThreadShell || value === interactionMode) {
return;
}
updateComposerDraftSettings(selectedThreadKey, { interactionMode: value });
updateComposerDraftSettings(selectedThreadKey, {
interactionMode: value,
interactionModeSync: beginThreadModeSync(
value,
CommandId.make(uuidv4()),
new Date().toISOString(),
),
});
},
[selectedThreadKey],
[interactionMode, selectedThreadKey, selectedThreadShell],
);

return {
Expand Down
Loading
Loading