Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2e3cc7d
fix(mobile): label mixed mention results accurately
only21mil Oct 19, 2026
13e4383
fix(mobile): preserve bare mention visibility
only21mil Oct 19, 2026
2faff92
fix(mobile): surface mention path search failures
only21mil Oct 19, 2026
9f65034
Merge main (05696c636e) into fix/mobile-mention-result-labels
only21mil Oct 21, 2026
d9316b7
Merge main (bd6940beab) into fix/mobile-mention-result-labels
only21mil Oct 22, 2026
513d4dd
Merge main (8d3b061121) into fix/mobile-mention-result-labels
only21mil Oct 22, 2026
f696b39
Merge remote-tracking branch 'github/main' into fix/mobile-mention-re…
maria-rcks Oct 26, 2026
33baa0d
fix(mobile): keep mention search errors accurate
maria-rcks Oct 26, 2026
1794e11
fix(mobile): wait for all mention search results
maria-rcks Oct 26, 2026
2af9b56
Merge github/main into fix/mobile-mention-result-labels
maria-rcks Oct 27, 2026
28879f3
chore: merge main into mobile mention labels
maria-rcks Oct 30, 2026
9ef0e1a
fix(mobile): wait for initial mention search snapshot
maria-rcks Oct 30, 2026
2c04fa7
chore: merge main into mobile mention labels
maria-rcks Oct 30, 2026
4885022
fix(mobile): wait for single-character mention snapshot
maria-rcks Oct 30, 2026
2f97382
chore: merge main into mobile mention labels
maria-rcks Oct 30, 2026
b29ed97
style(mobile): format mention snapshot condition
maria-rcks Oct 30, 2026
d1446f3
chore: merge main into mobile mention labels
maria-rcks Nov 1, 2026
404fa9e
Merge main (4c361199f4) into fix/mobile-mention-result-labels
only21mil Nov 10, 2026
b0f54ca
refactor(mobile): drop the mention heading change main now covers
only21mil Nov 10, 2026
5f4f578
fix(mobile): typed @ searches no longer say only files were searched
only21mil Nov 10, 2026
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
21 changes: 11 additions & 10 deletions apps/mobile/src/features/threads/ComposerCommandPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,21 +148,21 @@ 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;
}
}

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":
Expand Down Expand Up @@ -240,6 +240,13 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(
</Text>
</View>
) : null}
{(props.triggerKind === "path" && props.error) || props.items.length === 0 ? (
<View className="px-3.5 py-2.5">
<Text className="text-xs text-foreground-tertiary">
{props.error ?? emptyText(props.triggerKind, props.isLoading)}
</Text>
</View>
) : null}
{props.items.length > 0 ? (
<ScrollView
className="max-h-[180px]"
Expand All @@ -256,13 +263,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(
/>
))}
</ScrollView>
) : (
<View className="px-3.5 py-2.5">
<Text className="text-xs text-foreground-tertiary">
{props.error ?? emptyText(props.triggerKind, props.isLoading)}
</Text>
</View>
)}
) : null}
</PopoverSurface>
);
});
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ? (
<ComposerPopoverAnchor>
<ComposerCommandPopover
items={composerMenu.items}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
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";
import { act, createElement, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";

const search = vi.hoisted(() => ({ 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(),
Expand All @@ -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<typeof useComposerCommandMenu> | 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(
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
onSelect,
};
}
76 changes: 72 additions & 4 deletions apps/mobile/src/state/queries.debounce.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(),
}));
Expand All @@ -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: () => {},
}),
Expand All @@ -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(
<RegistryProvider>
<Probe />
</RegistryProvider>,
),
);
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<typeof useComposerPathSearch>;
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(<Probe target={{ ...target }} />));
},
Expand Down Expand Up @@ -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" } },
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/queries.filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} }));
Expand Down
28 changes: 23 additions & 5 deletions apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<EnvironmentId>).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
Expand Down Expand Up @@ -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 &&
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
}
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