Skip to content

Commit 7b53cac

Browse files
feat(mobile): sync composer drafts across devices
1 parent 9580bcf commit 7b53cac

9 files changed

Lines changed: 279 additions & 24 deletions
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import {
3+
canonicalComposerDraftCommon,
4+
composerDraftCommonEquals,
5+
createComposerDraftEnvironmentAtoms,
6+
createComposerDraftSyncController,
7+
type ComposerDraftSyncController,
8+
} from "@t3tools/client-runtime/state/composer-drafts";
9+
import type {
10+
ComposerDraftCommon,
11+
ComposerDraftSnapshot,
12+
ScopedThreadRef,
13+
} from "@t3tools/contracts";
14+
import { AsyncResult, Atom } from "effect/unstable/reactivity";
15+
import { useEffect, useRef } from "react";
16+
17+
import { connectionAtomRuntime } from "../connection/runtime";
18+
import { scopedThreadKey } from "../lib/scopedEntities";
19+
import { uuidv4 } from "../lib/uuid";
20+
import { appAtomRegistry } from "./atom-registry";
21+
import { serverEnvironment } from "./server";
22+
import {
23+
applySyncedComposerDraftCommon,
24+
composerDraftsAtom,
25+
composerDraftsLoadedAtom,
26+
type ComposerDraft,
27+
} from "./use-composer-drafts";
28+
29+
export const composerDraftEnvironment = createComposerDraftEnvironmentAtoms(connectionAtomRuntime);
30+
31+
const EMPTY_SYNC_ATOM = Atom.make<null>(null).pipe(
32+
Atom.withLabel("mobile:composer-draft-sync-disabled"),
33+
);
34+
const revisions = new Map<string, number>();
35+
const suppressedPostSendCommon = new Map<string, ComposerDraftCommon | null>();
36+
37+
export function readComposerDraftRevision(threadRef: ScopedThreadRef): number | undefined {
38+
return revisions.get(scopedThreadKey(threadRef.environmentId, threadRef.threadId));
39+
}
40+
41+
/** Prevents retained selector settings from being mistaken for a new draft. */
42+
export function markComposerDraftSent(threadRef: ScopedThreadRef): void {
43+
const key = scopedThreadKey(threadRef.environmentId, threadRef.threadId);
44+
const draft = appAtomRegistry.get(composerDraftsAtom)[key];
45+
suppressedPostSendCommon.set(key, commonFromDraft(draft ?? { text: "", attachments: [] }));
46+
}
47+
48+
function commonFromDraft(draft: ComposerDraft): ComposerDraftCommon | null {
49+
if (draft.attachments.length > 0) return null;
50+
return canonicalComposerDraftCommon({
51+
text: draft.text,
52+
modelSelection: draft.modelSelection ?? null,
53+
runtimeMode: draft.runtimeMode ?? null,
54+
interactionMode: draft.interactionMode ?? null,
55+
});
56+
}
57+
58+
export function useServerComposerDraftSync(threadRef: ScopedThreadRef | null): void {
59+
const drafts = useAtomValue(composerDraftsAtom);
60+
const draftsLoaded = useAtomValue(composerDraftsLoadedAtom);
61+
const serverConfig = useAtomValue(
62+
threadRef === null
63+
? EMPTY_SYNC_ATOM
64+
: serverEnvironment.configValueAtom(threadRef.environmentId),
65+
);
66+
const enabled =
67+
draftsLoaded &&
68+
threadRef !== null &&
69+
serverConfig !== null &&
70+
"environment" in serverConfig &&
71+
serverConfig.environment.capabilities.composerDraftSync === true;
72+
const streamResult = useAtomValue(
73+
enabled && threadRef !== null
74+
? composerDraftEnvironment.changes({
75+
environmentId: threadRef.environmentId,
76+
input: { threadId: threadRef.threadId },
77+
})
78+
: EMPTY_SYNC_ATOM,
79+
);
80+
const draftKey =
81+
threadRef === null ? null : scopedThreadKey(threadRef.environmentId, threadRef.threadId);
82+
const draft = draftKey === null ? null : (drafts[draftKey] ?? null);
83+
const draftRef = useRef<ComposerDraft | null>(draft);
84+
draftRef.current = draft;
85+
const controllerRef = useRef<ComposerDraftSyncController | null>(null);
86+
87+
useEffect(() => {
88+
controllerRef.current?.dispose();
89+
if (!enabled || threadRef === null || draftKey === null) {
90+
controllerRef.current = null;
91+
return;
92+
}
93+
const key = draftKey;
94+
const readLocal = () => {
95+
const common = commonFromDraft(draftRef.current ?? { text: "", attachments: [] });
96+
if (!suppressedPostSendCommon.has(key)) return common;
97+
const baseline = suppressedPostSendCommon.get(key) ?? null;
98+
if (composerDraftCommonEquals(common, baseline)) return null;
99+
suppressedPostSendCommon.delete(key);
100+
return common;
101+
};
102+
const controller = createComposerDraftSyncController({
103+
threadId: threadRef.threadId,
104+
readLocal,
105+
canApplyRemote: () => (draftRef.current?.attachments.length ?? 0) === 0,
106+
applyRemote: (common) => applySyncedComposerDraftCommon(key, common),
107+
update: async (input) => {
108+
const result = await composerDraftEnvironment.update.run(appAtomRegistry, {
109+
environmentId: threadRef.environmentId,
110+
input,
111+
});
112+
return AsyncResult.isSuccess(result) ? result.value : null;
113+
},
114+
createMutationId: () => `mobile:${uuidv4()}`,
115+
scheduleTask: (task, delayMs) => {
116+
const timer = setTimeout(task, delayMs);
117+
return () => clearTimeout(timer);
118+
},
119+
onRevisionChange: (snapshot: ComposerDraftSnapshot) => {
120+
revisions.set(key, snapshot.revision);
121+
},
122+
});
123+
controllerRef.current = controller;
124+
return () => {
125+
controller.dispose();
126+
if (controllerRef.current === controller) controllerRef.current = null;
127+
revisions.delete(key);
128+
suppressedPostSendCommon.delete(key);
129+
};
130+
}, [draftKey, enabled, threadRef?.environmentId, threadRef?.threadId]);
131+
132+
useEffect(() => {
133+
if (streamResult !== null && AsyncResult.isSuccess(streamResult)) {
134+
controllerRef.current?.observeSnapshot(streamResult.value);
135+
}
136+
}, [streamResult]);
137+
138+
useEffect(() => {
139+
controllerRef.current?.observeLocalChange();
140+
}, [
141+
draft?.attachments,
142+
draft?.interactionMode,
143+
draft?.modelSelection,
144+
draft?.runtimeMode,
145+
draft?.text,
146+
]);
147+
}

apps/mobile/src/state/thread-outbox-model.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IsoDateTime,
77
MessageId,
88
ModelSelection,
9+
NonNegativeInt,
910
ProjectId,
1011
ProviderInteractionMode,
1112
RuntimeMode,
@@ -21,7 +22,7 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema
2122
import type { DraftComposerImageAttachment } from "../lib/composerImages";
2223
import { scopedThreadKey } from "../lib/scopedEntities";
2324

24-
const THREAD_OUTBOX_SCHEMA_VERSION = 3;
25+
const THREAD_OUTBOX_SCHEMA_VERSION = 4;
2526
const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
2627

2728
const QueuedThreadCreationSchema = Schema.Struct({
@@ -37,7 +38,7 @@ const QueuedThreadCreationSchema = Schema.Struct({
3738
});
3839

3940
export const QueuedThreadMessageSchema = Schema.Struct({
40-
schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),
41+
schemaVersion: Schema.Literals([1, 2, 3, THREAD_OUTBOX_SCHEMA_VERSION]),
4142
environmentId: EnvironmentId,
4243
threadId: ThreadId,
4344
messageId: MessageId,
@@ -47,6 +48,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
4748
modelSelection: Schema.optional(ModelSelection),
4849
runtimeMode: Schema.optional(RuntimeMode),
4950
interactionMode: Schema.optional(ProviderInteractionMode),
51+
composerDraftRevision: Schema.optional(NonNegativeInt),
5052
// Present when the queued item creates a brand-new thread (pending task)
5153
// instead of appending a turn to an existing one.
5254
creation: Schema.optional(QueuedThreadCreationSchema),
@@ -76,6 +78,7 @@ export interface QueuedThreadMessage {
7678
readonly modelSelection?: ModelSelectionType;
7779
readonly runtimeMode?: RuntimeModeType;
7880
readonly interactionMode?: ProviderInteractionModeType;
81+
readonly composerDraftRevision?: number;
7982
readonly creation?: QueuedThreadCreation;
8083
readonly createdAt: string;
8184
}

apps/mobile/src/state/thread-outbox.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,20 @@ describe("thread outbox", () => {
5858
});
5959
});
6060

61-
it("decodes the persisted schema and rejects incomplete messages", () => {
61+
it("decodes persisted v1-v3 messages and rejects incomplete messages", () => {
6262
const message = queuedMessage({
6363
messageId: "message-1",
6464
createdAt: "2026-06-08T10:00:01.000Z",
6565
});
6666

67-
expect(
68-
decodeQueuedThreadMessage({
69-
schemaVersion: 1,
70-
...message,
71-
}),
72-
).toEqual(message);
67+
for (const schemaVersion of [1, 2, 3] as const) {
68+
expect(
69+
decodeQueuedThreadMessage({
70+
schemaVersion,
71+
...message,
72+
}),
73+
).toEqual(message);
74+
}
7375
expect(() =>
7476
decodeQueuedThreadMessage({
7577
schemaVersion: 1,
@@ -92,6 +94,7 @@ describe("thread outbox", () => {
9294
},
9395
runtimeMode: "approval-required",
9496
interactionMode: "plan",
97+
composerDraftRevision: 7,
9598
} satisfies QueuedThreadMessage;
9699

97100
expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual(

apps/mobile/src/state/use-composer-drafts.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
33

44
import { appAtomRegistry } from "./atom-registry";
55
import {
6+
applySyncedComposerDraftCommon,
67
clearComposerDraftContentState,
78
composerDraftsAtom,
89
decodePersistedComposerDrafts,
@@ -23,6 +24,42 @@ afterEach(() => {
2324
});
2425

2526
describe("mobile composer drafts", () => {
27+
it("applies synchronized common state without replacing local attachments or workspace", () => {
28+
const draftKey = "environment-1:thread-1";
29+
const attachment = {
30+
id: "image-1",
31+
previewUri: "file:///image.png",
32+
type: "image" as const,
33+
name: "image.png",
34+
mimeType: "image/png",
35+
sizeBytes: 4,
36+
dataUrl: "data:image/png;base64,AAAA",
37+
};
38+
const workspaceSelection = {
39+
mode: "worktree" as const,
40+
branch: "main",
41+
worktreePath: "/repo-worktree",
42+
};
43+
appAtomRegistry.set(composerDraftsAtom, {
44+
[draftKey]: { text: "local", attachments: [attachment], workspaceSelection },
45+
});
46+
47+
applySyncedComposerDraftCommon(draftKey, {
48+
text: "remote",
49+
modelSelection: null,
50+
runtimeMode: "full-access",
51+
interactionMode: "plan",
52+
});
53+
54+
expect(getComposerDraftSnapshot(draftKey)).toEqual({
55+
text: "remote",
56+
attachments: [attachment],
57+
workspaceSelection,
58+
runtimeMode: "full-access",
59+
interactionMode: "plan",
60+
});
61+
});
62+
2663
it("hydrates selector state even when the message content is empty", () => {
2764
expect(
2865
decodePersistedComposerDrafts({

apps/mobile/src/state/use-composer-drafts.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useAtomValue } from "@effect/atom-react";
22
import {
3+
type ComposerDraftCommon,
34
ModelSelection as ModelSelectionSchema,
45
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
56
ProviderInteractionMode as ProviderInteractionModeSchema,
@@ -100,6 +101,10 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p
100101
Atom.keepAlive,
101102
Atom.withLabel("mobile:composer-drafts"),
102103
);
104+
export const composerDraftsLoadedAtom = Atom.make(false).pipe(
105+
Atom.keepAlive,
106+
Atom.withLabel("mobile:composer-drafts-loaded"),
107+
);
103108

104109
let loadPromise: Promise<void> | null = null;
105110
let persistTimer: ReturnType<typeof setTimeout> | null = null;
@@ -247,6 +252,9 @@ export function ensureComposerDraftsLoaded(): void {
247252
}),
248253
);
249254
// Draft loading is best-effort; in-memory drafts still keep working.
255+
})
256+
.finally(() => {
257+
appAtomRegistry.set(composerDraftsLoadedAtom, true);
250258
});
251259
}
252260

@@ -369,6 +377,41 @@ export function updateComposerDraftSettings(
369377
});
370378
}
371379

380+
/** Applies the server-owned draft section without touching device-local assets. */
381+
export function applySyncedComposerDraftCommon(
382+
draftKey: string,
383+
common: ComposerDraftCommon | null,
384+
): void {
385+
updateComposerDrafts((current) => {
386+
const existing = normalizeDraft(current[draftKey]);
387+
const {
388+
modelSelection: _modelSelection,
389+
runtimeMode: _runtimeMode,
390+
interactionMode: _interactionMode,
391+
...deviceLocal
392+
} = existing;
393+
const draft: ComposerDraft = {
394+
...deviceLocal,
395+
text: common?.text ?? "",
396+
...(common?.modelSelection === null || common?.modelSelection === undefined
397+
? {}
398+
: { modelSelection: common.modelSelection }),
399+
...(common?.runtimeMode === null || common?.runtimeMode === undefined
400+
? {}
401+
: { runtimeMode: common.runtimeMode }),
402+
...(common?.interactionMode === null || common?.interactionMode === undefined
403+
? {}
404+
: { interactionMode: common.interactionMode }),
405+
};
406+
if (isEmptyDraft(draft)) {
407+
const next = { ...current };
408+
delete next[draftKey];
409+
return next;
410+
}
411+
return { ...current, [draftKey]: draft };
412+
});
413+
}
414+
372415
export function clearComposerDraftContentState(
373416
current: Record<string, ComposerDraft>,
374417
draftKey: string,

0 commit comments

Comments
 (0)