Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ vi.mock("../../state/queries", () => ({
}));
vi.mock("../../state/use-composer-drafts", () => ({
getComposerDraftSnapshot: vi.fn(),
readComposerDraftSelection: vi.fn(),
setComposerDraftContext: vi.fn(),
useComposerDraftSelection: vi.fn(),
}));
vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));
vi.mock("../../state/server", () => ({
Expand All @@ -23,8 +23,44 @@ vi.mock("../../state/server", () => ({
import {
buildComposerSlashCommandItems,
resolveComposerCommandSelection,
resolveComposerMenuSelection,
} from "./use-composer-command-menu";

describe("resolveComposerMenuSelection", () => {
it("prefers the caret an insertion placed in the current draft text", () => {
expect(
resolveComposerMenuSelection({
draftMessage: "hello @chip ",
ownerKey: "draft-a",
inserted: { start: 12, end: 12 },
local: { ownerKey: "draft-a", start: 2, end: 2 },
}),
).toEqual({ start: 12, end: 12 });
});

it("clamps the composer's own caret when the draft shrinks", () => {
expect(
resolveComposerMenuSelection({
draftMessage: "hi",
ownerKey: "draft-a",
inserted: null,
local: { ownerKey: "draft-a", start: 5, end: 9 },
}),
).toEqual({ start: 2, end: 2 });
});

it("starts at the end of the draft after switching owners", () => {
expect(
resolveComposerMenuSelection({
draftMessage: "other draft",
ownerKey: "draft-b",
inserted: null,
local: { ownerKey: "draft-a", start: 1, end: 1 },
}),
).toEqual({ start: 11, end: 11 });
});
});

describe("mobile slash commands", () => {
const antigravity = {
driver: ProviderDriverKind.make("antigravity"),
Expand Down
64 changes: 36 additions & 28 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import { pullRequestComposerContext, threadComposerContext } from "../../lib/com
import { uuidv4 } from "../../lib/uuid";
import {
getComposerDraftSnapshot,
readComposerDraftSelection,
setComposerDraftContext,
useComposerDraftSelection,
} from "../../state/use-composer-drafts";
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
import {
Expand All @@ -43,7 +43,7 @@ import {
} from "@t3tools/client-runtime/providerSkills";
import { useAtomRefresh } from "@effect/atom-react";
import { Atom } from "effect/reactivity";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";

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

/**
* A caret placed by an insertion (chips, pasted context) wins while the draft
* still has the text it was placed in. Otherwise the composer's own caret is
* clamped to the draft, and a caret from another owner starts at the end.
*/
export function resolveComposerMenuSelection(input: {
readonly draftMessage: string;
readonly ownerKey: string | null;
readonly inserted: ComposerEditorSelection | null;
readonly local: {
readonly ownerKey: string | null;
readonly start: number;
readonly end: number;
};
}): ComposerEditorSelection {
if (input.inserted) return input.inserted;
if (input.local.ownerKey !== input.ownerKey) return composerSelectionAtEnd(input.draftMessage);
const length = input.draftMessage.length;
return { start: Math.min(input.local.start, length), end: Math.min(input.local.end, length) };
}

export function buildComposerSlashCommandItems(input: {
readonly query: string;
readonly atMessageStart: boolean;
Expand Down Expand Up @@ -212,34 +233,21 @@ export function useComposerCommandMenu({
readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
readonly onUsageLimits?: () => void;
}) {
const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));
const [localSelection, setLocalSelection] = useState(() => ({
ownerKey,
...composerSelectionAtEnd(draftMessage),
}));
// Thread mentions rank by recency as of when the composer mounted.
const [now] = useState(() => Date.now());
const previousOwnerKeyRef = useRef(ownerKey);
useEffect(() => {
const inserted = ownerKey ? readComposerDraftSelection(ownerKey, draftMessage) : null;
if (inserted) {
// oxlint-disable-next-line react/set-state-in-effect -- Applies a selection inserted into the draft store by another surface.
setSelection((current) =>
current.start === inserted.start && current.end === inserted.end ? current : inserted,
);
return;
}
const end = draftMessage.length;
setSelection((current) => {
const start = Math.min(current.start, end);
const selectionEnd = Math.min(current.end, end);
if (start === current.start && selectionEnd === current.end) {
return current;
}
return { start, end: selectionEnd };
});
}, [draftMessage, ownerKey]);
useEffect(() => {
if (previousOwnerKeyRef.current === ownerKey) return;
previousOwnerKeyRef.current = ownerKey;
setSelection(composerSelectionAtEnd(draftMessage));
}, [draftMessage, ownerKey]);
const insertedSelection = useComposerDraftSelection(ownerKey, draftMessage);
const selection = resolveComposerMenuSelection({
draftMessage,
ownerKey,
inserted: insertedSelection,
local: localSelection,
});
const setSelection = (next: ComposerEditorSelection) =>
setLocalSelection({ ownerKey, start: next.start, end: next.end });

const skills = selectedProviderStatus
? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd)
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,37 @@ describe("mobile composer drafts", () => {
expect(getComposerDraftSnapshot(draftKey).context).toBeUndefined();
});

it("keeps each draft's caret when another composer moves its own caret", () => {
const threadKey = "caret-environment:caret-thread";
const newTaskKey = "caret-environment:new-task:caret-draft";
setComposerDraftText(threadKey, "Fix this next");
setComposerDraftText(newTaskKey, "Another task");
rememberComposerDraftSelection(threadKey, "Fix this next", { start: 4, end: 8 });
rememberComposerDraftSelection(newTaskKey, "Another task", { start: 2, end: 2 });
insertComposerDraftContext(threadKey, {
text: "[Build output](t3-context://v1/terminal/caret-terminal)",
context: {
version: 1,
records: [
{
version: 1,
kind: "terminal",
contextId: ComposerContextId.make("caret-terminal"),
label: "Build output",
terminalId: "main",
terminalLabel: "Terminal",
lineStart: 1,
lineEnd: 1,
text: "Build failed",
},
],
},
});
expect(getComposerDraftSnapshot(threadKey).text).toBe(
"Fix [Build output](t3-context://v1/terminal/caret-terminal) next",
);
});

it("releases videos rejected by the live draft limit and keeps accepted files", async () => {
const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
onTestFinished(() => outboxLoad.mockRestore());
Expand Down
56 changes: 42 additions & 14 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,26 +88,57 @@ export function setComposerContextImporting(draftKey: string, importing: boolean
appAtomRegistry.set(composerContextImportsAtom, next);
}

let lastComposerSelection: { draftKey: string; text: string; start: number; end: number } | null =
null;
interface ComposerDraftSelection {
readonly text: string;
readonly start: number;
readonly end: number;
}

// Keyed by draft so a caret move in one composer neither replaces another draft's caret
// nor notifies its readers. Kept alive so the caret survives the composer unmounting.
const composerDraftSelectionAtom = Atom.family((_draftKey: string) =>
Atom.make<ComposerDraftSelection | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:composer-draft-selection"),
),
);
const NO_COMPOSER_DRAFT_SELECTION_ATOM = Atom.make<ComposerDraftSelection | null>(null);

export function rememberComposerDraftSelection(
draftKey: string,
text: string,
selection: { start: number; end: number },
): void {
lastComposerSelection = { draftKey, text, ...selection };
appAtomRegistry.set(composerDraftSelectionAtom(draftKey), { text, ...selection });
}

function matchingComposerDraftSelection(
selection: ComposerDraftSelection | null,
text: string,
): { start: number; end: number } | null {
if (selection?.text !== text) return null;
return { start: selection.start, end: selection.end };
}

/** Read the post-chip-insertion selection or the editor restores the pre-insert caret before the chip. */
export function readComposerDraftSelection(
function readComposerDraftSelection(
draftKey: string,
text: string,
): { start: number; end: number } | null {
if (lastComposerSelection?.draftKey !== draftKey || lastComposerSelection.text !== text) {
return null;
}
return { start: lastComposerSelection.start, end: lastComposerSelection.end };
return matchingComposerDraftSelection(
appAtomRegistry.get(composerDraftSelectionAtom(draftKey)),
text,
);
}

export function useComposerDraftSelection(
draftKey: string | null,
text: string,
): { start: number; end: number } | null {
const selection = useAtomValue(
draftKey === null ? NO_COMPOSER_DRAFT_SELECTION_ATOM : composerDraftSelectionAtom(draftKey),
);
return draftKey === null ? null : matchingComposerDraftSelection(selection, text);
}

export interface ComposerDraftInsertion {
Expand All @@ -133,8 +164,7 @@ function contextInsertionRange(
draft: ComposerDraft,
target?: ComposerDraftInsertion,
) {
const captured =
target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null);
const captured = target ?? appAtomRegistry.get(composerDraftSelectionAtom(draftKey));
// Append after text edits during writes; caret-only moves preserve the captured insertion range.
const selection = captured?.text === draft.text ? captured : null;
const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length));
Expand Down Expand Up @@ -307,12 +337,10 @@ function draftWithInsertedContext(
for (const record of content.context.records) records.set(record.contextId, record);
const context = referencedComposerContext(text, { version: 1, records: [...records.values()] });
if ((context?.records.length ?? 0) > COMPOSER_CONTEXT_MAX_RECORDS) return null;
lastComposerSelection = {
draftKey,
text,
rememberComposerDraftSelection(draftKey, text, {
start: start + insertion.length,
end: start + insertion.length,
};
});
return withReferencedContextFiles(draft, text, context);
}

Expand Down
Loading