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
53 changes: 12 additions & 41 deletions apps/mobile/src/features/threads/ComposerCommandPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +79,7 @@ interface ComposerCommandPopoverProps {
readonly items: ReadonlyArray<ComposerCommandItem>;
readonly triggerKind: ComposerTriggerKind | null;
readonly isLoading: boolean;
readonly searchesThreads: boolean;
readonly error?: string | null;
readonly onSelect: (item: ComposerCommandItem) => void;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<PopoverSurface>
Expand All @@ -221,6 +191,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, props.searchesThreads)}
</Text>
</View>
) : null}
{props.items.length > 0 ? (
<ScrollView
className="max-h-[180px]"
Expand All @@ -237,13 +214,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>
);
});
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ? (
<ComposerPopoverAnchor>
<ComposerCommandPopover
items={composerMenu.items}
triggerKind={composerMenu.trigger.kind}
isLoading={composerMenu.isLoading}
searchesThreads={composerMenu.searchesThreads}
error={composerMenu.error}
onSelect={composerMenu.onSelect}
/>
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/features/threads/composerCommandLabels.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
});
47 changes: 47 additions & 0 deletions apps/mobile/src/features/threads/composerCommandLabels.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
}
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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<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
11 changes: 8 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 @@ -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 },
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
onSelect,
};
}
Loading
Loading