Skip to content

Commit 78e93fd

Browse files
fix(mobile): a caret move in one composer no longer moves another draft's insert point (#25681)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 576a40f commit 78e93fd

4 files changed

Lines changed: 146 additions & 43 deletions

File tree

‎apps/mobile/src/features/threads/use-composer-command-menu.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ vi.mock("../../state/queries", () => ({
99
}));
1010
vi.mock("../../state/use-composer-drafts", () => ({
1111
getComposerDraftSnapshot: vi.fn(),
12-
readComposerDraftSelection: vi.fn(),
1312
setComposerDraftContext: vi.fn(),
13+
useComposerDraftSelection: vi.fn(),
1414
}));
1515
vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));
1616
vi.mock("../../state/server", () => ({
@@ -23,8 +23,44 @@ vi.mock("../../state/server", () => ({
2323
import {
2424
buildComposerSlashCommandItems,
2525
resolveComposerCommandSelection,
26+
resolveComposerMenuSelection,
2627
} from "./use-composer-command-menu";
2728

29+
describe("resolveComposerMenuSelection", () => {
30+
it("prefers the caret an insertion placed in the current draft text", () => {
31+
expect(
32+
resolveComposerMenuSelection({
33+
draftMessage: "hello @chip ",
34+
ownerKey: "draft-a",
35+
inserted: { start: 12, end: 12 },
36+
local: { ownerKey: "draft-a", start: 2, end: 2 },
37+
}),
38+
).toEqual({ start: 12, end: 12 });
39+
});
40+
41+
it("clamps the composer's own caret when the draft shrinks", () => {
42+
expect(
43+
resolveComposerMenuSelection({
44+
draftMessage: "hi",
45+
ownerKey: "draft-a",
46+
inserted: null,
47+
local: { ownerKey: "draft-a", start: 5, end: 9 },
48+
}),
49+
).toEqual({ start: 2, end: 2 });
50+
});
51+
52+
it("starts at the end of the draft after switching owners", () => {
53+
expect(
54+
resolveComposerMenuSelection({
55+
draftMessage: "other draft",
56+
ownerKey: "draft-b",
57+
inserted: null,
58+
local: { ownerKey: "draft-a", start: 1, end: 1 },
59+
}),
60+
).toEqual({ start: 11, end: 11 });
61+
});
62+
});
63+
2864
describe("mobile slash commands", () => {
2965
const antigravity = {
3066
driver: ProviderDriverKind.make("antigravity"),

‎apps/mobile/src/features/threads/use-composer-command-menu.ts‎

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import { pullRequestComposerContext, threadComposerContext } from "../../lib/com
1717
import { uuidv4 } from "../../lib/uuid";
1818
import {
1919
getComposerDraftSnapshot,
20-
readComposerDraftSelection,
2120
setComposerDraftContext,
21+
useComposerDraftSelection,
2222
} from "../../state/use-composer-drafts";
2323
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
2424
import {
@@ -43,7 +43,7 @@ import {
4343
} from "@t3tools/client-runtime/providerSkills";
4444
import { useAtomRefresh } from "@effect/atom-react";
4545
import { Atom } from "effect/reactivity";
46-
import { useEffect, useRef, useState } from "react";
46+
import { useEffect, useState } from "react";
4747

4848
import type { ComposerEditorSelection } from "../../components/ComposerEditor";
4949
import { serverEnvironment } from "../../state/server";
@@ -68,6 +68,27 @@ function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection {
6868
return { start: draftMessage.length, end: draftMessage.length };
6969
}
7070

71+
/**
72+
* A caret placed by an insertion (chips, pasted context) wins while the draft
73+
* still has the text it was placed in. Otherwise the composer's own caret is
74+
* clamped to the draft, and a caret from another owner starts at the end.
75+
*/
76+
export function resolveComposerMenuSelection(input: {
77+
readonly draftMessage: string;
78+
readonly ownerKey: string | null;
79+
readonly inserted: ComposerEditorSelection | null;
80+
readonly local: {
81+
readonly ownerKey: string | null;
82+
readonly start: number;
83+
readonly end: number;
84+
};
85+
}): ComposerEditorSelection {
86+
if (input.inserted) return input.inserted;
87+
if (input.local.ownerKey !== input.ownerKey) return composerSelectionAtEnd(input.draftMessage);
88+
const length = input.draftMessage.length;
89+
return { start: Math.min(input.local.start, length), end: Math.min(input.local.end, length) };
90+
}
91+
7192
export function buildComposerSlashCommandItems(input: {
7293
readonly query: string;
7394
readonly atMessageStart: boolean;
@@ -212,34 +233,21 @@ export function useComposerCommandMenu({
212233
readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
213234
readonly onUsageLimits?: () => void;
214235
}) {
215-
const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));
236+
const [localSelection, setLocalSelection] = useState(() => ({
237+
ownerKey,
238+
...composerSelectionAtEnd(draftMessage),
239+
}));
216240
// Thread mentions rank by recency as of when the composer mounted.
217241
const [now] = useState(() => Date.now());
218-
const previousOwnerKeyRef = useRef(ownerKey);
219-
useEffect(() => {
220-
const inserted = ownerKey ? readComposerDraftSelection(ownerKey, draftMessage) : null;
221-
if (inserted) {
222-
// oxlint-disable-next-line react/set-state-in-effect -- Applies a selection inserted into the draft store by another surface.
223-
setSelection((current) =>
224-
current.start === inserted.start && current.end === inserted.end ? current : inserted,
225-
);
226-
return;
227-
}
228-
const end = draftMessage.length;
229-
setSelection((current) => {
230-
const start = Math.min(current.start, end);
231-
const selectionEnd = Math.min(current.end, end);
232-
if (start === current.start && selectionEnd === current.end) {
233-
return current;
234-
}
235-
return { start, end: selectionEnd };
236-
});
237-
}, [draftMessage, ownerKey]);
238-
useEffect(() => {
239-
if (previousOwnerKeyRef.current === ownerKey) return;
240-
previousOwnerKeyRef.current = ownerKey;
241-
setSelection(composerSelectionAtEnd(draftMessage));
242-
}, [draftMessage, ownerKey]);
242+
const insertedSelection = useComposerDraftSelection(ownerKey, draftMessage);
243+
const selection = resolveComposerMenuSelection({
244+
draftMessage,
245+
ownerKey,
246+
inserted: insertedSelection,
247+
local: localSelection,
248+
});
249+
const setSelection = (next: ComposerEditorSelection) =>
250+
setLocalSelection({ ownerKey, start: next.start, end: next.end });
243251

244252
const skills = selectedProviderStatus
245253
? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd)

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,37 @@ describe("mobile composer drafts", () => {
981981
expect(getComposerDraftSnapshot(draftKey).context).toBeUndefined();
982982
});
983983

984+
it("keeps each draft's caret when another composer moves its own caret", () => {
985+
const threadKey = "caret-environment:caret-thread";
986+
const newTaskKey = "caret-environment:new-task:caret-draft";
987+
setComposerDraftText(threadKey, "Fix this next");
988+
setComposerDraftText(newTaskKey, "Another task");
989+
rememberComposerDraftSelection(threadKey, "Fix this next", { start: 4, end: 8 });
990+
rememberComposerDraftSelection(newTaskKey, "Another task", { start: 2, end: 2 });
991+
insertComposerDraftContext(threadKey, {
992+
text: "[Build output](t3-context://v1/terminal/caret-terminal)",
993+
context: {
994+
version: 1,
995+
records: [
996+
{
997+
version: 1,
998+
kind: "terminal",
999+
contextId: ComposerContextId.make("caret-terminal"),
1000+
label: "Build output",
1001+
terminalId: "main",
1002+
terminalLabel: "Terminal",
1003+
lineStart: 1,
1004+
lineEnd: 1,
1005+
text: "Build failed",
1006+
},
1007+
],
1008+
},
1009+
});
1010+
expect(getComposerDraftSnapshot(threadKey).text).toBe(
1011+
"Fix [Build output](t3-context://v1/terminal/caret-terminal) next",
1012+
);
1013+
});
1014+
9841015
it("releases videos rejected by the live draft limit and keeps accepted files", async () => {
9851016
const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
9861017
onTestFinished(() => outboxLoad.mockRestore());

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

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,26 +88,57 @@ export function setComposerContextImporting(draftKey: string, importing: boolean
8888
appAtomRegistry.set(composerContextImportsAtom, next);
8989
}
9090

91-
let lastComposerSelection: { draftKey: string; text: string; start: number; end: number } | null =
92-
null;
91+
interface ComposerDraftSelection {
92+
readonly text: string;
93+
readonly start: number;
94+
readonly end: number;
95+
}
96+
97+
// Keyed by draft so a caret move in one composer neither replaces another draft's caret
98+
// nor notifies its readers. Kept alive so the caret survives the composer unmounting.
99+
const composerDraftSelectionAtom = Atom.family((_draftKey: string) =>
100+
Atom.make<ComposerDraftSelection | null>(null).pipe(
101+
Atom.keepAlive,
102+
Atom.withLabel("mobile:composer-draft-selection"),
103+
),
104+
);
105+
const NO_COMPOSER_DRAFT_SELECTION_ATOM = Atom.make<ComposerDraftSelection | null>(null);
93106

94107
export function rememberComposerDraftSelection(
95108
draftKey: string,
96109
text: string,
97110
selection: { start: number; end: number },
98111
): void {
99-
lastComposerSelection = { draftKey, text, ...selection };
112+
appAtomRegistry.set(composerDraftSelectionAtom(draftKey), { text, ...selection });
113+
}
114+
115+
function matchingComposerDraftSelection(
116+
selection: ComposerDraftSelection | null,
117+
text: string,
118+
): { start: number; end: number } | null {
119+
if (selection?.text !== text) return null;
120+
return { start: selection.start, end: selection.end };
100121
}
101122

102123
/** Read the post-chip-insertion selection or the editor restores the pre-insert caret before the chip. */
103-
export function readComposerDraftSelection(
124+
function readComposerDraftSelection(
104125
draftKey: string,
105126
text: string,
106127
): { start: number; end: number } | null {
107-
if (lastComposerSelection?.draftKey !== draftKey || lastComposerSelection.text !== text) {
108-
return null;
109-
}
110-
return { start: lastComposerSelection.start, end: lastComposerSelection.end };
128+
return matchingComposerDraftSelection(
129+
appAtomRegistry.get(composerDraftSelectionAtom(draftKey)),
130+
text,
131+
);
132+
}
133+
134+
export function useComposerDraftSelection(
135+
draftKey: string | null,
136+
text: string,
137+
): { start: number; end: number } | null {
138+
const selection = useAtomValue(
139+
draftKey === null ? NO_COMPOSER_DRAFT_SELECTION_ATOM : composerDraftSelectionAtom(draftKey),
140+
);
141+
return draftKey === null ? null : matchingComposerDraftSelection(selection, text);
111142
}
112143

113144
export interface ComposerDraftInsertion {
@@ -133,8 +164,7 @@ function contextInsertionRange(
133164
draft: ComposerDraft,
134165
target?: ComposerDraftInsertion,
135166
) {
136-
const captured =
137-
target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null);
167+
const captured = target ?? appAtomRegistry.get(composerDraftSelectionAtom(draftKey));
138168
// Append after text edits during writes; caret-only moves preserve the captured insertion range.
139169
const selection = captured?.text === draft.text ? captured : null;
140170
const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length));
@@ -307,12 +337,10 @@ function draftWithInsertedContext(
307337
for (const record of content.context.records) records.set(record.contextId, record);
308338
const context = referencedComposerContext(text, { version: 1, records: [...records.values()] });
309339
if ((context?.records.length ?? 0) > COMPOSER_CONTEXT_MAX_RECORDS) return null;
310-
lastComposerSelection = {
311-
draftKey,
312-
text,
340+
rememberComposerDraftSelection(draftKey, text, {
313341
start: start + insertion.length,
314342
end: start + insertion.length,
315-
};
343+
});
316344
return withReferencedContextFiles(draft, text, context);
317345
}
318346

0 commit comments

Comments
 (0)