From 2e3cc7ddd0c3f1b1fabf3909eecad515f22360f3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 18 Oct 2026 20:29:08 -0500 Subject: [PATCH 1/8] fix(mobile): label mixed mention results accurately --- .../threads/ComposerCommandPopover.tsx | 40 ++------------- .../features/threads/NewTaskDraftScreen.tsx | 5 +- .../src/features/threads/ThreadComposer.tsx | 5 +- .../threads/composerCommandLabels.test.ts | 50 +++++++++++++++++++ .../features/threads/composerCommandLabels.ts | 47 +++++++++++++++++ .../threads/use-composer-command-menu.ts | 1 + 6 files changed, 111 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/features/threads/composerCommandLabels.test.ts create mode 100644 apps/mobile/src/features/threads/composerCommandLabels.ts diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cd924e4c61f03..41ff42016c0eb 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -17,6 +17,8 @@ import { AppText as Text } from "../../components/ui/AppText"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; +import { emptyText, groupLabel } from "./composerCommandLabels"; + export type ComposerCommandItem = | { readonly id: string; @@ -66,6 +68,7 @@ interface ComposerCommandPopoverProps { readonly items: ReadonlyArray; readonly triggerKind: ComposerTriggerKind | null; readonly isLoading: boolean; + readonly searchesThreads: boolean; readonly error?: string | null; readonly onSelect: (item: ComposerCommandItem) => void; } @@ -113,39 +116,6 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null { } } -function groupLabel(triggerKind: ComposerTriggerKind | null): string | null { - switch (triggerKind) { - case "pull-request": - return "Pull requests"; - case "slash-command": - return "Commands"; - case "skill": - return "Skills"; - case "path": - return "Files"; - default: - return null; - } -} - -function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string { - if (isLoading) { - return triggerKind === "path" ? "Searching files…" : "Loading…"; - } - switch (triggerKind) { - case "pull-request": - return "No matching pull requests."; - case "path": - return "No matching files or folders."; - case "skill": - return "No skills found."; - case "slash-command": - return "No matching commands."; - default: - return "No results."; - } -} - const CommandRow = memo(function CommandRow(props: { readonly item: ComposerCommandItem; readonly onPress: () => void; @@ -197,7 +167,7 @@ const CommandRow = memo(function CommandRow(props: { export const ComposerCommandPopover = memo(function ComposerCommandPopover( props: ComposerCommandPopoverProps, ) { - const label = groupLabel(props.triggerKind); + const label = groupLabel(props.triggerKind, props.searchesThreads); return ( @@ -227,7 +197,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : ( - {props.error ?? emptyText(props.triggerKind, props.isLoading)} + {props.error ?? emptyText(props.triggerKind, props.isLoading, props.searchesThreads)} )} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index c0b76f4a7a794..dee05644b6da7 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1570,12 +1570,15 @@ export function NewTaskDraftScreen(props: { > {!voiceInput.isBusy && composerMenu.trigger && - (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( + (composerMenu.items.length > 0 || + composerMenu.trigger.kind === "pull-request" || + composerMenu.trigger.kind === "path") ? ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 2a95b632587a3..8a79adabdf454 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -673,12 +673,15 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps > {!voiceInput.isBusy && composerMenu.trigger && - (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( + (composerMenu.items.length > 0 || + composerMenu.trigger.kind === "pull-request" || + composerMenu.trigger.kind === "path") ? ( diff --git a/apps/mobile/src/features/threads/composerCommandLabels.test.ts b/apps/mobile/src/features/threads/composerCommandLabels.test.ts new file mode 100644 index 0000000000000..30a67a809165d --- /dev/null +++ b/apps/mobile/src/features/threads/composerCommandLabels.test.ts @@ -0,0 +1,50 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; +import { describe, expect, it } from "vite-plus/test"; + +import { emptyText, groupLabel } from "./composerCommandLabels"; + +const environmentId = EnvironmentId.make("mention-labels"); +const shells = [ + { + environmentId, + id: ThreadId.make("review"), + title: "Review changes", + updatedAt: "2026-10-18T00:00:00.000Z", + archivedAt: null, + }, +]; + +function matchingThreads(query: string) { + return matchComposerThreadItems({ shells, environmentId, excludeThreadId: null, query }); +} + +describe("composer command labels", () => { + it("keeps the bare @ picker file-only", () => { + expect(matchingThreads(" ")).toEqual([]); + expect(groupLabel("path", false)).toBe("Files"); + expect(emptyText("path", false, false)).toBe("No matching files or folders."); + }); + + it("describes thread searches even when no thread title matches", () => { + expect(matchingThreads("Rev")).toHaveLength(1); + expect(groupLabel("path", true)).toBe("Threads and files"); + expect(matchingThreads("no-match")).toEqual([]); + expect(groupLabel("path", true)).toBe("Threads and files"); + expect(emptyText("path", false, true)).toBe("No matching threads, files, or folders."); + }); + + it("describes the search scope while file results are loading", () => { + expect(emptyText("path", true, true)).toBe("Searching threads and files…"); + expect(emptyText("path", true, false)).toBe("Searching files…"); + }); + + it("keeps other command menus unchanged", () => { + expect(groupLabel("skill", false)).toBe("Skills"); + expect(emptyText("skill", false, false)).toBe("No skills found."); + expect(groupLabel("pull-request", false)).toBe("Pull requests"); + expect(emptyText("pull-request", true, false)).toBe("Loading…"); + expect(groupLabel("slash-command", false)).toBe("Commands"); + expect(emptyText("slash-command", false, false)).toBe("No matching commands."); + }); +}); diff --git a/apps/mobile/src/features/threads/composerCommandLabels.ts b/apps/mobile/src/features/threads/composerCommandLabels.ts new file mode 100644 index 0000000000000..c116c15bcbe10 --- /dev/null +++ b/apps/mobile/src/features/threads/composerCommandLabels.ts @@ -0,0 +1,47 @@ +import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; + +export function groupLabel( + triggerKind: ComposerTriggerKind | null, + searchesThreads: boolean, +): string | null { + switch (triggerKind) { + case "pull-request": + return "Pull requests"; + case "slash-command": + return "Commands"; + case "skill": + return "Skills"; + case "path": + return searchesThreads ? "Threads and files" : "Files"; + default: + return null; + } +} + +export function emptyText( + triggerKind: ComposerTriggerKind | null, + isLoading: boolean, + searchesThreads: boolean, +): string { + if (isLoading) { + return triggerKind === "path" + ? searchesThreads + ? "Searching threads and files…" + : "Searching files…" + : "Loading…"; + } + switch (triggerKind) { + case "pull-request": + return "No matching pull requests."; + case "path": + return searchesThreads + ? "No matching threads, files, or folders." + : "No matching files or folders."; + case "skill": + return "No skills found."; + case "slash-command": + return "No matching commands."; + default: + return "No results."; + } +} 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 67e560b8eaa36..711cea2c1f49f 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -618,6 +618,7 @@ export function useComposerCommandMenu({ onSelectionChange, trigger, items, + searchesThreads: Boolean(environmentId && trigger?.kind === "path" && trigger.query.trim()), skills, isLoading: trigger?.kind === "pull-request" ? pullRequestSearch.isPending : pathSearch.isPending, From 13e4383b0975ea37029b75e3bc47ead45cb420e6 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 18 Oct 2026 20:43:06 -0500 Subject: [PATCH 2/8] fix(mobile): preserve bare mention visibility --- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 2 +- apps/mobile/src/features/threads/ThreadComposer.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index dee05644b6da7..ad31314b0d350 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1572,7 +1572,7 @@ export function NewTaskDraftScreen(props: { composerMenu.trigger && (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request" || - composerMenu.trigger.kind === "path") ? ( + (composerMenu.trigger.kind === "path" && composerMenu.trigger.query.trim().length > 0)) ? ( 0 || composerMenu.trigger.kind === "pull-request" || - composerMenu.trigger.kind === "path") ? ( + (composerMenu.trigger.kind === "path" && + composerMenu.trigger.query.trim().length > 0)) ? ( Date: Sun, 18 Oct 2026 21:31:37 -0500 Subject: [PATCH 3/8] fix(mobile): surface mention path search failures --- .../threads/use-composer-command-menu.test.ts | 42 +++++++++++++++++-- .../threads/use-composer-command-menu.ts | 4 +- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 9a2c8b74bdd83..18e1f49986958 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -9,10 +9,11 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; const refreshProviders = vi.hoisted(() => vi.fn()); +const pathSearchError = vi.hoisted(() => ({ current: null as string | null })); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ - useComposerPathSearch: () => ({ entries: [], isPending: false }), + useComposerPathSearch: () => ({ entries: [], isPending: false, error: pathSearchError.current }), useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), })); vi.mock("../../state/use-composer-drafts", () => ({ @@ -139,9 +140,19 @@ describe("workspace command discovery retry", () => { ], } satisfies ServerProvider; - function Probe({ cwd, status = provider }: { cwd: string; status?: ServerProvider }) { - useComposerCommandMenu({ - draftMessage: "/project", + function Probe({ + cwd, + status = provider, + message = "/project", + onError, + }: { + cwd: string; + status?: ServerProvider; + message?: string; + onError?: (error: string | null) => void; + }) { + const menu = useComposerCommandMenu({ + draftMessage: message, ownerKey: null, environmentId, projectCwd: cwd, @@ -150,12 +161,14 @@ describe("workspace command discovery retry", () => { hasCompactableConversation: false, onChangeDraftMessage: () => {}, }); + onError?.(menu.error); return null; } beforeEach(() => { vi.useFakeTimers(); refreshProviders.mockReset(); + pathSearchError.current = null; refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [provider] } }); const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; const container = { @@ -180,6 +193,27 @@ describe("workspace command discovery retry", () => { vi.unstubAllGlobals(); }); + it.each(["This connection cannot search host files.", "Search connection lost"])( + "surfaces a failed path search instead of a no-match result: %s", + async (error) => { + pathSearchError.current = error; + const onError = vi.fn(); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", message: "@missing", onError })); + }); + expect(onError).toHaveBeenLastCalledWith(error); + }, + ); + + it("keeps path-search failures out of unrelated command menus", async () => { + pathSearchError.current = "Search connection lost"; + const onError = vi.fn(); + await act(async () => { + root.render(createElement(Probe, { cwd: "/project-a", onError })); + }); + expect(onError).toHaveBeenLastCalledWith(null); + }); + it("retries partial commands after the cooldown without editing the draft", async () => { const recovered = { ...provider, 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 711cea2c1f49f..03ede0ee67810 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -627,7 +627,9 @@ export function useComposerCommandMenu({ ? pullRequestProjectId === null || pullRequestRepository === null ? "Pull requests are unavailable for this project." : pullRequestSearch.error - : null, + : trigger?.kind === "path" + ? pathSearch.error + : null, onSelect, }; } From 33baa0d7c72b9a8ef7ed2679cd974911edac4407 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 21:02:21 +0000 Subject: [PATCH 4/8] fix(mobile): keep mention search errors accurate --- .../threads/ComposerCommandPopover.tsx | 15 +++++----- .../threads/composerCommandLabels.test.ts | 21 -------------- .../src/state/queries.debounce.test.tsx | 28 +++++++++++++++++-- apps/mobile/src/state/queries.ts | 2 +- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 41ff42016c0eb..e098d36c04ea2 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -178,6 +178,13 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : null} + {props.error || props.items.length === 0 ? ( + + + {props.error ?? emptyText(props.triggerKind, props.isLoading, props.searchesThreads)} + + + ) : null} {props.items.length > 0 ? ( ))} - ) : ( - - - {props.error ?? emptyText(props.triggerKind, props.isLoading, props.searchesThreads)} - - - )} + ) : null} ); }); diff --git a/apps/mobile/src/features/threads/composerCommandLabels.test.ts b/apps/mobile/src/features/threads/composerCommandLabels.test.ts index 30a67a809165d..2c4599e8550e2 100644 --- a/apps/mobile/src/features/threads/composerCommandLabels.test.ts +++ b/apps/mobile/src/features/threads/composerCommandLabels.test.ts @@ -1,35 +1,14 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; import { describe, expect, it } from "vite-plus/test"; import { emptyText, groupLabel } from "./composerCommandLabels"; -const environmentId = EnvironmentId.make("mention-labels"); -const shells = [ - { - environmentId, - id: ThreadId.make("review"), - title: "Review changes", - updatedAt: "2026-10-18T00:00:00.000Z", - archivedAt: null, - }, -]; - -function matchingThreads(query: string) { - return matchComposerThreadItems({ shells, environmentId, excludeThreadId: null, query }); -} - describe("composer command labels", () => { it("keeps the bare @ picker file-only", () => { - expect(matchingThreads(" ")).toEqual([]); expect(groupLabel("path", false)).toBe("Files"); expect(emptyText("path", false, false)).toBe("No matching files or folders."); }); it("describes thread searches even when no thread title matches", () => { - expect(matchingThreads("Rev")).toHaveLength(1); - expect(groupLabel("path", true)).toBe("Threads and files"); - expect(matchingThreads("no-match")).toEqual([]); expect(groupLabel("path", true)).toBe("Threads and files"); expect(emptyText("path", false, true)).toBe("No matching threads, files, or folders."); }); diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index 0a4c8ff828f3a..014e72dbedb94 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -1,10 +1,11 @@ import { AuthFilesystemReadScope, EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ sessionAtom: {}, + searchError: null as string | null, searchEntries: vi.fn(() => null), })); @@ -31,7 +32,7 @@ vi.mock("./query", () => ({ atom === mocks.sessionAtom ? { authenticated: true, scopes: [AuthFilesystemReadScope] } : null, - error: null, + error: atom === mocks.sessionAtom ? null : mocks.searchError, isPending: false, refresh: () => {}, }), @@ -43,13 +44,21 @@ 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()); }, @@ -82,6 +91,19 @@ it("keeps the original search deadline when rerenders have equivalent targets", }); }); +it("hides a previous search error until the new query finishes debouncing", () => { + using harness = setup(); + mocks.searchError = "File search failed"; + harness.render(target); + expect(harness.result.error).toBe("File search failed"); + + harness.render({ ...target, query: "other" }); + 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.ts b/apps/mobile/src/state/queries.ts index d1b828081b78a..c7c863f5baf64 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -410,7 +410,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return { entries: result.data?.entries ?? [], error: - !hasTarget || fileAccess.isPending + !hasTarget || fileAccess.isPending || normalizedTarget.query !== debouncedTarget.query ? null : canReadFiles ? result.error From 1794e1133688cd9bfdf1ba5ba0015c08d327fa35 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 21:09:19 +0000 Subject: [PATCH 5/8] fix(mobile): wait for all mention search results --- .../threads/ComposerCommandPopover.tsx | 2 +- .../threads/use-composer-command-menu.test.ts | 39 ++++++++++++++++++- .../threads/use-composer-command-menu.ts | 6 ++- .../src/state/queries.debounce.test.tsx | 8 +++- apps/mobile/src/state/queries.ts | 10 +++-- apps/mobile/vite.config.ts | 1 + 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index e098d36c04ea2..d28c114cb3294 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -178,7 +178,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : null} - {props.error || props.items.length === 0 ? ( + {(props.triggerKind === "path" && props.error) || props.items.length === 0 ? ( {props.error ?? emptyText(props.triggerKind, props.isLoading, props.searchesThreads)} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 1b025a38418c2..2088ad903e1d5 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { ProviderDriverKind } from "@t3tools/contracts"; +import { EnvironmentId, ProviderDriverKind } 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(), @@ -23,8 +29,39 @@ vi.mock("../../state/server", () => ({ import { buildComposerSlashCommandItems, resolveComposerCommandSelection, + useComposerCommandMenu, } 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("mobile slash commands", () => { const antigravity = { driver: ProviderDriverKind.make("antigravity"), 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 822222c1f7f2a..53776ba6a2182 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -279,7 +279,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 }, @@ -563,7 +563,9 @@ export function useComposerCommandMenu({ searchesThreads: Boolean(environmentId && trigger?.kind === "path" && trigger.query.trim()), 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 diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index 014e72dbedb94..0c916e56d33b5 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -91,13 +91,17 @@ it("keeps the original search deadline when rerenders have equivalent targets", }); }); -it("hides a previous search error until the new query finishes debouncing", () => { +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({ ...target, query: "other" }); + harness.render(changed); expect(harness.result).toMatchObject({ error: null, isPending: true }); act(() => vi.advanceTimersByTime(200)); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index c7c863f5baf64..3398f9affdd66 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -376,6 +376,10 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { [target.environmentId, 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 @@ -410,14 +414,12 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return { entries: result.data?.entries ?? [], error: - !hasTarget || fileAccess.isPending || normalizedTarget.query !== debouncedTarget.query + !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 c341dec4e2b9a..69de18baeb5fe 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/projects/AddProjectScreen.test.ts", "src/lib/useMediaVideoPlayback.test.tsx", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", From 9ef0e1aa1944d981e8566d93eb6ccbdd939e667f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 00:33:54 +0000 Subject: [PATCH 6/8] fix(mobile): wait for initial mention search snapshot --- .../src/state/queries.debounce.test.tsx | 46 ++++++++++++++++++- .../src/state/queries.filesystem.test.ts | 1 + apps/mobile/src/state/queries.ts | 20 +++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index 0c916e56d33b5..ded9676cf1c07 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -1,4 +1,5 @@ import { AuthFilesystemReadScope, EnvironmentId } from "@t3tools/contracts"; +import { RegistryProvider } from "@effect/atom-react"; import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; @@ -7,6 +8,7 @@ const mocks = vi.hoisted(() => ({ sessionAtom: {}, searchError: null as string | null, searchEntries: vi.fn(() => null), + shellState: { generation: 0, status: "synchronizing" }, })); vi.mock("./session", () => ({ @@ -25,7 +27,14 @@ vi.mock("./projects", () => ({ vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); vi.mock("./vcs", () => ({ vcsEnvironment: {} })); -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: @@ -38,7 +47,40 @@ vi.mock("./query", () => ({ }), })); -import { useComposerPathSearch, type ComposerPathSearchTarget } from "./queries"; +import { useComposerPathSearch, 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: false }, +])("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; 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 a2bc5d715c93d..3014c07273da3 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, 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,13 @@ 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 >= 2 ? environmentIds : [], + ), + ), + ); // Messages need a word of three or more letters; older settled titles match from two. const canSearch = environmentIds.length > 0 && @@ -226,7 +242,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 = From 4885022b84b7e522339ec4fc429d3e2e63e1a2c8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 21:48:48 +0000 Subject: [PATCH 7/8] fix(mobile): wait for single-character mention snapshot --- apps/mobile/src/state/queries.debounce.test.tsx | 2 +- apps/mobile/src/state/queries.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index 9e2baaa61130a..1649cdb78a217 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -65,7 +65,7 @@ it.each([ { 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: 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 }; diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 8f9507c4b36fd..f1e3ef553429e 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -226,7 +226,7 @@ export function useThreadSearch( const initialSnapshotPending = useAtomValue( initialThreadSnapshotPendingAtom( JSON.stringify( - scope.titlesOnly === true && normalizedQuery.length >= 2 ? environmentIds : [], + scope.titlesOnly === true && normalizedQuery.length > 0 ? environmentIds : [], ), ), ); From b29ed9700268e996bfb3d5ba8c584d1115db8046 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 21:51:31 +0000 Subject: [PATCH 8/8] style(mobile): format mention snapshot condition --- apps/mobile/src/state/queries.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index f1e3ef553429e..af352fd2974d1 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -225,9 +225,7 @@ export function useThreadSearch( const anyOlderSettled = useAtomValue(anyOlderSettledAtom(JSON.stringify(environmentIds))); const initialSnapshotPending = useAtomValue( initialThreadSnapshotPendingAtom( - JSON.stringify( - scope.titlesOnly === true && normalizedQuery.length > 0 ? environmentIds : [], - ), + JSON.stringify(scope.titlesOnly === true && normalizedQuery.length > 0 ? environmentIds : []), ), ); // Messages need a word of three or more letters; older settled titles match from two.