diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cbe89f5fb8768..731d1ff1eb2c9 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -20,6 +20,8 @@ import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachine 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; @@ -77,6 +79,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; } @@ -126,39 +129,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; @@ -210,7 +180,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 ( @@ -221,6 +191,13 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : null} + {(props.triggerKind === "path" && 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)} - - - )} + ) : null} ); }); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 52ef705e93b29..1e4fde4f120e9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -831,12 +831,16 @@ 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)) ? ( 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..2c4599e8550e2 --- /dev/null +++ b/apps/mobile/src/features/threads/composerCommandLabels.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { emptyText, groupLabel } from "./composerCommandLabels"; + +describe("composer command labels", () => { + it("keeps the bare @ picker file-only", () => { + 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(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.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index c0044a74f7944..4cc8c87b9baf4 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,9 +29,40 @@ vi.mock("../../state/server", () => ({ import { 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 72987599f48f6..255db24434bb7 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -302,7 +302,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 }, @@ -614,15 +614,20 @@ 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, + 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 dcef7887012df..1649cdb78a217 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()); }, @@ -91,6 +142,23 @@ it("keeps the original search deadline when rerenders have equivalent targets", }); }); +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 8721b9ecfda88..af352fd2974d1 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 b64dc1c4e2a12..335d2cbb90139 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/new-task-flow-provider.test.tsx", "src/features/workspace/workspace-tabs-state.test.ts", "src/state/use-thread-pr.demand.test.tsx",