mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
40
apps/mobile/components/chat/chat-session-actions.tsx
Normal file
40
apps/mobile/components/chat/chat-session-actions.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
apps/mobile/components/chat/chat-title-button.tsx
Normal file
59
apps/mobile/components/chat/chat-title-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
71
apps/mobile/components/ui/header.tsx
Normal file
71
apps/mobile/components/ui/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/mobile/components/ui/icon-button.tsx
Normal file
37
apps/mobile/components/ui/icon-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user