diff --git a/apps/mobile/app/(app)/[workspace]/_layout.tsx b/apps/mobile/app/(app)/[workspace]/_layout.tsx
index a1794b0c9..f2a3a543e 100644
--- a/apps/mobile/app/(app)/[workspace]/_layout.tsx
+++ b/apps/mobile/app/(app)/[workspace]/_layout.tsx
@@ -182,9 +182,20 @@ export default function WorkspaceLayout() {
name="issue/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
+ {/* Experiment: assignee uses iOS-native nav header + UISearchController
+ instead of the body-rendered header pattern in SHEET_OPTIONS.
+ Eliminates the #3634 overlap class of bugs and the focus-loss
+ footgun of a custom TextInput inside ListHeaderComponent. The
+ route file wires `headerSearchBarOptions` via setOptions. If this
+ proves out, propagate to label / project / other search pickers
+ and update CLAUDE.md Lesson 6 with a carve-out. */}
();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
+ const query = useNativeSearchBar("Search people", { autoFocus: true });
const value =
issue?.assignee_type && issue?.assignee_id
@@ -23,6 +27,7 @@ export default function IssueAssigneePickerRoute() {
return (
{
if (next === null) {
updateIssue.mutate({ assignee_type: null, assignee_id: null });
diff --git a/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx b/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx
index 6ac43ce18..2684e8abd 100644
--- a/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx
+++ b/apps/mobile/app/(app)/[workspace]/new-issue-picker/assignee.tsx
@@ -1,17 +1,23 @@
/**
* Assignee picker route for the in-progress new-issue draft. See ./status.tsx.
+ * Uses the same iOS-native nav header + UISearchController pattern as
+ * `issue/[id]/picker/assignee.tsx`, with the search bar wiring encapsulated
+ * in `useNativeSearchBar`.
*/
import { router } from "expo-router";
import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-body";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
+import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function NewIssueAssigneePickerRoute() {
const assignee = useNewIssueDraftStore((s) => s.assignee);
const setAssignee = useNewIssueDraftStore((s) => s.setAssignee);
+ const query = useNativeSearchBar("Search people", { autoFocus: true });
return (
{
setAssignee(next);
router.back();
diff --git a/apps/mobile/components/issue/pickers/assignee-picker-body.tsx b/apps/mobile/components/issue/pickers/assignee-picker-body.tsx
index 25526ef96..c327ca8c4 100644
--- a/apps/mobile/components/issue/pickers/assignee-picker-body.tsx
+++ b/apps/mobile/components/issue/pickers/assignee-picker-body.tsx
@@ -5,10 +5,18 @@
*
* Mirrors web `packages/views/issues/components/pickers/assignee-picker.tsx`
* (mobile skips frequency-sort; alphabetical instead).
+ *
+ * Header + search bar are owned by the iOS native nav header registered in
+ * `app/(app)/[workspace]/_layout.tsx` (assignee Stack.Screen sets
+ * `headerShown: true` + `title`); the route file wires
+ * `headerSearchBarOptions.onChangeText` to a local `query` state and passes
+ * it in as the `query` prop. This body is just a FlatList — no chrome.
*/
-import { useMemo, useState } from "react";
+import { useMemo } from "react";
import { FlatList, Pressable, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
+import { Ionicons } from "@expo/vector-icons";
+import { useColorScheme } from "nativewind";
import type {
Agent,
IssueAssigneeType,
@@ -17,12 +25,14 @@ import type {
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
-import { TextField } from "@/components/ui/text-field";
import { memberListOptions } from "@/data/queries/members";
import { agentListOptions } from "@/data/queries/agents";
import { squadListOptions } from "@/data/queries/squads";
import { useWorkspaceStore } from "@/data/workspace-store";
-import { cn } from "@/lib/utils";
+import { useScrollToTopOnChange } from "@/lib/use-scroll-to-top-on-change";
+import { THEME } from "@/lib/theme";
+
+const AVATAR_SIZE = 36;
export type AssigneeValue = {
type: IssueAssigneeType;
@@ -31,6 +41,7 @@ export type AssigneeValue = {
interface Props {
value: AssigneeValue;
+ query: string;
onChange: (next: AssigneeValue) => void;
}
@@ -40,12 +51,29 @@ type Row =
| { kind: "agent"; agent: Agent }
| { kind: "squad"; squad: Squad };
-export function AssigneePickerBody({ value, onChange }: Props) {
+function isRowSelected(value: AssigneeValue, row: Row): boolean {
+ if (row.kind === "unassigned") return value === null;
+ if (value === null) return false;
+ if (row.kind === "member")
+ return value.type === "member" && value.id === row.member.user_id;
+ if (row.kind === "agent")
+ return value.type === "agent" && value.id === row.agent.id;
+ return value.type === "squad" && value.id === row.squad.id;
+}
+
+export function AssigneePickerBody({ value, query, onChange }: Props) {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
- const [query, setQuery] = useState("");
+ const listRef = useScrollToTopOnChange(query);
+ const { colorScheme } = useColorScheme();
+ // Tint color for the checkmark accessory. Project uses a monochrome
+ // shadcn palette where `primary` is the canonical tint (near-black light /
+ // near-white dark); matches Apple HIG's "tintColor" semantics for
+ // selection accessories.
+ const checkColor =
+ colorScheme === "dark" ? THEME.dark.primary : THEME.light.primary;
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -65,23 +93,24 @@ export function AssigneePickerBody({ value, onChange }: Props) {
.map((s) => ({ kind: "squad" as const, squad: s }));
if (q) return [...memberRows, ...agentRows, ...squadRows];
+
+ // Pin the currently-selected actor right below Unassigned and remove it
+ // from its own section so it doesn't render twice. Apple HIG doesn't
+ // require this — it's a product UX choice that speeds up the common
+ // "see who's assigned + reassign nearby" path. Skipped when query is
+ // active because search-result order should reflect matches, not state.
+ const all = [...memberRows, ...agentRows, ...squadRows];
+ const selectedRow = all.find((r) => isRowSelected(value, r));
return [
{ kind: "unassigned" },
- ...memberRows,
- ...agentRows,
- ...squadRows,
+ ...(selectedRow ? [selectedRow] : []),
+ ...memberRows.filter((r) => !isRowSelected(value, r)),
+ ...agentRows.filter((r) => !isRowSelected(value, r)),
+ ...squadRows.filter((r) => !isRowSelected(value, r)),
];
- }, [members, agents, squads, query]);
+ }, [members, agents, squads, query, value]);
- const isSelected = (row: Row): boolean => {
- if (row.kind === "unassigned") return value === null;
- if (value === null) return false;
- if (row.kind === "member")
- return value.type === "member" && value.id === row.member.user_id;
- if (row.kind === "agent")
- return value.type === "agent" && value.id === row.agent.id;
- return value.type === "squad" && value.id === row.squad.id;
- };
+ const isSelected = (row: Row) => isRowSelected(value, row);
const select = (row: Row) => {
if (row.kind === "unassigned") onChange(null);
@@ -92,74 +121,75 @@ export function AssigneePickerBody({ value, onChange }: Props) {
else onChange({ type: "squad", id: row.squad.id });
};
+ // FlatList is returned as the route's direct child so RNSScreenContentWrapper
+ // can find it as a direct subview and apply the iOS formSheet header offset.
+ // See react-native-screens#3634 — wrapping in a parent hides the list
+ // from the native search and the rows render at y=0, overlapping the header.
return (
-
-
- Assignee
-
-
-
-
- {
- if (row.kind === "unassigned") return "unassigned";
- if (row.kind === "member") return `m:${row.member.user_id}`;
- if (row.kind === "agent") return `a:${row.agent.id}`;
- return `s:${row.squad.id}`;
- }}
- renderItem={({ item }) => (
- select(item)}
- className={cn(
- "flex-row items-center gap-3 px-3 py-2.5 active:bg-secondary",
- isSelected(item) && "bg-secondary",
- )}
- >
- {item.kind === "unassigned" ? (
-
- ∅
-
- ) : item.kind === "member" ? (
-
- ) : item.kind === "agent" ? (
-
- ) : (
-
- )}
-
- {item.kind === "unassigned"
- ? "Unassigned"
- : item.kind === "member"
- ? item.member.name
- : item.kind === "agent"
- ? item.agent.name
- : item.squad.name}
-
- {isSelected(item) ? (
- ✓
- ) : null}
-
- )}
- ListEmptyComponent={
-
- No matches.
-
- }
- />
-
+ {
+ if (row.kind === "unassigned") return "unassigned";
+ if (row.kind === "member") return `m:${row.member.user_id}`;
+ if (row.kind === "agent") return `a:${row.agent.id}`;
+ return `s:${row.squad.id}`;
+ }}
+ renderItem={({ item }) => (
+ select(item)}
+ className="flex-row items-center gap-3 px-4 py-3 active:bg-secondary"
+ >
+ {item.kind === "unassigned" ? (
+
+ ∅
+
+ ) : item.kind === "member" ? (
+
+ ) : item.kind === "agent" ? (
+
+ ) : (
+
+ )}
+
+ {item.kind === "unassigned"
+ ? "Unassigned"
+ : item.kind === "member"
+ ? item.member.name
+ : item.kind === "agent"
+ ? item.agent.name
+ : item.squad.name}
+
+ {/* Right-aligned secondary label. Mirrors Apple's
+ UITableViewCellStyleValue1 / UIListContentConfiguration.valueCell
+ pattern used throughout iOS Settings — type tag in lighter font on
+ the same row. Members carry no tag (they're the default actor). */}
+ {item.kind === "agent" ? (
+ Agent
+ ) : item.kind === "squad" ? (
+ Squad
+ ) : null}
+ {isSelected(item) ? (
+
+ ) : null}
+
+ )}
+ ListEmptyComponent={
+
+ No matches.
+
+ }
+ />
);
}
diff --git a/apps/mobile/lib/use-native-search-bar.ts b/apps/mobile/lib/use-native-search-bar.ts
new file mode 100644
index 000000000..5b6d109cd
--- /dev/null
+++ b/apps/mobile/lib/use-native-search-bar.ts
@@ -0,0 +1,51 @@
+/**
+ * Hook for wiring an iOS native `UISearchController` (via react-native-screens
+ * `headerSearchBarOptions`) into a route. Returns the current query string.
+ *
+ * Used by every search-enabled picker route on mobile (issue/project/label/
+ * lead). Pair with `useScrollToTopOnChange` in the body to reset the list
+ * scroll position when the filter changes.
+ *
+ * Why this exists rather than inlining `setOptions` in every route:
+ * - Cancel button contract: `cancelSearch()` clears the native text but
+ * does NOT fire `onChangeText`, so the route MUST reset query state in
+ * `onCancelButtonPress`. Easy to forget when copy-pasted.
+ * - Sensible defaults (autoCapitalize: "none", hideWhenScrolling: false)
+ * match the standard iOS picker pattern; one place to revise.
+ *
+ * Requires the Stack.Screen to register `headerShown: true` + a `title` in
+ * the layout. See `apps/mobile/app/(app)/[workspace]/_layout.tsx` for the
+ * pattern.
+ */
+import { useLayoutEffect, useState } from "react";
+import { useNavigation } from "expo-router";
+import type { NativeSyntheticEvent, TextInputFocusEventData } from "react-native";
+
+export function useNativeSearchBar(
+ placeholder: string,
+ options?: { autoFocus?: boolean },
+): string {
+ const navigation = useNavigation();
+ const [query, setQuery] = useState("");
+ const autoFocus = options?.autoFocus;
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ headerSearchBarOptions: {
+ placeholder,
+ autoCapitalize: "none",
+ hideWhenScrolling: false,
+ // Opt-in: pickers whose primary action is typing (assignee, label,
+ // project, lead) set this so the keyboard appears on mount. Apple
+ // HIG cautions against auto-keyboard for browse-first lists; pass
+ // `autoFocus: true` only when the picker is search-first.
+ autoFocus,
+ onChangeText: (e: NativeSyntheticEvent) =>
+ setQuery(e.nativeEvent.text),
+ onCancelButtonPress: () => setQuery(""),
+ },
+ });
+ }, [navigation, placeholder, autoFocus]);
+
+ return query;
+}
diff --git a/apps/mobile/lib/use-scroll-to-top-on-change.ts b/apps/mobile/lib/use-scroll-to-top-on-change.ts
new file mode 100644
index 000000000..14ebea0ef
--- /dev/null
+++ b/apps/mobile/lib/use-scroll-to-top-on-change.ts
@@ -0,0 +1,27 @@
+/**
+ * Hook that returns a FlatList ref and scrolls the list to offset 0 whenever
+ * the watched value changes.
+ *
+ * Used by every search-enabled picker body to reset scroll when the filter
+ * query changes. UISearchController does NOT do this for us — system iOS
+ * apps work because they swap in a separate `searchResultsController`; the
+ * RN pattern reuses the same FlatList for browse and filter, so without
+ * this reset the filtered list sits below the previously-scrolled viewport
+ * and looks blank.
+ *
+ * `animated: false` is deliberate: `animated: true` produces competing
+ * scroll tweens during fast typing (RN does not cancel in-flight scroll
+ * animations before starting a new one).
+ */
+import { useEffect, useRef } from "react";
+import type { FlatList } from "react-native";
+
+export function useScrollToTopOnChange(value: T) {
+ const ref = useRef>(null);
+
+ useEffect(() => {
+ ref.current?.scrollToOffset({ offset: 0, animated: false });
+ }, [value]);
+
+ return ref;
+}