refactor(mobile): simple Header + IconButton, drop ScreenHeader / ChatHeader

Tab and stack screens were carrying two hand-rolled header components
(ScreenHeader, ChatHeader) that reimplemented enough of UINavigationBar
to ship the obvious bugs: hardcoded hex colors that didn't follow the
NativeWind dark scheme, no shared dark/light token wiring, no consistent
touch feedback for action buttons (Pressable + custom className per
call site).

This commit collapses both into one shared component family:

  - `components/ui/header.tsx` — slot-based (`title` / `center` / `left`
    / `right`) rendered in the screen's JSX. Self-handles the top safe
    area, uses semantic RNR tokens (`bg-background`, `text-foreground`,
    `border-border`) so dark mode flips via NativeWind class mode with
    no per-screen logic.
  - `components/ui/icon-button.tsx` — `<RNR Button variant="ghost"
    size="icon">` wrapping an Ionicon whose color falls back to
    `useTheme().colors.text` (the active navigation theme), so the
    glyph follows dark/light automatically without callers passing
    a color prop.
  - `components/chat/chat-title-button.tsx` + `chat-session-actions.tsx`
    — chat-specific slots that plug into the same Header (center +
    right) instead of the chat tab having its own complete header.

Call sites:
  - Inbox / My Issues / Chat / more/issues — drop `<ScreenHeader>` and
    `<ChatHeader>`, render `<Header ...>` at the top of the screen body
    with the appropriate slot contents.
  - HeaderActions — Search / New-Issue buttons swap raw Pressable for
    IconButton. The previously-added Menu button is removed (redundant
    with the "More" tab in the bottom bar).
  - more/issues — was rendering both the workspace stack's native
    header AND its own ScreenHeader inside the screen body, so the
    filter button now goes onto the stack header via
    `navigation.setOptions({ headerRight })` and the in-body header
    is gone.

Why the per-tab Stack approach (briefly explored) was abandoned:
react-navigation's native large title is the only thing that needed a
Stack per tab, and the product doesn't want collapse-on-scroll. With
that gone, every dynamic header content piece (Inbox's archive menu,
Chat's agent picker title) was forced through `navigation.setOptions`
in a useLayoutEffect — strictly more complexity than just rendering
the Header in JSX with state passed as props.

Net: 349 lines removed, 208 added. Two header components deleted; two
small primitives added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-19 13:35:23 +08:00
parent 8996e821b9
commit 125151e2c4
11 changed files with 287 additions and 332 deletions

View File

@@ -2,9 +2,12 @@
* Chat tab — single-screen IA.
*
* Layout:
* SafeAreaView ─ ChatHeader ─ (NoAgentBanner?) ─ ChatMessageList
* └─ StatusPill
* └─ ChatComposer
* View ─ Header(center: ChatTitleButton, right: ChatSessionActions)
* ─ (NoAgentBanner?)
* ─ KeyboardAvoidingView ─ ChatMessageList
* ─ StatusPill
* ─ OfflineBanner
* ─ ChatComposer
*
* Session switching, agent selection, and session deletion all happen
* inside this screen via Modal sheets — there is no `/chat/[id]` sub-route.
@@ -33,9 +36,7 @@ import {
Platform,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
@@ -67,7 +68,9 @@ import { useChatSessionRealtime } from "@/data/realtime/use-chat-session-realtim
import { canAssignAgent } from "@/lib/can-assign-agent";
import { useWorkspaceAgentAvailability } from "@/lib/workspace-agent-availability";
import { useAgentPresence } from "@/lib/use-agent-presence";
import { ChatHeader } from "@/components/chat/chat-header";
import { Header } from "@/components/ui/header";
import { ChatTitleButton } from "@/components/chat/chat-title-button";
import { ChatSessionActions } from "@/components/chat/chat-session-actions";
import { ChatMessageList } from "@/components/chat/chat-message-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { StatusPill } from "@/components/chat/status-pill";
@@ -79,7 +82,6 @@ import { OfflineBanner } from "@/components/chat/offline-banner";
export default function ChatTab() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const userId = useAuthStore((s) => s.user?.id);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
@@ -94,26 +96,14 @@ export default function ChatTab() {
// ── Auto-hydrate active session on first Chat tab entry ────────────────
// Mobile-only deviation from web: web's chat-window opens to an empty
// state when no `activeSessionId` is persisted, because the sidebar
// SessionDropdown makes switching one-click cheap. On a phone, picking
// a session is 4 taps (header → sheet open → row → close), so an
// always-empty default is friction. Instead, when the user first sees
// the Chat tab in this workspace, jump straight to the most recent
// session (sessions are server-sorted by updated_at desc, so
// sessions[0] is "what they were last working on" 99% of the time).
//
// Hydration is a one-shot per workspace: once it runs, subsequent
// user intent (point + New, delete-active) is respected and never
// overwritten by this effect. ref resets when wsId changes so the
// next workspace gets its own first-entry hydration.
// state when no `activeSessionId` is persisted; on a phone, picking
// a session is 4 taps, so jump straight to the most recent session.
// Hydration is one-shot per workspace.
const hydratedWsRef = useRef<string | null>(null);
useEffect(() => {
if (!wsId) return;
if (hydratedWsRef.current === wsId) return;
if (sessions.length === 0) {
// Workspace truly has no chat history — leave activeSessionId null
// so the empty-state ("Start the conversation") renders. Mark
// hydrated so we don't keep checking on every WS event.
hydratedWsRef.current = wsId;
return;
}
@@ -147,8 +137,7 @@ export default function ChatTab() {
);
// Active agent: explicit selection wins; otherwise inherit from the
// active session; otherwise pick the first available agent so a fresh
// workspace lands on the right header rather than "Chat" placeholder.
// active session; otherwise pick the first available agent.
const currentAgent: Agent | null = useMemo(() => {
if (selectedAgentId) {
return availableAgents.find((a) => a.id === selectedAgentId) ?? null;
@@ -160,9 +149,6 @@ export default function ChatTab() {
}, [selectedAgentId, availableAgents, activeSession, agents]);
const availability = useWorkspaceAgentAvailability();
// Current agent's runtime-driven presence — drives the OfflineBanner above
// the composer. `"loading"` collapses to `undefined` so the banner stays
// silent during cold fetch (avoids a 1s flash of speculative offline copy).
const presenceDetail = useAgentPresence(wsId, currentAgent?.id);
const presenceAvailability =
presenceDetail === "loading" ? undefined : presenceDetail.availability;
@@ -177,27 +163,11 @@ export default function ChatTab() {
const promoteNewDraft = useChatDraftsStore((s) => s.promoteNewDraft);
// ── Realtime ───────────────────────────────────────────────────────────
// Per-record subscription for the active session. If the session is
// deleted by another client, drop the pointer so we land back on the
// new-chat blank state instead of a phantom view.
useChatSessionRealtime(activeSessionId, () => {
setActiveSessionId(null);
});
// ── Auto markRead while viewing a session with unread state ──────────
// Mirrors packages/views/chat/components/chat-window.tsx auto-markRead.
//
// Gate on tab focus: in React Navigation tab navigators, sibling tabs
// stay mounted in the background, so this effect re-fires for every WS
// chat:done arriving on the active session even when the user has
// switched to Inbox/My Issues. Without the focus check the badge clears
// itself behind the user's back. Web's equivalent gates on `isOpen` for
// the same reason.
//
// has_unread is the inner dedup signal: the optimistic patch in
// markRead flips it to false, so the effect won't re-fire until a new
// chat:done event flips it true again — at which point we DO want to
// mark it read again, because the user is still viewing the session.
const isFocused = useIsFocused();
const markRead = useMarkChatSessionRead();
useEffect(() => {
@@ -212,8 +182,6 @@ export default function ChatTab() {
const deleteSession = useDeleteChatSession();
// ── Send burst ─────────────────────────────────────────────────────────
// Ensures a single in-flight createChatSession when the user fires
// multiple sends back-to-back on a new chat.
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
const ensureSession = useCallback(
@@ -247,8 +215,6 @@ export default function ChatTab() {
const sessionId = await ensureSession(content);
if (!sessionId) return;
// Optimistic burst — every visual cue lands before the HTTP
// roundtrip so the user sees their message + StatusPill instantly.
const sentAt = new Date().toISOString();
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
@@ -258,14 +224,9 @@ export default function ChatTab() {
task_id: null,
created_at: sentAt,
};
// Seed messages cache BEFORE flipping activeSessionId so the
// useQuery subscription doesn't render an empty/loading state for
// one frame.
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? [...old, optimistic] : [optimistic],
);
// Seed pendingTask with a temporary id so StatusPill mounts and
// starts ticking immediately. The real task_id arrives below.
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: `optimistic-${optimistic.id}`,
status: "queued",
@@ -278,25 +239,18 @@ export default function ChatTab() {
try {
const result = await api.sendChatMessage(sessionId, content);
// Replace the temporary task_id with the server's authoritative
// one + snap created_at so the StatusPill timer doesn't jump.
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
created_at: result.created_at,
});
// Refetch messages to pick up the persisted user message with its
// real id (replacing the `optimistic-*` placeholder).
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
clearDraft(sessionId);
} catch (err) {
// Roll back the optimistic message + pendingTask seed.
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? old.filter((m) => m.id !== optimistic.id) : old,
);
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
// Re-throw so ChatComposer restores the user's text into the
// input (it catches and calls onChangeText to repopulate).
throw err;
}
},
@@ -313,10 +267,6 @@ export default function ChatTab() {
// ── Cancel in-flight ───────────────────────────────────────────────────
const handleStop = useCallback(() => {
if (!pendingTask?.task_id || !activeSessionId) return;
// Optimistic clear — pill disappears immediately. WS task:cancelled
// (eventual) will confirm. If the cancel POST fails because the task
// already finished, the success path's WS chat:done already wrote
// the assistant message and there's nothing to recover.
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
void api.cancelTaskById(pendingTask.task_id).catch(() => {
// Silent — task may have already terminated server-side.
@@ -325,8 +275,6 @@ export default function ChatTab() {
// ── Header / sheet actions ─────────────────────────────────────────────
const handleNewChat = useCallback(() => {
// Multi-agent → ask the user. Single-agent or none → just clear the
// active session and let the empty state guide them.
if (availableAgents.length > 1) {
setAgentPickerOpen(true);
return;
@@ -341,9 +289,6 @@ export default function ChatTab() {
}, []);
const handleSelectSession = useCallback((session: ChatSession) => {
// Clearing selectedAgentId lets currentAgent inherit from the
// session's agent_id (which may differ from what the picker last
// showed).
setSelectedAgentId(null);
setActiveSessionId(session.id);
}, []);
@@ -391,15 +336,21 @@ export default function ChatTab() {
: undefined;
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<ChatHeader
currentSession={activeSession}
currentAgent={currentAgent}
onTitlePress={() => setSessionSheetOpen(true)}
onMorePress={handleDeleteActive}
onNewPress={handleNewChat}
onMenuPress={
wsSlug ? () => router.push(`/${wsSlug}/menu`) : undefined
<View className="flex-1 bg-background">
<Header
center={
<ChatTitleButton
currentSession={activeSession}
currentAgent={currentAgent}
onPress={() => setSessionSheetOpen(true)}
/>
}
right={
<ChatSessionActions
showMore={!!activeSession}
onMorePress={handleDeleteActive}
onNewPress={handleNewChat}
/>
}
/>
{availability === "none" ? <NoAgentBanner /> : null}
@@ -407,18 +358,6 @@ export default function ChatTab() {
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
{/* NO wrapper around the message list. Mirrors web's chat-message-
list.tsx which mounts a bare `<div className="flex-1 overflow-
y-auto">` directly inside the chat-window flex column.
"Tap empty area → dismiss keyboard" is provided by the
FlatList itself via `keyboardShouldPersistTaps="handled"`
(taps not handled by a child bubble dismiss the keyboard,
per RN docs). "Drag list → dismiss keyboard" is provided
via `keyboardDismissMode="interactive"`. Wrapping with any
Touchable* (Pressable / TouchableWithoutFeedback / etc.)
inserts a touch-responder claim above the FlatList that
kills its pan gesture on iOS — that's what made the list
unscrollable. */}
<ChatMessageList messages={messages} loading={messagesLoading} />
<StatusPill pendingTask={pendingTask} onStop={handleStop} />
<OfflineBanner
@@ -452,6 +391,6 @@ export default function ChatTab() {
onPick={handlePickAgent}
onClose={() => setAgentPickerOpen(false)}
/>
</SafeAreaView>
</View>
);
}

View File

@@ -4,17 +4,15 @@ import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { ScreenHeader } from "@/components/ui/screen-header";
import { Header } from "@/components/ui/header";
import { IconButton } from "@/components/ui/icon-button";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { SwipeableInboxRow } from "@/components/inbox/swipeable-inbox-row";
import { inboxListOptions } from "@/data/queries/inbox";
@@ -54,23 +52,13 @@ export default function Inbox() {
// for the native stack transition. The mutation's own onMutate writes
// optimistically too, but it awaits cancelQueries first — that one
// microtask is enough for iOS to freeze the row in its unread state
// inside the transition snapshot. Mark-read mutation still runs to
// sync with the server and to fire onSettled invalidate.
// inside the transition snapshot.
qc.setQueryData<InboxItem[]>(["inbox", wsId], (old) =>
old?.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
);
markRead.mutate(item.id);
}
if (item.issue_id && wsSlug) {
// `highlight`: the target comment id (only present on new_comment /
// mentioned / reaction_added notifications — backend populates
// details.comment_id there). When undefined, expo-router strips the
// key cleanly (no "undefined" string).
//
// `h`: nonce forcing the param tuple to differ each tap, so re-tapping
// the same inbox row from a back-navigation re-fires the highlight
// effect on the issue screen (otherwise React sees identical params
// and skips the re-render).
router.push({
pathname: "/[workspace]/issue/[id]",
params: {
@@ -85,8 +73,7 @@ export default function Inbox() {
// Trailing batch menu — mirrors desktop's dropdown
// (packages/views/inbox/components/inbox-page.tsx:220-235). "Archive all"
// is destructive so it gets the iOS red treatment + Alert confirm; the
// narrower variants fire directly because they're already filtered.
// is destructive so it gets the iOS red treatment + Alert confirm.
const onPressMenu = () => {
const options = [
"Cancel",
@@ -123,12 +110,16 @@ export default function Inbox() {
};
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<ScreenHeader
<View className="flex-1 bg-background">
<Header
title="Inbox"
right={
<>
<InboxMenuButton onPress={onPressMenu} />
<IconButton
name="ellipsis-horizontal"
onPress={onPressMenu}
accessibilityLabel="Inbox actions"
/>
<HeaderActions />
</>
}
@@ -138,7 +129,7 @@ export default function Inbox() {
<ActivityIndicator />
</View>
) : error ? (
<View className="px-4 gap-3">
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load inbox:{" "}
{error instanceof Error ? error.message : "unknown error"}
@@ -172,21 +163,6 @@ export default function Inbox() {
onRefresh={refetch}
/>
)}
</SafeAreaView>
);
}
const HIT_SLOP = { top: 8, right: 8, bottom: 8, left: 8 } as const;
function InboxMenuButton({ onPress }: { onPress: () => void }) {
return (
<Pressable
onPress={onPress}
hitSlop={HIT_SLOP}
className="size-9 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="Inbox actions"
>
<Ionicons name="ellipsis-horizontal" size={20} color="#3f3f46" />
</Pressable>
</View>
);
}

View File

@@ -2,16 +2,13 @@
* "My Issues" tab. Three scopes — assigned / created / agents — mirroring
* web's `packages/views/my-issues/components/my-issues-page.tsx`.
*
* Visual baseline mirrors the inbox tab (apps/mobile/CLAUDE.md "Visual
* alignment is baseline"): SafeAreaView + ScreenHeader + scroll body.
* Issues are grouped by status using SectionList in `BOARD_STATUSES` order;
* empty status sections are filtered out so the screen doesn't fill with
* "(0)" headers.
*
* Status + Priority filters mirror web's MyIssuesHeader filter sub-menus
* (packages/views/my-issues/components/my-issues-header.tsx). Filter state
* lives in `useMyIssuesViewStore` and is cleared on workspace change to
* mirror `useClearFiltersOnWorkspaceChange` in
* Status + Priority filters mirror web's MyIssuesHeader filter sub-menus.
* Filter state lives in `useMyIssuesViewStore` and is cleared on workspace
* change to mirror `useClearFiltersOnWorkspaceChange` in
* packages/core/issues/stores/view-store.ts:273-284.
*/
import { useEffect, useMemo, useRef, useState } from "react";
@@ -21,7 +18,6 @@ import {
SectionList,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import Svg, { Line } from "react-native-svg";
@@ -33,7 +29,7 @@ import type {
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { ScreenHeader } from "@/components/ui/screen-header";
import { Header } from "@/components/ui/header";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
@@ -117,8 +113,7 @@ export default function MyIssues() {
);
// When statusFilters is non-empty, intersect visible status order with it
// so hidden statuses don't render an empty section header. Mirrors
// packages/views/my-issues/components/my-issues-page.tsx:94-98.
// so hidden statuses don't render an empty section header.
const sections = useMemo<IssueSection[]>(() => {
if (filtered.length === 0) return [];
const byStatus = new Map<IssueStatus, Issue[]>();
@@ -142,8 +137,8 @@ export default function MyIssues() {
!isLoading && !error && filtered.length === 0;
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<ScreenHeader title="My Issues" right={<HeaderActions />} />
<View className="flex-1 bg-background">
<Header title="My Issues" right={<HeaderActions />} />
<ScopeTabs
scope={scope}
onChange={setScope}
@@ -173,7 +168,7 @@ export default function MyIssues() {
<ActivityIndicator />
</View>
) : error ? (
<View className="px-4 gap-3">
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
@@ -231,7 +226,7 @@ export default function MyIssues() {
}
onClearFilters={() => useMyIssuesViewStore.getState().clearFilters()}
/>
</SafeAreaView>
</View>
);
}
@@ -245,7 +240,7 @@ function ScopeTabs({
filterSlot?: React.ReactNode;
}) {
return (
<View className="flex-row items-center px-4 pb-2">
<View className="flex-row items-center px-4 pt-2 pb-2">
<View className="flex-row gap-1 flex-1">
{SCOPES.map((s) => {
const active = s.value === scope;

View File

@@ -20,21 +20,19 @@
* are deferred — see plan; v1 ships with the same filter set as My Issues
* for consistency and code reuse.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Pressable,
SectionList,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { router, useNavigation } from "expo-router";
import Svg, { Line } from "react-native-svg";
import type { Issue, IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { ScreenHeader } from "@/components/ui/screen-header";
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
import { IssueFilterSheet } from "@/components/issue/issue-filter-sheet";
@@ -51,6 +49,7 @@ import { filterIssues } from "@/lib/filter-issues";
type IssueSection = { status: IssueStatus; data: Issue[] };
export default function IssuesPage() {
const navigation = useNavigation();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
@@ -107,17 +106,22 @@ export default function IssuesPage() {
const showEmptyState =
!isLoading && !error && filtered.length === 0;
// Native Stack header (registered in [workspace]/_layout.tsx as
// "more/issues" with title: "Issues") owns the chrome — we just feed it
// the filter button via headerRight.
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<FilterButton
hasActive={hasActiveFilters}
onPress={() => setSheetOpen(true)}
/>
),
});
});
return (
<SafeAreaView className="flex-1 bg-background" edges={["bottom"]}>
<ScreenHeader
title="Issues"
right={
<FilterButton
hasActive={hasActiveFilters}
onPress={() => setSheetOpen(true)}
/>
}
/>
<View className="flex-1 bg-background">
{hasActiveFilters ? (
<ActiveFilterChips
statusFilters={statusFilters}
@@ -193,7 +197,7 @@ export default function IssuesPage() {
}
onClearFilters={() => useIssuesViewStore.getState().clearFilters()}
/>
</SafeAreaView>
</View>
);
}

View File

@@ -1,118 +0,0 @@
/**
* Chat screen top bar.
*
* Layout (left-to-right):
* - Tappable centre region: agent avatar + agent name + session title
* subtitle (▼ indicator). Tap → opens session sheet.
* - Right-side actions: ⋯ (current-session menu, only when there IS an
* active session — Delete in v1), + (new chat).
*
* Global nav lives in the bottom-bar "More" tab, not here.
*
* Differs from ScreenHeader (`@/components/ui/screen-header`): the latter
* is left-aligned and doesn't have a press handler on the title. Chat
* needs a centred / tappable title-as-affordance, so this is its own
* component rather than a ScreenHeader variant.
*
* Empty-state copy: when `currentSession === null` (new chat) the
* subtitle reads "New chat" so the title region never looks broken.
*/
import { Pressable, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { Agent, ChatSession } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
interface Props {
/** Active session — `null` when on the new-chat blank state. */
currentSession: ChatSession | null;
/** Currently selected agent. May differ from `currentSession.agent_id` for
* one render between agent switch and session reset; the screen reconciles. */
currentAgent: Agent | null;
onTitlePress: () => void;
onMorePress: () => void;
onNewPress: () => void;
/** Opens the workspace menu sheet (/[workspace]/menu). Optional so other
* embed sites can omit it, but the chat tab always provides it. */
onMenuPress?: () => void;
}
export function ChatHeader({
currentSession,
currentAgent,
onTitlePress,
onMorePress,
onNewPress,
onMenuPress,
}: Props) {
const agentName = currentAgent?.name ?? "Chat";
const subtitle = currentSession?.title || "New chat";
const showMore = !!currentSession;
return (
<View className="flex-row items-center px-3 pt-2 pb-2 border-b border-border bg-background">
{onMenuPress ? (
<Pressable
onPress={onMenuPress}
hitSlop={8}
className="h-9 w-9 mr-1 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="Menu"
>
<Ionicons name="ellipsis-horizontal" size={20} color="#3f3f46" />
</Pressable>
) : null}
<Pressable
onPress={onTitlePress}
hitSlop={4}
className="flex-1 flex-row items-center gap-2 px-1 py-1 rounded-lg active:bg-secondary"
accessibilityRole="button"
accessibilityLabel="Sessions and agent picker"
>
<ActorAvatar
type={currentAgent ? "agent" : null}
id={currentAgent?.id ?? null}
size={28}
showPresence
/>
<View className="flex-1">
<View className="flex-row items-center gap-1">
<Text
className="text-base font-semibold text-foreground"
numberOfLines={1}
>
{agentName}
</Text>
<Text className="text-xs text-muted-foreground"></Text>
</View>
<Text
className="text-xs text-muted-foreground mt-0.5"
numberOfLines={1}
>
{subtitle}
</Text>
</View>
</Pressable>
<View className="flex-row items-center">
{showMore ? (
<Pressable
onPress={onMorePress}
hitSlop={8}
className="h-9 w-9 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="Session actions"
>
<Text className="text-base text-foreground"></Text>
</Pressable>
) : null}
<Pressable
onPress={onNewPress}
hitSlop={8}
className="h-9 w-9 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="New chat"
>
<Text className="text-xl text-foreground leading-none">+</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -0,0 +1,40 @@
/**
* Right-side actions for the Chat tab header. Two buttons:
* - ⋯ (session menu): only when an active session exists.
* - + (new chat): always shown.
*
* Both are RNR `<Button variant="ghost" size="icon">` via IconButton, so
* touch feedback / sizing / dark-mode tinting are all consistent with the
* rest of the header toolbar.
*/
import { IconButton } from "@/components/ui/icon-button";
interface Props {
showMore: boolean;
onMorePress: () => void;
onNewPress: () => void;
}
export function ChatSessionActions({
showMore,
onMorePress,
onNewPress,
}: Props) {
return (
<>
{showMore ? (
<IconButton
name="ellipsis-horizontal"
onPress={onMorePress}
accessibilityLabel="Session actions"
/>
) : null}
<IconButton
name="add"
iconSize={24}
onPress={onNewPress}
accessibilityLabel="New chat"
/>
</>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Centred, tappable title region for the Chat tab's native Stack header.
* Rendered as `headerTitle: () => <ChatTitleButton ... />` so iOS positions
* it where it expects the screen title, but the whole region is a Pressable
* — tap opens the sessions + agent picker sheet.
*/
import { Pressable, View } from "react-native";
import type { Agent, ChatSession } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
interface Props {
currentSession: ChatSession | null;
currentAgent: Agent | null;
onPress: () => void;
}
export function ChatTitleButton({
currentSession,
currentAgent,
onPress,
}: Props) {
const agentName = currentAgent?.name ?? "Chat";
const subtitle = currentSession?.title || "New chat";
return (
<Pressable
onPress={onPress}
hitSlop={4}
className="flex-row items-center gap-2 px-2 py-1 rounded-lg active:bg-secondary"
accessibilityRole="button"
accessibilityLabel="Sessions and agent picker"
>
<ActorAvatar
type={currentAgent ? "agent" : null}
id={currentAgent?.id ?? null}
size={24}
showPresence
/>
<View>
<View className="flex-row items-center gap-1">
<Text
className="text-base font-semibold text-foreground"
numberOfLines={1}
>
{agentName}
</Text>
<Text className="text-xs text-muted-foreground"></Text>
</View>
<Text
className="text-xs text-muted-foreground"
numberOfLines={1}
>
{subtitle}
</Text>
</View>
</Pressable>
);
}

View File

@@ -3,21 +3,15 @@
* Provides two global actions on the right: search and create-issue.
*
* The workspace menu (global nav, workspace switcher, settings) is reached
* via the "More" tab in the bottom bar — see (tabs)/_layout.tsx, which
* intercepts the tabPress and presents /[workspace]/menu as an iOS
* formSheet. Header doesn't duplicate that entry to keep the strip clean.
* via the "More" tab in the bottom bar.
*
* Tab-specific actions (e.g. My Issues filter) MUST NOT live here — they
* mix scope levels with global actions and would clutter the strip.
*/
import { Pressable } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { IconButton } from "@/components/ui/icon-button";
import { useWorkspaceStore } from "@/data/workspace-store";
const ICON_COLOR = "#3f3f46";
const HIT = { top: 8, right: 8, bottom: 8, left: 8 } as const;
export function HeaderActions() {
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
@@ -30,22 +24,17 @@ export function HeaderActions() {
return (
<>
<Pressable
<IconButton
name="search"
onPress={onSearch}
hitSlop={HIT}
className="size-9 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="Search"
>
<Ionicons name="search" size={20} color={ICON_COLOR} />
</Pressable>
<Pressable
/>
<IconButton
name="add"
iconSize={24}
onPress={onCreate}
hitSlop={HIT}
className="size-9 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="New issue"
>
<Ionicons name="add" size={24} color={ICON_COLOR} />
</Pressable>
/>
</>
);
}

View File

@@ -0,0 +1,71 @@
/**
* Mobile screen header — single row, slot-based. Rendered in the screen's
* JSX (NOT a react-navigation header), so dynamic content reaches it as
* plain props instead of `navigation.setOptions` closures.
*
* <Header title="Inbox" right={<HeaderActions />} />
* <Header center={<ChatTitleButton ... />} right={<ChatSessionActions ... />} />
*
* Self-handles the top safe area. Colors live on RNR tokens
* (`bg-background`, `text-foreground`, `border-border`) so dark mode flips
* via NativeWind without any logic here.
*
* For push screens (issue/[id], more/issues, etc.) keep using the native
* Stack header — that's where the iOS back button + swipe-to-dismiss come
* from. This component is for tab roots only.
*/
import type { ReactNode } from "react";
import { View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Text } from "@/components/ui/text";
interface Props {
/** Title text (fallback when `center` is not provided). */
title?: string;
/** Optional subtitle below the title. Ignored when `center` is provided. */
subtitle?: string;
/** Centered custom node — wins over `title`. Use for tappable titles, agent pickers, etc. */
center?: ReactNode;
/** Leading slot (left of title). Use sparingly — most screens just leave it null. */
left?: ReactNode;
/** Trailing slot — action buttons, menus, search/add toolbar. */
right?: ReactNode;
}
export function Header({ title, subtitle, center, left, right }: Props) {
return (
<SafeAreaView
edges={["top"]}
className="bg-background border-b border-border"
>
<View className="flex-row items-center h-12 px-2">
{left ? <View className="flex-row items-center">{left}</View> : null}
<View className="flex-1 px-2 justify-center">
{center ?? (
title ? (
<>
<Text
className="text-lg font-semibold text-foreground"
numberOfLines={1}
>
{title}
</Text>
{subtitle ? (
<Text
className="text-xs text-muted-foreground"
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
</>
) : null
)}
</View>
{right ? (
<View className="flex-row items-center gap-1">{right}</View>
) : null}
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Icon-only button — RNR `<Button variant="ghost" size="icon">` wrapping an
* Ionicon. The icon color falls back to the active navigation theme's
* foreground (via `useTheme()`), so dark mode flips automatically without
* anyone passing a color prop.
*
* Use everywhere we'd otherwise hand-write
* <Pressable className="size-9 active:bg-secondary"><Ionicons color="#3f3f46" /></Pressable>
* — that pattern hardcodes a light-mode hex and reinvents button chrome RNR
* already ships.
*/
import { type ComponentProps } from "react";
import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "@react-navigation/native";
import { Button, type ButtonProps } from "@/components/ui/button";
interface Props extends Omit<ButtonProps, "children" | "size"> {
name: ComponentProps<typeof Ionicons>["name"];
/** Glyph size in points. Default 20 matches iOS toolbar icons. */
iconSize?: number;
/** Override the icon color. Defaults to NAV_THEME[scheme].text. */
color?: string;
}
export function IconButton({
name,
iconSize = 20,
color,
...buttonProps
}: Props) {
const { colors } = useTheme();
return (
<Button variant="ghost" size="icon" {...buttonProps}>
<Ionicons name={name} size={iconSize} color={color ?? colors.text} />
</Button>
);
}

View File

@@ -1,37 +0,0 @@
/**
* iOS-style large title header — single row: title on the left, global
* actions on the right. Not a real UINavigationBar (no scroll-to-shrink
* collapse), but visually communicates "this is an iOS app".
*
* Tab-specific affordances (filter, scope tabs) belong INSIDE the tab
* body, not in this header — keeps scope levels from mixing.
*/
import type { ReactNode } from "react";
import { View } from "react-native";
import { Text } from "@/components/ui/text";
export function ScreenHeader({
title,
subtitle,
right,
}: {
title: string;
subtitle?: string;
right?: ReactNode;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-2 pb-3">
<View className="flex-1 pr-2">
<Text className="text-3xl font-bold text-foreground">{title}</Text>
{subtitle ? (
<Text className="text-sm text-muted-foreground mt-0.5">
{subtitle}
</Text>
) : null}
</View>
{right ? (
<View className="flex-row items-center gap-1">{right}</View>
) : null}
</View>
);
}