diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
index f53541ceeaaef..9c154249f12a3 100644
--- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
+++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
@@ -148,7 +148,7 @@ function groupLabel(
case "skill":
return "Skills";
case "path":
- return items.every((item) => item.type === "path") ? "Files" : "Mentions";
+ return items.length > 0 && items.every((item) => item.type === "path") ? "Files" : "Mentions";
default:
return null;
}
@@ -156,13 +156,13 @@ function groupLabel(
function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string {
if (isLoading) {
- return triggerKind === "path" ? "Searching files…" : "Loading…";
+ return triggerKind === "path" ? "Searching…" : "Loading…";
}
switch (triggerKind) {
case "pull-request":
return "No matching pull requests.";
case "path":
- return "No matching files or folders.";
+ return "No matches.";
case "skill":
return "No skills found.";
case "slash-command":
@@ -240,6 +240,13 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(
) : null}
+ {(props.triggerKind === "path" && props.error) || props.items.length === 0 ? (
+
+
+ {props.error ?? emptyText(props.triggerKind, props.isLoading)}
+
+
+ ) : null}
{props.items.length > 0 ? (
))}
- ) : (
-
-
- {props.error ?? emptyText(props.triggerKind, props.isLoading)}
-
-
- )}
+ ) : null}
);
});
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index e36900736e8e2..7b2be8f95c87e 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -839,7 +839,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
{props.draftControls}
{!isSubmissionLocked &&
composerMenu.trigger &&
- (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
+ (composerMenu.items.length > 0 ||
+ composerMenu.trigger.kind === "pull-request" ||
+ (composerMenu.trigger.kind === "path" &&
+ composerMenu.trigger.query.trim().length > 0)) ? (
({ threadPending: false }));
vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
+vi.mock("@effect/atom-react", () => ({ useAtomRefresh: () => () => {} }));
vi.mock("../../state/queries", () => ({
useComposerPathSearch: () => ({ entries: [], isPending: false }),
useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }),
+ useThreadSearch: () => ({ isPending: search.threadPending }),
}));
vi.mock("../../state/use-composer-drafts", () => ({
getComposerDraftSnapshot: vi.fn(),
@@ -25,9 +36,40 @@ import {
buildComposerModelItems,
buildComposerSlashCommandItems,
resolveComposerCommandSelection,
+ useComposerCommandMenu,
resolveComposerMenuSelection,
} from "./use-composer-command-menu";
+it("keeps a mention search loading until older threads finish", () => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ search.threadPending = true;
+ const root = createRoot(document.createElement("div"));
+ using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
+ let menu: ReturnType | undefined;
+ function Probe() {
+ const state = useComposerCommandMenu({
+ draftMessage: "@missing",
+ ownerKey: null,
+ environmentId: EnvironmentId.make("test"),
+ projectCwd: "/repo",
+ selectedProviderStatus: null,
+ hasThread: true,
+ hasCompactableConversation: false,
+ onChangeDraftMessage: vi.fn(),
+ });
+ useLayoutEffect(() => {
+ menu = state;
+ });
+ return null;
+ }
+
+ act(() => root.render(createElement(Probe)));
+ expect(menu).toMatchObject({ items: [], isLoading: true });
+ search.threadPending = false;
+ act(() => root.render(createElement(Probe)));
+ expect(menu).toMatchObject({ items: [], isLoading: false });
+});
+
describe("resolveComposerMenuSelection", () => {
it("prefers the caret an insertion placed in the current draft text", () => {
expect(
diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts
index 4bc5c2b257ffe..54bfaa71a9bc7 100644
--- a/apps/mobile/src/features/threads/use-composer-command-menu.ts
+++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts
@@ -346,7 +346,7 @@ export function useComposerCommandMenu({
query: trigger?.kind === "path" ? trigger.query : null,
});
// Mounts older settled threads matching the mention, which shells may leave out.
- useThreadSearch(
+ const threadSearch = useThreadSearch(
trigger?.kind === "path" && environmentId ? [environmentId] : [],
trigger?.kind === "path" ? trigger.query : "",
{ titlesOnly: true },
@@ -663,13 +663,17 @@ export function useComposerCommandMenu({
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
? "Pull requests are unavailable for this project."
: pullRequestSearch.error
- : null,
+ : trigger?.kind === "path"
+ ? pathSearch.error
+ : null,
onSelect,
};
}
diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx
index e2eccb4d4125c..7ad21d48ea263 100644
--- a/apps/mobile/src/state/queries.debounce.test.tsx
+++ b/apps/mobile/src/state/queries.debounce.test.tsx
@@ -1,5 +1,5 @@
import { AuthFilesystemReadScope, EnvironmentId } from "@t3tools/contracts";
-import { RegistryContext } from "@effect/atom-react";
+import { RegistryContext, RegistryProvider } from "@effect/atom-react";
import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { act, useLayoutEffect } from "react";
@@ -8,7 +8,9 @@ import { expect, it, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
sessionAtom: {},
+ searchError: null as string | null,
searchEntries: vi.fn(() => null),
+ shellState: { generation: 0, status: "synchronizing" },
listRefs: vi.fn(),
refresh: vi.fn(),
}));
@@ -29,14 +31,21 @@ vi.mock("./projects", () => ({
vi.mock("./atom-registry", () => ({ appAtomRegistry: { refresh: mocks.refresh } }));
vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} }));
vi.mock("./vcs", () => ({ vcsEnvironment: { listRefs: mocks.listRefs } }));
-vi.mock("./threads", () => ({ olderSettledThreads: {}, threadFamilies: {} }));
+vi.mock("./shell", async () => {
+ const { Atom } = await import("effect/reactivity");
+ return { environmentShell: { stateValueAtom: Atom.family(() => Atom.make(mocks.shellState)) } };
+});
+vi.mock("./threads", async () => {
+ const { Atom } = await import("effect/reactivity");
+ return { olderSettledThreads: { olderSettledAtom: () => Atom.make(null) }, threadFamilies: {} };
+});
vi.mock("./query", () => ({
useEnvironmentQuery: (atom: unknown) => ({
data:
atom === mocks.sessionAtom
? { authenticated: true, scopes: [AuthFilesystemReadScope] }
: null,
- error: null,
+ error: atom === mocks.sessionAtom ? null : mocks.searchError,
isPending: false,
refresh: () => {},
}),
@@ -45,20 +54,62 @@ vi.mock("./query", () => ({
import {
useComposerPathSearch,
usePaginatedBranches,
+ useThreadSearch,
type ComposerPathSearchTarget,
} from "./queries";
+it.each([
+ { status: "synchronizing", generation: 0, query: "missing", pending: true },
+ { status: "synchronizing", generation: 1, query: "missing", pending: false },
+ { status: "live", generation: 1, query: "missing", pending: false },
+ { status: "cached", generation: 0, query: "missing", pending: false },
+ { status: "empty", generation: 0, query: "missing", pending: false },
+ { status: "synchronizing", generation: 0, query: "", pending: false },
+ { status: "synchronizing", generation: 0, query: "a", pending: true },
+])("reports title search loading for $status generation $generation query '$query'", (state) => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ mocks.shellState = { status: state.status, generation: state.generation };
+ const root = createRoot(document.createElement("div"));
+ using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
+ let pending: boolean | undefined;
+ function Probe() {
+ const result = useThreadSearch([EnvironmentId.make(JSON.stringify(state))], state.query, {
+ titlesOnly: true,
+ });
+ useLayoutEffect(() => {
+ pending = result.isPending;
+ });
+ return null;
+ }
+ act(() =>
+ root.render(
+
+
+ ,
+ ),
+ );
+ expect(pending).toBe(state.pending);
+});
+
function setup() {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
mocks.searchEntries.mockClear();
+ mocks.searchError = null;
const root = createRoot(document.createElement("div"));
+ let result: ReturnType;
function Probe({ target }: { readonly target: ComposerPathSearchTarget }) {
- useComposerPathSearch(target);
+ const state = useComposerPathSearch(target);
+ useLayoutEffect(() => {
+ result = state;
+ });
return null;
}
return {
+ get result() {
+ return result;
+ },
render(target: ComposerPathSearchTarget) {
act(() => root.render());
},
@@ -100,6 +151,23 @@ it.each([
});
});
+it.each([
+ { field: "environment", changed: { ...target, environmentId: EnvironmentId.make("other") } },
+ { field: "project directory", changed: { ...target, cwd: "/other" } },
+ { field: "query", changed: { ...target, query: "test" } },
+])("hides the previous error while the $field change debounces", ({ changed }) => {
+ using harness = setup();
+ mocks.searchError = "File search failed";
+ harness.render(target);
+ expect(harness.result.error).toBe("File search failed");
+
+ harness.render(changed);
+ expect(harness.result).toMatchObject({ error: null, isPending: true });
+
+ act(() => vi.advanceTimersByTime(200));
+ expect(harness.result).toMatchObject({ error: "File search failed", isPending: false });
+});
+
it.each([
{ field: "environment", changed: { ...target, environmentId: EnvironmentId.make("other") } },
{ field: "project directory", changed: { ...target, cwd: "/other" } },
diff --git a/apps/mobile/src/state/queries.filesystem.test.ts b/apps/mobile/src/state/queries.filesystem.test.ts
index 8737caccf14bd..78c7a3b2013fc 100644
--- a/apps/mobile/src/state/queries.filesystem.test.ts
+++ b/apps/mobile/src/state/queries.filesystem.test.ts
@@ -30,6 +30,7 @@ vi.mock("./projects", () => ({
projectEnvironment: { searchEntries: () => state.searchAtom },
}));
vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
+vi.mock("./shell", () => ({ environmentShell: {} }));
vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} }));
vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() }));
vi.mock("./vcs", () => ({ vcsEnvironment: {} }));
diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts
index 837d77ce851cf..fc97cffb0e531 100644
--- a/apps/mobile/src/state/queries.ts
+++ b/apps/mobile/src/state/queries.ts
@@ -34,6 +34,7 @@ import { AsyncResult, Atom } from "effect/reactivity";
import { useEffect, useMemo, useState } from "react";
import { appAtomRegistry } from "./atom-registry";
+import { environmentShell } from "./shell";
import { orchestrationEnvironment } from "./orchestration";
import { olderSettledThreads, threadFamilies } from "./threads";
import { projectEnvironment } from "./projects";
@@ -72,6 +73,14 @@ const anyOlderSettledAtom = Atom.family((environmentIdsKey: string) =>
),
).pipe(Atom.withLabel(`mobile:any-older-settled:${environmentIdsKey}`)),
);
+const initialThreadSnapshotPendingAtom = Atom.family((environmentIdsKey: string) =>
+ Atom.make((get) =>
+ (JSON.parse(environmentIdsKey) as ReadonlyArray).some((environmentId) => {
+ const state = get(environmentShell.stateValueAtom(environmentId));
+ return state.generation === 0 && state.status === "synchronizing";
+ }),
+ ).pipe(Atom.withLabel(`mobile:initial-thread-snapshot-pending:${environmentIdsKey}`)),
+);
const threadSearch = createThreadSearchResultsAtomFamily({
getSearchAtom: (environmentId, input) =>
input.olderSettledBefore === undefined
@@ -214,6 +223,11 @@ export function useThreadSearch(
const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim();
const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS);
const anyOlderSettled = useAtomValue(anyOlderSettledAtom(JSON.stringify(environmentIds)));
+ const initialSnapshotPending = useAtomValue(
+ initialThreadSnapshotPendingAtom(
+ JSON.stringify(scope.titlesOnly === true && normalizedQuery.length > 0 ? environmentIds : []),
+ ),
+ );
// Messages need a word of three or more letters; older settled titles match from two.
const canSearch =
environmentIds.length > 0 &&
@@ -226,7 +240,9 @@ export function useThreadSearch(
const result = useAtomValue(
searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearch.results(searchKey),
);
- const isPending = canSearch && (normalizedQuery !== debouncedQuery || result.isLoading);
+ const isPending =
+ initialSnapshotPending ||
+ (canSearch && (normalizedQuery !== debouncedQuery || result.isLoading));
// What fast environments returned shows without waiting for a slow one;
// callers keep shown rows in place with `nextShownOrder`.
const ready =
@@ -396,6 +412,10 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) {
const query = normalizeComposerPathSearchQuery(target.query);
const normalizedTarget = { environmentId: target.environmentId, cwd: target.cwd, query };
const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS);
+ const isDebouncing =
+ normalizedTarget.environmentId !== debouncedTarget.environmentId ||
+ normalizedTarget.cwd !== debouncedTarget.cwd ||
+ normalizedTarget.query !== debouncedTarget.query;
const fileAccessSession = useEnvironmentQuery(
debouncedTarget.environmentId === null
? null
@@ -430,14 +450,12 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) {
return {
entries: result.data?.entries ?? [],
error:
- !hasTarget || fileAccess.isPending
+ !hasTarget || fileAccess.isPending || isDebouncing
? null
: canReadFiles
? result.error
: (fileAccess.error ?? "This connection cannot search host files."),
- isPending:
- normalizedTarget.query !== debouncedTarget.query ||
- (hasTarget && (fileAccess.isPending || result.isPending)),
+ isPending: isDebouncing || (hasTarget && (fileAccess.isPending || result.isPending)),
refresh: result.refresh,
};
}
diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts
index 046a2dc97b236..98c10babe6c2e 100644
--- a/apps/mobile/vite.config.ts
+++ b/apps/mobile/vite.config.ts
@@ -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",