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
172 changes: 170 additions & 2 deletions apps/mobile/src/features/threads/use-composer-command-menu.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { act, createElement, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import { useComposerPathSearch, useThreadSearch } from "../../state/queries";
import { useComposerDraftSelection } from "../../state/use-composer-drafts";
import { makeThreadShellFixture } from "../../test-fixtures";

import { describe, expect, it, vi } from "vite-plus/test";
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
} from "@t3tools/contracts";

vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));

vi.mock("../../state/queries", () => ({
useComposerPathSearch: () => ({ entries: [], isPending: false }),
useThreadSearch: vi.fn(() => ({ matches: [], query: "", isPending: false })),
useComposerPathSearch: vi.fn(() => ({ entries: [], isPending: false, truncated: false })),
useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }),
}));
vi.mock("../../state/use-composer-drafts", () => ({
Expand All @@ -26,6 +38,7 @@ import {
buildComposerSlashCommandItems,
resolveComposerCommandSelection,
resolveComposerMenuSelection,
useComposerCommandMenu,
} from "./use-composer-command-menu";

describe("resolveComposerMenuSelection", () => {
Expand Down Expand Up @@ -142,6 +155,16 @@ describe("mobile model mentions", () => {
};
};

it("continues a spaced model label without a matching filesystem path", async () => {
await using state = await mountMenu("@GPT", { start: 4, end: 4 }, undefined, {
modelOptions: [option("traex:gpt-6.1", { label: "GPT Six" })],
modelProviders: [provider("traex")],
});
await state.renderDraft("@GPT ", { start: 5, end: 5 });
expect(state.menu.trigger?.query).toBe("GPT ");
expect(state.menu.items.some((item) => item.type === "model")).toBe(true);
});

it("offers current, visible models labelled with their provider", () => {
const items = buildComposerModelItems(
[
Expand Down Expand Up @@ -174,3 +197,148 @@ describe("mobile model mentions", () => {
expect(buildComposerModelItems([option("traex:gpt-6.1")], "gpt", providers)).toEqual([]);
});
});

async function mountMenu(
draftMessage: string,
selection: { start: number; end: number },
threadShells?: Parameters<typeof useComposerCommandMenu>[0]["threadShells"],
options: Partial<Parameters<typeof useComposerCommandMenu>[0]> = {},
) {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const root = createRoot(document.createElement("div"));
let menu: ReturnType<typeof useComposerCommandMenu> | undefined;
function Probe({ text, ownerKey }: { text: string; ownerKey: string }) {
const value = useComposerCommandMenu({
draftMessage: text,
ownerKey,
environmentId: null,
projectCwd: null,
selectedProviderStatus: null,
hasThread: false,
hasCompactableConversation: false,
onChangeDraftMessage: () => {},
...(threadShells ? { threadShells } : {}),
...options,
});
useLayoutEffect(() => {
menu = value;
});
return null;
}
const dispose = async () => {
await act(() => root.unmount());
vi.unstubAllGlobals();
vi.mocked(useComposerDraftSelection).mockReset();
vi.mocked(useComposerPathSearch).mockReset();
vi.mocked(useThreadSearch).mockReset();
};
const renderDraft = async (
text: string,
caret: { start: number; end: number },
ownerKey = "draft-a",
) => {
vi.mocked(useComposerDraftSelection).mockReturnValue(caret);
await act(() => root.render(createElement(Probe, { text, ownerKey })));
if (!menu) throw new Error("Composer menu did not mount");
return menu;
};
try {
await renderDraft(draftMessage, selection);
return {
renderDraft,
get menu() {
return menu!;
},
[Symbol.asyncDispose]: dispose,
};
} catch (error) {
await dispose();
throw error;
}
}

it("keeps the picker closed when mounting with a selected mention", async () => {
await using state = await mountMenu("@foo", { start: 0, end: 4 });
expect(state.menu.trigger).toBeNull();
expect(state.menu.items).toEqual([]);
});

it("uses the restored caret when switching draft owners", async () => {
await using state = await mountMenu("@first", { start: 6, end: 6 });
const menu = await state.renderDraft("@foo suffix @last", { start: 4, end: 4 }, "draft-b");
expect(menu.trigger).toMatchObject({ query: "foo", rangeStart: 0, rangeEnd: 4 });
});

it("keeps a spaced search open while a thread title or capped results may match it", async () => {
const typeQuery = async (state: Awaited<ReturnType<typeof mountMenu>>, query: string) => {
for (let end = 2; end <= query.length; end += 1) {
await state.renderDraft(query.slice(0, end), { start: end, end });
}
return state.menu.trigger?.query;
};
await using threads = await mountMenu(
"@",
{ start: 1, end: 1 },
[makeThreadShellFixture({ title: "Quarterly Planning" })],
{ environmentId: EnvironmentId.make("environment-test") },
);
expect(await typeQuery(threads, "@Quarterly Pl")).toBe("Quarterly Pl");

vi.mocked(useComposerPathSearch).mockReturnValue({
entries: [{ path: "Foreign Alpha 000.md" }],
isPending: false,
truncated: true,
} as unknown as ReturnType<typeof useComposerPathSearch>);
await using capped = await mountMenu("@", { start: 1, end: 1 });
expect(await typeQuery(capped, "@Foreign Ze")).toBe("Foreign Ze");
});

it("rejects pending fuzzy selections and revalidates when the same query settles", async () => {
const onChangeDraftMessage = vi.fn();
const entry = { path: "ChatComposer.tsx", kind: "file" as const };
vi.mocked(useComposerPathSearch).mockReturnValue({
entries: [entry],
error: null,
refresh: vi.fn(),
isPending: true,
truncated: false,
});
await using state = await mountMenu("Fix @ChatComposer", { start: 17, end: 17 }, undefined, {
onChangeDraftMessage,
});
const text = "Fix @ChatComposer so";
await state.renderDraft(text, { start: text.length, end: text.length });
const item = state.menu.items.find((item) => item.type === "path")!;
await act(() => state.menu.onSelect(item));
expect.soft(onChangeDraftMessage).not.toHaveBeenCalled();
vi.mocked(useComposerPathSearch).mockReturnValue({
entries: [entry],
error: null,
refresh: vi.fn(),
isPending: false,
truncated: false,
});
await state.renderDraft(text, { start: text.length, end: text.length });
expect(state.menu.trigger).toBeNull();
expect(state.menu.items).toEqual([]);
});

it("continues a spaced machine name when filesystem results do not contain it", async () => {
const environmentId = EnvironmentId.make("local");
await using state = await mountMenu("@Build", { start: 6, end: 6 }, undefined, {
environmentId,
environments: [
{ environmentId: EnvironmentId.make("remote"), label: "Build Server", machine: "server" },
],
});
await state.renderDraft("@Build ", { start: 7, end: 7 });
expect(state.menu.trigger?.query).toBe("Build ");
expect(state.menu.items.some((item) => item.type === "environment")).toBe(true);
});

it("keeps continuation while only the remote thread search is pending", async () => {
vi.mocked(useThreadSearch).mockReturnValue({ matches: [], query: "Design", isPending: true });
await using state = await mountMenu("@Design", { start: 7, end: 7 });
await state.renderDraft("@Design Review", { start: 14, end: 14 });
expect(state.menu.trigger?.query).toBe("Design Review");
});
120 changes: 106 additions & 14 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
} from "../../state/use-composer-drafts";
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
import {
continueComposerPathTrigger,
isSpacedComposerQueryUnmatched,
detectComposerTrigger,
replaceTextRange,
serializeComposerFileLink,
Expand Down Expand Up @@ -328,23 +330,94 @@ export function useComposerCommandMenu({
setSelection(nextSelection);
};

const trigger = (() => {
if (!enabled || selection.start !== selection.end) {
return null;
}
return detectComposerTrigger(draftMessage, selection.end);
})();
const [previousSearch, setPreviousSearch] = useState(() => ({
ownerKey,
draftMessage,
selection,
localSelection,
insertedSelection,
cursor: selection.end,
queryEnd: selection.end,
enabled,
trigger:
enabled && selection.start === selection.end
? detectComposerTrigger(draftMessage, selection.end)
: null,
}));
// Searches for the stored trigger: a change below re-renders before commit, so the committed
// render searches the current query while the update can check the previous results.
const pathSearch = useComposerPathSearch({
environmentId,
cwd: trigger?.kind === "path" ? projectCwd : null,
query: trigger?.kind === "path" ? trigger.query : null,
cwd: previousSearch.trigger?.kind === "path" ? projectCwd : null,
query: previousSearch.trigger?.kind === "path" ? previousSearch.trigger.query : null,
});
// Mounts older settled threads matching the mention, which shells may leave out.
useThreadSearch(
trigger?.kind === "path" && environmentId ? [environmentId] : [],
trigger?.kind === "path" ? trigger.query : "",
const threadSearch = useThreadSearch(
previousSearch.trigger?.kind === "path" && environmentId ? [environmentId] : [],
previousSearch.trigger?.kind === "path" ? previousSearch.trigger.query : "",
{ titlesOnly: true },
);
let trigger = previousSearch.trigger;
if (
previousSearch.ownerKey !== ownerKey ||
previousSearch.draftMessage !== draftMessage ||
previousSearch.localSelection !== localSelection ||
previousSearch.insertedSelection?.start !== insertedSelection?.start ||
previousSearch.insertedSelection?.end !== insertedSelection?.end ||
previousSearch.enabled !== enabled
) {
// Draft updates can lead selection updates by several edits. Accumulate
// those edits from the predicted cursor until selection catches up.
const cursor =
previousSearch.ownerKey !== ownerKey
? selection.end
: previousSearch.localSelection === localSelection &&
previousSearch.insertedSelection?.start === insertedSelection?.start &&
previousSearch.insertedSelection?.end === insertedSelection?.end
? previousSearch.cursor + draftMessage.length - previousSearch.draftMessage.length
: selection.end;
const continued = continueComposerPathTrigger(
draftMessage,
cursor,
previousSearch.ownerKey === ownerKey ? previousSearch.trigger : null,
previousSearch.draftMessage,
previousSearch.queryEnd,
pathSearch.isPending || pathSearch.truncated || threadSearch.isPending
? null
: [
...pathSearch.entries.map((entry) => entry.path),
...threadShells.map((shell) => shell.title),
Comment thread
omarbdri marked this conversation as resolved.
Resolved
...environments
.filter((environment) => environment.environmentId !== environmentId)
.map((environment) => environment.label),
...(modelOptions && modelProviders
? buildComposerModelItems(
modelOptions,
draftMessage.slice((previousSearch.trigger?.rangeStart ?? 0) + 1, cursor),
modelProviders,
).map((item) => item.label)
: []),
],
);
trigger =
enabled && selection.start === selection.end
? (detectComposerTrigger(draftMessage, cursor) ?? continued?.trigger ?? null)
: null;
const queryEnd =
trigger?.kind === "path" && trigger.rangeStart === continued?.trigger.rangeStart
? continued.queryEnd
: (trigger?.rangeEnd ?? 0);
setPreviousSearch({
ownerKey,
draftMessage,
selection,
localSelection,
insertedSelection,
cursor,
queryEnd,
enabled,
trigger,
});
}
const pullRequestSearch = useComposerPullRequestSearch({
environmentId,
projectId: pullRequestProjectId,
Expand Down Expand Up @@ -535,8 +608,25 @@ export function useComposerCommandMenu({
return [];
})();

if (
trigger?.kind === "path" &&
!pathSearch.isPending &&
!pathSearch.truncated &&
!threadSearch.isPending &&
isSpacedComposerQueryUnmatched(
trigger.query,
items.map((item) => (item.type === "path" ? item.path : item.label)),
)
)
trigger = null;

const onSelect = (item: ComposerCommandItem) => {
if (!trigger) return;
if (
trigger.kind === "path" &&
isSpacedComposerQueryUnmatched(trigger.query, [item.type === "path" ? item.path : item.label])
)
return;
if (item.type === "environment") {
if (!ownerKey || trigger.kind !== "path") return;
const record = environmentComposerContext(item.environmentId, item.label);
Expand Down Expand Up @@ -654,10 +744,12 @@ export function useComposerCommandMenu({
selection,
onSelectionChange,
trigger,
items,
items: trigger ? items : [],
skills,
isLoading:
trigger?.kind === "pull-request" ? pullRequestSearch.isPending : pathSearch.isPending,
trigger?.kind === "pull-request"
? pullRequestSearch.isPending
: pathSearch.isPending || threadSearch.isPending,
error:
trigger?.kind === "pull-request"
? pullRequestProjectId === null || pullRequestRepository === null
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) {
isPending:
normalizedTarget.query !== debouncedTarget.query ||
(hasTarget && (fileAccess.isPending || result.isPending)),
truncated: result.data?.truncated ?? false,
refresh: result.refresh,
};
}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus";
import "vite-plus/test/config";

const reactTests = [
"src/features/threads/use-composer-command-menu.test.ts",
"src/features/threads/git/GitBranchesSheet.test.tsx",
"src/features/threads/new-task-flow-provider.test.tsx",
"src/features/layout/workspace/workspace-tabs-state.test.ts",
Expand Down
Loading
Loading