diff --git a/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx b/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx index 6bc1300d67..1ccadef588 100644 --- a/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx +++ b/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx @@ -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(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(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 | 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(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(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(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(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 ( - - setSessionSheetOpen(true)} - onMorePress={handleDeleteActive} - onNewPress={handleNewChat} - onMenuPress={ - wsSlug ? () => router.push(`/${wsSlug}/menu`) : undefined + +
setSessionSheetOpen(true)} + /> + } + right={ + } /> {availability === "none" ? : 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 `
` 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. */} setAgentPickerOpen(false)} /> - + ); } diff --git a/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx b/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx index ea8b517526..9d431c2181 100644 --- a/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx +++ b/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx @@ -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(["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 ( - - +
- + } @@ -138,7 +129,7 @@ export default function Inbox() { ) : error ? ( - + Failed to load inbox:{" "} {error instanceof Error ? error.message : "unknown error"} @@ -172,21 +163,6 @@ export default function Inbox() { onRefresh={refetch} /> )} - - ); -} - -const HIT_SLOP = { top: 8, right: 8, bottom: 8, left: 8 } as const; - -function InboxMenuButton({ onPress }: { onPress: () => void }) { - return ( - - - + ); } diff --git a/apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx b/apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx index fe6695ed65..5ba658951a 100644 --- a/apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx +++ b/apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx @@ -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(() => { if (filtered.length === 0) return []; const byStatus = new Map(); @@ -142,8 +137,8 @@ export default function MyIssues() { !isLoading && !error && filtered.length === 0; return ( - - } /> + +
} /> ) : error ? ( - + Failed to load issues:{" "} {error instanceof Error ? error.message : "unknown error"} @@ -231,7 +226,7 @@ export default function MyIssues() { } onClearFilters={() => useMyIssuesViewStore.getState().clearFilters()} /> - + ); } @@ -245,7 +240,7 @@ function ScopeTabs({ filterSlot?: React.ReactNode; }) { return ( - + {SCOPES.map((s) => { const active = s.value === scope; diff --git a/apps/mobile/app/(app)/[workspace]/more/issues.tsx b/apps/mobile/app/(app)/[workspace]/more/issues.tsx index 7b0be8e965..847cedfd9f 100644 --- a/apps/mobile/app/(app)/[workspace]/more/issues.tsx +++ b/apps/mobile/app/(app)/[workspace]/more/issues.tsx @@ -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: () => ( + setSheetOpen(true)} + /> + ), + }); + }); + return ( - - setSheetOpen(true)} - /> - } - /> + {hasActiveFilters ? ( useIssuesViewStore.getState().clearFilters()} /> - + ); } diff --git a/apps/mobile/components/chat/chat-header.tsx b/apps/mobile/components/chat/chat-header.tsx deleted file mode 100644 index b1f827049f..0000000000 --- a/apps/mobile/components/chat/chat-header.tsx +++ /dev/null @@ -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 ( - - {onMenuPress ? ( - - - - ) : null} - - - - - - {agentName} - - - - - {subtitle} - - - - - - {showMore ? ( - - - - ) : null} - - + - - - - ); -} diff --git a/apps/mobile/components/chat/chat-session-actions.tsx b/apps/mobile/components/chat/chat-session-actions.tsx new file mode 100644 index 0000000000..730b9deee1 --- /dev/null +++ b/apps/mobile/components/chat/chat-session-actions.tsx @@ -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 ` + ); +} diff --git a/apps/mobile/components/ui/screen-header.tsx b/apps/mobile/components/ui/screen-header.tsx deleted file mode 100644 index b1498aa8bd..0000000000 --- a/apps/mobile/components/ui/screen-header.tsx +++ /dev/null @@ -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 ( - - - {title} - {subtitle ? ( - - {subtitle} - - ) : null} - - {right ? ( - {right} - ) : null} - - ); -}