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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
ProviderInstanceId,
type ServerProvider,
} from "@t3tools/contracts";
import { act, createElement } from "react";
import { act, createElement, useLayoutEffect } from "react";
import { createRoot, type Root } from "react-dom/client";

const refreshProviders = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -322,3 +322,96 @@ describe("workspace command discovery retry", () => {
expect(vi.getTimerCount()).toBe(0);
});
});

describe("mobile multi-word path search", () => {
let root: Root;
let menu: ReturnType<typeof useComposerCommandMenu>;
function Probe({ draftMessage, ownerKey }: { draftMessage: string; ownerKey: string }) {
const state = useComposerCommandMenu({
draftMessage,
ownerKey,
environmentId: null,
projectCwd: null,
selectedProviderStatus: null,
hasThread: false,
hasCompactableConversation: false,
onChangeDraftMessage() {},
});
useLayoutEffect(() => {
menu = state;
});
return null;
}
async function type(text: string, ownerKey = "draft-1") {
await act(() => root.render(createElement(Probe, { draftMessage: text, ownerKey })));
await act(() => menu.onSelectionChange({ start: text.length, end: text.length }));
}
beforeEach(() => {
const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} };
const container = {
nodeType: 1,
tagName: "DIV",
namespaceURI: "http://www.w3.org/1999/xhtml",
ownerDocument: document,
addEventListener() {},
removeEventListener() {},
};
vi.stubGlobal("document", document);
vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
root = createRoot(container as unknown as HTMLElement);
});
afterEach(async () => {
await act(() => root.unmount());
vi.unstubAllGlobals();
});
it("keeps the full query through spaces and closes after accepting a file", async () => {
const query = "Foreign Subsidiaries Motion Video";
for (let length = 0; length <= query.length; length += 1) {
await type("@" + query.slice(0, length));
expect(menu.trigger).toEqual({
kind: "path",
query: query.slice(0, length),
rangeStart: 0,
rangeEnd: length + 1,
});
}
await type("[Foreign Subsidiaries](Foreign%20Subsidiaries) ");
expect(menu.trigger).toBeNull();
});
it("keeps typed extensions separate from existing prose and closes on a caret jump", async () => {
const suffix = " then summarize";
await type("@Foreign" + suffix);
await act(() => menu.onSelectionChange({ start: 8, end: 8 }));
await act(() =>
root.render(
createElement(Probe, {
draftMessage: "@Foreign Subsidiaries" + suffix,
ownerKey: "draft-1",
}),
),
);
const cursor = "@Foreign Subsidiaries".length;
await act(() => menu.onSelectionChange({ start: cursor, end: cursor }));
expect(menu.trigger?.query).toBe("Foreign Subsidiaries");
expect(menu.trigger?.rangeEnd).toBe(cursor);
const end = cursor + suffix.length;
await act(() => menu.onSelectionChange({ start: end, end }));
expect(menu.trigger).toBeNull();
});

it("does not carry an active search into a different draft", async () => {
await type("@Foreign");
await type("@Foreign Subsidiaries");
expect(menu.trigger?.query).toBe("Foreign Subsidiaries");
await type("@Foreign Subsidiaries", "draft-2");
expect(menu.trigger).toBeNull();
});
it("closes when text is selected and does not resume it after the boundary", async () => {
await type("@Foreign ");
await act(() => menu.onSelectionChange({ start: 0, end: 9 }));
expect(menu.trigger).toBeNull();
await type("@Foreign Subsidiaries");
expect(menu.trigger).toBeNull();
});
});
41 changes: 35 additions & 6 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "../../state/use-composer-drafts";
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
import {
continueComposerPathTrigger,
detectComposerTrigger,
replaceTextRange,
serializeComposerFileLink,
Expand Down Expand Up @@ -342,12 +343,40 @@ export function useComposerCommandMenu({
workspaceRefreshRetry,
]);

const trigger = useMemo(() => {
if (!enabled || selection.start !== selection.end) {
return null;
}
return detectComposerTrigger(draftMessage, selection.end);
}, [draftMessage, enabled, selection]);
const [previousSearch, setPreviousSearch] = useState(() => ({
ownerKey,
draftMessage,
selection,
enabled,
trigger: enabled ? detectComposerTrigger(draftMessage, selection.end) : null,
}));
let trigger = previousSearch.trigger;
if (
previousSearch.ownerKey !== ownerKey ||
previousSearch.draftMessage !== draftMessage ||
previousSearch.selection !== selection ||
previousSearch.enabled !== enabled
) {
// Native text and selection events can arrive separately. Apply the edit's
// length change while waiting for the caret event, then validate that event
// against the updated query range.
const cursor =
selection.end +
(previousSearch.selection === selection && previousSearch.ownerKey === ownerKey
? draftMessage.length - previousSearch.draftMessage.length
: 0);
trigger =
enabled && selection.start === selection.end
? (detectComposerTrigger(draftMessage, cursor) ??
continueComposerPathTrigger(
draftMessage,
cursor,
previousSearch.ownerKey === ownerKey ? previousSearch.trigger : null,
previousSearch.draftMessage,
))
: null;
setPreviousSearch({ ownerKey, draftMessage, selection, enabled, trigger });
}
const pathSearch = useComposerPathSearch({
environmentId,
cwd: trigger?.kind === "path" ? projectCwd : null,
Expand Down
28 changes: 18 additions & 10 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2442,13 +2442,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
const {
trigger: composerTrigger,
detectTrigger: detectActiveComposerTrigger,
setTrigger: setComposerTrigger,
resolveTrigger: resolveComposerTrigger,
dismissTrigger: dismissComposerTrigger,
resetTrigger: resetComposerTrigger,
} = useComposerTriggerState(() =>
isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length),
);
} = useComposerTriggerState(prompt, !isLiteralPendingAnswer);
const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
const composerSuggestionId = useId();
const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
Expand Down Expand Up @@ -3523,7 +3522,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptRef.current = prompt;
const { cursor, trigger } = composerStateAtPromptEnd(prompt);
setComposerCursor(cursor);
resetComposerTrigger(trigger);
resetComposerTrigger(trigger, prompt);
}
lastSyncedPendingInputRef.current = null;
return;
Expand All @@ -3548,7 +3547,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptRef.current = nextCustomAnswer;
const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer, isLiteralPendingAnswer);
setComposerCursor(cursor);
resetComposerTrigger(trigger);
resetComposerTrigger(trigger, nextCustomAnswer);
setComposerHighlightedItemId(null);
}, [
activePendingProgress?.customAnswer,
Expand All @@ -3570,7 +3569,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setProviderInputSubmissionError(null);
const { cursor, trigger } = composerStateAtPromptEnd(promptRef.current, isLiteralPendingAnswer);
setComposerCursor(cursor);
resetComposerTrigger(trigger);
resetComposerTrigger(trigger, promptRef.current);
setIsDragOverComposer(false);
setIsComposerScrollCollapsed(false);
}, [
Expand Down Expand Up @@ -3737,7 +3736,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return;
setComposerCursor(nextCursor);
setComposerTrigger(
cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
cursorAdjacentToMention ? null : detectActiveComposerTrigger(nextPrompt, expandedCursor),
);
onChangeActivePendingUserInputCustomAnswer(
activePendingProgress.activeQuestion.id,
Expand Down Expand Up @@ -3849,7 +3848,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
setComposerCursor(nextCursor);
setComposerTrigger(
cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
cursorAdjacentToMention ? null : detectActiveComposerTrigger(nextPrompt, expandedCursor),
);
},
[
Expand All @@ -3861,6 +3860,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptRef,
setPrompt,
setComposerTrigger,
detectActiveComposerTrigger,
composerDraftTarget,
composerTerminalContexts,
setComposerDraftTerminalContexts,
Expand Down Expand Up @@ -4008,9 +4008,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
snapshot,
trigger: isLiteralPendingAnswer
? null
: resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)),
: resolveComposerTrigger(
detectActiveComposerTrigger(snapshot.value, snapshot.expandedCursor),
),
};
}, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]);
}, [
isLiteralPendingAnswer,
readComposerSnapshot,
resolveComposerTrigger,
detectActiveComposerTrigger,
]);

const { onUsageLimitsCommand } = props;
const onSelectComposerItem = useCallback(
Expand Down Expand Up @@ -6537,6 +6544,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
expandCollapsedComposerCursor(promptForState, cursor),
)
: null,
promptForState,
);
},
addTerminalContext: (selection: TerminalContextSelection) => {
Expand Down
102 changes: 96 additions & 6 deletions apps/web/src/components/chat/useComposerTriggerState.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { act, StrictMode, useLayoutEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";

import { detectComposerTrigger } from "../../composer-logic";
import { useComposerTriggerState } from "./useComposerTriggerState";
Expand All @@ -10,18 +12,16 @@ const initialPrompt = "pnpm install -g @openai";
let root: Root;
let composer: ReturnType<typeof useComposerTriggerState>;

function ComposerProbe() {
const state = useComposerTriggerState(() =>
detectComposerTrigger(initialPrompt, initialPrompt.length),
);
function ComposerProbe({ text = initialPrompt, enabled = true } = {}) {
const state = useComposerTriggerState(text, enabled);
useLayoutEffect(() => {
composer = state;
});
return null;
}

async function updatePrompt(text: string, cursor = text.length) {
await act(() => composer.setTrigger(detectComposerTrigger(text, cursor)));
await act(() => composer.setTrigger(composer.detectTrigger(text, cursor)));
}

beforeEach(async () => {
Expand Down Expand Up @@ -58,6 +58,96 @@ afterEach(async () => {
});

describe("composer suggestion dismissal", () => {
it.each(["@Foreign", "/plan", "$review", "#123"])(
"keeps suggestions closed when initialized with the literal answer %s",
async (text) => {
await act(() =>
root.render(<ComposerProbe key="literal-answer" text={text} enabled={false} />),
);
expect(composer.trigger).toBeNull();
},
);

it("clears a multi-word search when resetting to a literal answer", async () => {
await updatePrompt("@Foreign");
await updatePrompt("@Foreign Subsidiaries");
expect(composer.trigger?.query).toBe("Foreign Subsidiaries");
await act(() => composer.resetTrigger(null, "@Literal answer"));
expect(composer.trigger).toBeNull();
// Returning to a regular composer must not continue the literal answer as an old search.
await updatePrompt("@Literal answer more");
expect(composer.trigger).toBeNull();
});
it("keeps a multi-word @ search active and replaces its whole range", async () => {
const prefix = "Please inspect ";
const query = "Foreign Subsidiaries Motion Video";
for (let length = 0; length <= query.length; length += 1) {
const text = `${prefix}@${query.slice(0, length)}`;
await updatePrompt(text);
expect(composer.trigger).toEqual({
kind: "path",
query: query.slice(0, length),
rangeStart: prefix.length,
rangeEnd: text.length,
});
}
const environmentId = EnvironmentId.make("env-1");
const items = matchComposerThreadItems({
environmentId,
excludeThreadId: null,
query: composer.trigger!.query,
shells: [query, "Foreign Subsidiaries Training"].map((title, index) => ({
environmentId,
id: ThreadId.make("thread-" + index),
title,
archivedAt: null,
updatedAt: "2026-10-03T00:00:00.000Z",
})),
});
expect(items.map((item) => item.label)).toEqual([query]);
});

it("closes when the caret jumps into existing prose and rejects keyboard selection", async () => {
const suffix = " then summarize";
await updatePrompt("@Foreign" + suffix, "@Foreign".length);
await updatePrompt("@Foreign Subsidiaries" + suffix, "@Foreign Subsidiaries".length);
expect(composer.trigger?.query).toBe("Foreign Subsidiaries");
// Keyboard selection re-reads the editor before its next selection render.
const text = "@Foreign Subsidiaries" + suffix;
expect(composer.resolveTrigger(composer.detectTrigger(text, text.length))).toBeNull();
await updatePrompt(text);
expect(composer.trigger).toBeNull();
});

it("preserves the suffix when editing a query before existing prose", async () => {
const suffix = " then summarize";
await updatePrompt("@Foreign" + suffix, "@Foreign".length);
await updatePrompt("@Foreign " + suffix, "@Foreign ".length);
await updatePrompt("@Foreign Subsidiaries" + suffix, "@Foreign Subsidiaries".length);
expect(composer.trigger?.rangeEnd).toBe("@Foreign Subsidiaries".length);
await updatePrompt("@Foreign Subsidiaries" + suffix, 0);
await updatePrompt("@Foreign Subsidiaries" + suffix);
expect(composer.trigger).toBeNull();
});

it("does not reopen a dismissed multi-word search as typing continues", async () => {
await updatePrompt("@Foreign");
await updatePrompt("@Foreign ");
await act(() => composer.dismissTrigger(composer.trigger));
await updatePrompt("@Foreign Subsidiaries");
expect(composer.trigger).toBeNull();
await updatePrompt("@Foreign Subsidiaries @src");
expect(composer.trigger?.query).toBe("src");
});

it("closes after selection and leaves following prose outside the search", async () => {
await updatePrompt("@Foreign");
await updatePrompt("@Foreign Subsidiaries");
await updatePrompt("[Foreign Subsidiaries](Foreign%20Subsidiaries) ");
await updatePrompt("[Foreign Subsidiaries](Foreign%20Subsidiaries) please inspect");
expect(composer.trigger).toBeNull();
});

it("closes suggestions and rejects keyboard selection before the next render", async () => {
const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
expect(composer.trigger).toEqual(candidate);
Expand Down Expand Up @@ -127,7 +217,7 @@ describe("composer suggestion dismissal", () => {
it("clears dismissal when switching drafts or pending questions", async () => {
await act(() => composer.dismissTrigger(composer.trigger));
const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
await act(() => composer.resetTrigger(candidate));
await act(() => composer.resetTrigger(candidate, initialPrompt));

expect(composer.trigger).toEqual(candidate);
expect(composer.resolveTrigger(candidate)).toEqual(candidate);
Expand Down
Loading
Loading