feat(mobile): native iOS assignee picker — search bar + pin selected + checkmark accessory

- Switch assignee picker (issue + new-issue) from body-rendered header to
  native Stack header + UISearchController via headerSearchBarOptions.
- Body becomes pure FlatList — fixes react-native-screens#3634 overlap
  (FlatList now route's direct child, no intermediate wrapper view).
- Pin currently-selected actor + Unassigned to the top when no query;
  search results stay in member → agent → squad order.
- Inline right-aligned "Agent" / "Squad" tag mirrors Apple's Value-1 cell
  style (UIListContentConfiguration.valueCell) used throughout Settings.
- Selection indicator: Ionicons checkmark in primary tint only, no row
  bg highlight (Apple HIG: never use selection to indicate state).
- Avatar 28pt → 36pt.
- autoFocus on search bar for search-first pickers — keyboard appears on
  mount, opt-in via hook option.
- Extract useNativeSearchBar + useScrollToTopOnChange hooks under
  apps/mobile/lib/ for phase-2 rollout to label / project / lead pickers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-20 19:13:50 +08:00
parent 6166a0b6a6
commit c19bf6612a
6 changed files with 224 additions and 90 deletions

View File

@@ -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. */}
<Stack.Screen
name="issue/[id]/picker/assignee"
options={SHEET_OPTIONS}
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="issue/[id]/picker/label"
@@ -238,7 +249,11 @@ export default function WorkspaceLayout() {
/>
<Stack.Screen
name="new-issue-picker/assignee"
options={SHEET_OPTIONS}
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="new-issue-picker/project"

View File

@@ -1,6 +1,8 @@
/**
* Assignee picker route for an existing issue. See ./status.tsx for the
* self-contained-route rationale.
* Assignee picker route for an existing issue. Uses the native iOS Stack
* header + UISearchController (registered in ../_layout.tsx with
* `headerShown: true` + title); the search bar wiring is encapsulated in
* `useNativeSearchBar`.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
@@ -8,12 +10,14 @@ import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-b
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueAssigneePickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
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 (
<AssigneePickerBody
value={value}
query={query}
onChange={(next) => {
if (next === null) {
updateIssue.mutate({ assignee_type: null, assignee_id: null });

View File

@@ -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 (
<AssigneePickerBody
value={assignee}
query={query}
onChange={(next) => {
setAssignee(next);
router.back();

View File

@@ -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<Row[]>(() => {
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 <View> hides the list
// from the native search and the rows render at y=0, overlapping the header.
return (
<View className="flex-1">
<View className="px-4 pt-3 pb-2">
<Text className="text-lg font-semibold text-foreground">Assignee</Text>
</View>
<View className="px-4 pb-2 border-b border-border">
<TextField
value={query}
onChangeText={setQuery}
placeholder="Search people"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<FlatList
data={rows}
className="flex-1"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
keyExtractor={(row) => {
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 }) => (
<Pressable
onPress={() => 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" ? (
<View className="size-7 rounded-full border border-dashed border-muted-foreground/40 items-center justify-center">
<Text className="text-xs text-muted-foreground"></Text>
</View>
) : item.kind === "member" ? (
<ActorAvatar
type="member"
id={item.member.user_id}
size={28}
/>
) : item.kind === "agent" ? (
<ActorAvatar type="agent" id={item.agent.id} size={28} />
) : (
<ActorAvatar type="squad" id={item.squad.id} size={28} />
)}
<Text className="flex-1 text-sm text-foreground">
{item.kind === "unassigned"
? "Unassigned"
: item.kind === "member"
? item.member.name
: item.kind === "agent"
? item.agent.name
: item.squad.name}
</Text>
{isSelected(item) ? (
<Text className="text-xs text-muted-foreground"></Text>
) : null}
</Pressable>
)}
ListEmptyComponent={
<View className="px-3 py-8 items-center">
<Text className="text-xs text-muted-foreground">No matches.</Text>
</View>
}
/>
</View>
<FlatList
ref={listRef}
data={rows}
className="flex-1"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
contentInsetAdjustmentBehavior="automatic"
keyExtractor={(row) => {
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 }) => (
<Pressable
onPress={() => select(item)}
className="flex-row items-center gap-3 px-4 py-3 active:bg-secondary"
>
{item.kind === "unassigned" ? (
<View
className="rounded-full border border-dashed border-muted-foreground/40 items-center justify-center"
style={{ width: AVATAR_SIZE, height: AVATAR_SIZE }}
>
<Text className="text-sm text-muted-foreground"></Text>
</View>
) : item.kind === "member" ? (
<ActorAvatar
type="member"
id={item.member.user_id}
size={AVATAR_SIZE}
/>
) : item.kind === "agent" ? (
<ActorAvatar type="agent" id={item.agent.id} size={AVATAR_SIZE} />
) : (
<ActorAvatar type="squad" id={item.squad.id} size={AVATAR_SIZE} />
)}
<Text className="flex-1 text-base text-foreground">
{item.kind === "unassigned"
? "Unassigned"
: item.kind === "member"
? item.member.name
: item.kind === "agent"
? item.agent.name
: item.squad.name}
</Text>
{/* 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" ? (
<Text className="text-sm text-muted-foreground">Agent</Text>
) : item.kind === "squad" ? (
<Text className="text-sm text-muted-foreground">Squad</Text>
) : null}
{isSelected(item) ? (
<Ionicons name="checkmark" size={20} color={checkColor} />
) : null}
</Pressable>
)}
ListEmptyComponent={
<View className="px-3 py-8 items-center">
<Text className="text-sm text-muted-foreground">No matches.</Text>
</View>
}
/>
);
}

View File

@@ -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<TextInputFocusEventData>) =>
setQuery(e.nativeEvent.text),
onCancelButtonPress: () => setQuery(""),
},
});
}, [navigation, placeholder, autoFocus]);
return query;
}

View File

@@ -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<T>(value: T) {
const ref = useRef<FlatList<any>>(null);
useEffect(() => {
ref.current?.scrollToOffset({ offset: 0, animated: false });
}, [value]);
return ref;
}