import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, TouchEvent as ReactTouchEvent } from 'react' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconPlusOutlineRegular, IconSettingsOutlineRegular, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { agentPresetOf, mainSessionIdOf } from './compat/types.ts' import type { MobileSessionRow, SessionId, UsePendingInteractions, WorkspaceId } from './compat/types.ts' import { NS } from './locales.ts' import { GO_HOME_EVENT } from './nav-store.ts' import { hasLayer, popLayer, pushLayer } from './history-nav.ts' /** The phone shell's one page-stack step: session list -> session. */ const SESSION_LAYER = 'session' import type { createNavStore } from './nav-store.ts' import type { WorkspaceFilter } from './nav-store.ts' import type { MobileNavKey } from './locales.ts' import { dotState } from './session-dot.ts' import { MobileHomeChips, MobileHomeChipsSheetBody } from './MobileHomeChips.tsx' /** Full props for the phone home screen (shell.overlay entry). */ export type MobileHomeProps = & PropsRuntime<'shell.overlay'> & PropsStore> & PropsLocale & { /** Bound ctx.uiWorkspace.openSession(id). */ openSession: (id: SessionId) => void /** Bound ctx.workspaces.startSession(workspaceId?). */ startSession: (workspaceId?: WorkspaceId) => void /** Bound ctx.sessionLogDownload.download() — the session-log chip (S5). */ downloadSessionLog: (id: SessionId) => void /** Bound ctx.workspaces.archiveSession(id) — the row swipe action. */ archiveSession: (id: SessionId) => Promise /** * 0.1.2 的待处理交互选择器标准 prop;0.1.1 没有这个 prop,运行时读到 * undefined(见组件里 EMPTY_PENDING_HOOK 兜底)。 */ useSessionPendingInteraction?: UsePendingInteractions } /** Phone breakpoint: below the tablet range, where the app-shell layout applies. */ const PHONE_QUERY = '(max-width: 767px)' /** * Empty pending-interaction map singleton: when the 0.1.2 source is absent * the fallback hook returns THIS map, so the selector sees one stable * reference across renders — a fresh Map per render would make every * selector result a new reference and loop the list into infinite * re-renders. Module-level on purpose. */ const EMPTY_PENDING_MAP: ReadonlyMap = new Map() /** 0.1.1 兜底:恒返回空 Map 的替身 hook(见组件里 useSessionPendingInteraction ?? EMPTY_PENDING_HOOK)。 */ const EMPTY_PENDING_HOOK: UsePendingInteractions = (sel: (m: ReadonlyMap) => S) => sel(EMPTY_PENDING_MAP) /** Swipe geometry: reveal width of the archive action / settle threshold. */ const SWIPE_REVEAL = 88 const SWIPE_SETTLE = 44 /** * Direction-lock slop: total displacement before the gesture commits to * horizontal (card drag) or vertical (native scroll) — decided ONCE per * touch, the way native swipe rows do it. Before the lock nothing moves. */ const SWIPE_SLOP = 10 /** Horizontal wins only when clearly flatter than ~35° (|dx| > 1.4·|dy|). */ const SWIPE_SLOPE = 1.4 /** * One session row with swipe-left-to-archive (user request, 2026-08-25; * interaction redone after real-device feedback the same day). * * Native-pattern mechanics: * - **One-shot direction lock.** The first ~10px of displacement decides the * whole gesture: clearly-flat movement (|dx| > 1.4·|dy|) locks to the card * drag, anything else locks to native vertical scroll and the card never * moves again for that touch. The first version re-judged every move * sample, so a curvy upward scroll could flip mid-gesture into a drag. * - **Progressive reveal, iOS-mail style.** The wrapper clips (overflow: * hidden) and the archive button is PARKED past the right edge, sliding in * lock-step with the card (button shift = reveal + card shift). Nothing is * pre-painted under the card, so a 1px drag shows a 1px slice of button * edge — not a fully drawn button popping through a gap. * - At most one row open at a time (parent owns `open`); tapping an open or * mid-drag card closes it instead of navigating. * * Touch-only by design — the list only exists on the phone breakpoint. */ function SwipeRow({ open, onOpenChange, onArchive, archiveLabel, children }: { open: boolean onOpenChange: (open: boolean) => void onArchive: () => void archiveLabel: string children: (guardClick: (navigate: () => void) => void) => ReactNode }) { const start = useRef<{ x: number, y: number } | null>(null) const lock = useRef<'h' | 'v' | null>(null) const [drag, setDrag] = useState(null) // Ref mirror of `drag`: touchend must read the LATEST position even when // the browser delivers move+end in one task (coalesced touch events) — // the state value in this render's closure can be a frame stale. const dragRef = useRef(null) const moveTo = (value: number | null): void => { dragRef.current = value setDrag(value) } const base = open ? -SWIPE_REVEAL : 0 const shift = drag ?? base const dragging = drag !== null const onTouchStart = (e: ReactTouchEvent): void => { const t = e.touches[0] if (t === undefined) return start.current = { x: t.clientX, y: t.clientY } lock.current = null } const onTouchMove = (e: ReactTouchEvent): void => { const s = start.current const t = e.touches[0] if (s === null || t === undefined) return const dx = t.clientX - s.x const dy = t.clientY - s.y if (lock.current === null) { if (Math.hypot(dx, dy) < SWIPE_SLOP) return lock.current = Math.abs(dx) > SWIPE_SLOPE * Math.abs(dy) ? 'h' : 'v' if (lock.current === 'v') return // Re-anchor so the card starts moving from here, not with a 10px jump. start.current = { x: t.clientX, y: t.clientY } return } if (lock.current === 'v') return moveTo(Math.max(-SWIPE_REVEAL, Math.min(0, base + dx))) } const onTouchEnd = (): void => { start.current = null if (lock.current !== 'h') return const settled = (dragRef.current ?? base) < -SWIPE_SETTLE moveTo(null) onOpenChange(settled) } const guardClick = (navigate: () => void): void => { // A tap on an open (or mid-drag) card closes it; navigation needs a // second, clean tap. Matches every list app's swipe convention. if (open || lock.current === 'h') onOpenChange(false) else navigate() } const motion = dragging ? { transition: 'none' as const } : {} return (
  • {children(guardClick)}
  • ) } /** Live matchMedia hook for the phone breakpoint. */ function usePhone(): boolean { const [phone, setPhone] = useState(() => window.matchMedia(PHONE_QUERY).matches) useEffect(() => { const query = window.matchMedia(PHONE_QUERY) const onChange = (event: MediaQueryListEvent) => setPhone(event.matches) query.addEventListener('change', onChange) return () => query.removeEventListener('change', onChange) }, []) return phone } /** * The site's own favicon, read at runtime from `document.head` (real-device * round 2 feedback: a home-screen logo, without shipping any trademarked * asset in this repo). A one-time lazy read is enough — the gateway/host * writes this `` before the client bundle ever runs (same "first * frame" guarantee AGENTS.md documents for `viewport-fit=cover`), and * favicons do not change at runtime in practice, so there is no case here * that justifies a MutationObserver. */ function useSiteIconHref(): string | undefined { const [href] = useState(() => document.querySelector('link[rel~="icon"]')?.href) return href } // Timestamps use the browser's own locale data — no dictionary keys, correct // plurals everywhere. Deliberately NOT `document.documentElement.lang`: the // shell stamps zh-CN there while the UI copy follows the browser languages // (measured 2026-08-17), so the html attribute would print Chinese times in // an English UI. const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) const shortDate = new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }) /** * Relative timestamp for a session row. * @param at - epoch milliseconds. * @returns "now" / "3 minutes ago" / "5/12", localized. */ function relativeTime(at: number): string { const elapsed = Date.now() - at if (elapsed < 60_000) return relative.format(0, 'second') if (elapsed < 3_600_000) return relative.format(-Math.floor(elapsed / 60_000), 'minute') if (elapsed < 86_400_000) return relative.format(-Math.floor(elapsed / 3_600_000), 'hour') if (elapsed < 604_800_000) return relative.format(-Math.floor(elapsed / 86_400_000), 'day') return shortDate.format(at) } /** * Card status subline (real-device round 2 follow-up, 2026-08-17): reuses * `dotState`'s own state read (the same ongoing/warning/done semantics the * dot already encodes) plus `agentPreset` from the same session row — no * new data source. Returns undefined when there is neither a * state nor a preset to show, so the caller can skip the subline entirely * rather than render an empty row. */ function statusLine(row: MobileSessionRow, dot: StateDotState | undefined, t: (key: MobileNavKey) => string): string | undefined { const preset = agentPresetOf(row) const state = dot === 'ongoing' ? t('homeStatusOngoing') : dot === 'warning' ? t('homeStatusWarning') : dot === 'done' ? t('homeStatusDone') : undefined if (state !== undefined && preset !== undefined) return `${state} · ${preset}` return state ?? preset } /** * Phone home screen: the full-screen session list that owns the first level * of the page stack. Renders nothing at or above 768px — the tablet drawer * and the desktop layout stay exactly as they were. * * All data comes from the standard kit (`useSessions` / `useWorkspaces`) and * all navigation from the injected official actions; nothing here reads the * official DOM. */ export function MobileHome({ useSessions, useWorkspaces, useSessionPendingInteraction, useStore, actions, openSession, startSession, downloadSessionLog, archiveSession, t, }: MobileHomeProps) { const phone = usePhone() const iconHref = useSiteIconHref() const [iconBroken, setIconBroken] = useState(false) const view = useStore((s) => s.view) const pinned = useStore((s) => s.workspace) // Whole snapshots: both stores keep unchanged rows identity-stable, and the // list re-renders on any session change anyway (the running dots live here). const sessions = useSessions((s) => s) const workspaces = useWorkspaces((s) => s) const [sheet, setSheet] = useState<'filter' | 'chips' | null>(null) /** The one row whose archive action is swiped open (null = none). */ const [swipeOpen, setSwipeOpen] = useState(null) // 0.1.2 的待处理交互来源;0.1.1 没有这个标准 prop,读到 undefined 时用一个 // 恒返回空 Map 的替身,保证 hook 调用次数与顺序在两版之间一致(hooks 规则: // 真实 hook 只在 0.1.2 被调用,且每次渲染同一位置无条件调用一次)。 const pendingHook = useSessionPendingInteraction ?? EMPTY_PENDING_HOOK const pending = pendingHook((m) => m) /** Archive with the same confirm gate the session-info card uses. */ const onArchiveRow = (id: SessionId): void => { if (!window.confirm(t('infoArchiveConfirm'))) { setSwipeOpen(null); return } setSwipeOpen(null) // The store removes the row reactively (archivedSessionIds frame echo); // failures leave the row in place, which is the honest outcome. void archiveSession(id) } // 0.1.7: SessionListState.current is gone — the main-view session derives // from the catalog rows (mainSessionIdOf = official uiSession.publishMain's // pick). One read shared by the filter default, the highlight row, and the // two page-stack props below. const current = mainSessionIdOf(sessions.byId) // Workspace of the current session — the untouched filter default. const currentWorkspaceId = useMemo(() => { if (current === undefined) return undefined return workspaces.items.find((item) => item.sessionIds.includes(current))?.workspaceId }, [current, workspaces.items]) const selected: WorkspaceFilter = pinned ?? currentWorkspaceId ?? 'all' const selectedWorkspace = selected === 'all' ? undefined : workspaces.items.find((item) => item.workspaceId === selected) const rows = useMemo(() => { const archived = new Set(workspaces.archivedSessionIds) const scope = selectedWorkspace === undefined ? null : new Set(selectedWorkspace.sessionIds) return sessions.ids .flatMap((id) => { const row = sessions.byId[id] return row === undefined ? [] : [row] }) // Blank sessions are the New Session placeholders the official sidebar // hides too; subagents belong to their parent's session view, excluded // by their durable origin. Fork children also carry parentId but no // origin — they must stay listed, so parentage is never a filter here // (issue #9: forked branches were invisible on the phone). .filter((row) => !row.blank && row.origin !== 'subagent') .filter((row) => !archived.has(row.id)) .filter((row) => scope === null || scope.has(row.id)) // A session that never completed a single turn is not established yet — // creating one from the FAB and backing out leaves these behind, and // they pile up as basename-titled rows (user report, 2026-08-25). The // host's sessionStats projection rides every list row; `turns` counts // turns with at least one CLOSED step, and cancelled steps count too, // so an interrupted first reply still shows. Keep when: it is the // current session (highlight row), it is running (first reply in // flight), it earned a title, or the projection is absent (no signal — // show rather than silently hide). .filter((row) => { if (row.id === current || row.running || row.title !== undefined) return true const stats = (row.projectionValues as { sessionStats?: { turns?: number } } | undefined)?.sessionStats return stats?.turns === undefined || stats.turns > 0 }) .sort((a, b) => b.updatedAt - a.updatedAt) }, [sessions.ids, sessions.byId, current, workspaces.archivedSessionIds, selectedWorkspace]) // The session header's back button (session scope) cannot hold this // store directly — a handle mounts under exactly one scope, and this one // is already root-scoped here (see nav-store.ts) — so it dispatches // GO_HOME_EVENT instead and this, the store's actual owner, applies it. // // Both directions go through the history layer (history-nav.ts) so // Android's system back gesture lands on the session→list step instead of // exiting the PWA. GO_HOME_EVENT rewinds rather than setting the store: the // store is only ever moved home by the layer's own close callback, so the // history stack and the page stack cannot drift apart. The direct // `show('home')` fallback covers a session view that was somehow entered // without a layer (a restored session, a host that blocks pushState). useEffect(() => { const onGoHome = (): void => { if (hasLayer(SESSION_LAYER)) popLayer(SESSION_LAYER) else actions.show('home') } window.addEventListener(GO_HOME_EVENT, onGoHome) return () => window.removeEventListener(GO_HOME_EVENT, onGoHome) }, [actions]) // Mirror the current phone view onto the AppFrame element itself so CSS inside // the main column (session header hide + fade strip gate) can key off a direct // ancestor attribute instead of relying solely on cross-subtree body:has(...) // invalidation on iOS WebKit (especially across installViewportHeal's display // toggle on standalone PWA launch). useLayoutEffect(() => { const frame = document.querySelector('[data-mobile-nav="frame"]') ?? document.querySelector('[data-shell-overlay]')?.parentElement ?? null if (!phone || frame === null) { frame?.removeAttribute('data-mobile-view') return } frame.setAttribute('data-mobile-view', view) return () => frame.removeAttribute('data-mobile-view') }, [phone, view]) const enter = (start: () => void): void => { start() setSheet(null) actions.show('session') pushLayer({ id: SESSION_LAYER, close: () => actions.show('home') }) } // Opens the OFFICIAL settings modal (dsh-client-ui-settings-general's // SettingsRoot) by clicking its real trigger — its "open" state is // component-local React state with no public setter, same reasoning as // MobileSessionHeader's Chat/Trajectory tab click. The trigger lives in the // sidebar's `sidebar.settings` footer slot (`[class$="_settingsArea"]`, the // sidebar shell's stable class suffix — see dsh-client-ui-sidebar's // SidebarRoot), which is display:none on the phone breakpoint // (home.css.ts): `.click()` still dispatches (bypasses hit-testing, S2.1 // precedent) and flips `open`. // // DSH 0.2.1-rc.2 moved the entry: that slot now holds only the ACCOUNT // launcher (aria-haspopup="menu"), and Settings is an item inside its menu // — the `button[aria-haspopup="dialog"]` this used to forward to is gone // (and the resulting dialog is a body portal now, so the chips.css.ts // ancestor un-hide below no longer has anything to reveal). Hence the // menu fallback; both paths keep the label-free, structure-free // "dispatch a click on the official control" approach. const openSettings = (): void => { const area = document.querySelector('[class$="_settingsArea"]') // Older DSH: the settings modal's own trigger sits in the settings area. const direct = area?.querySelector('button[aria-haspopup="dialog"]') if (direct) { direct.click() return } // DSH 0.2.1-rc.2: the sidebar footer's settings area now holds only the // ACCOUNT launcher (aria-haspopup="menu", data-signed-out), and 设置 / // Settings is an item inside its menu — the dialog trigger this used to // forward to no longer exists there, so the bare // querySelector()?.click() silently no-oped (real-phone report // 2026-10-21: "手机端右上角设置点了没反应"). Open the menu, then click the // settings item. // // Two constraints from review: (1) menus are body portals and a sibling // menu (session actions, model picker) may already be mounted, so the // item must come from the menu THIS click opened — snapshot the existing // ones and pick the newly mounted menu; (2) the item is located without // depending on its label, because the account menu's copy is localized: // 设置 / Settings is the FIRST menuitem in both the signed-in and the // signed-out variant (AccountMenu items: settings, contact, signin/out), // so the first item is the locale-independent anchor, with the label as // a refinement if that order ever changes. const launcher = area?.querySelector('button[aria-haspopup="menu"], button') if (!launcher) return const before = new Set(document.querySelectorAll('[role="menu"]')) launcher.click() const pick = (): boolean => { const menu = [...document.querySelectorAll('[role="menu"]')].find((el) => !before.has(el)) if (!menu) return false const items = [...menu.querySelectorAll('button[role="menuitem"]')] const item = items.find((el) => /设置|Settings/i.test(el.textContent || '')) ?? items[0] if (!item) return false item.click() return true } // React commits the menu in the click's own task; observe for the mount // (a deferred sibling launcher cannot leave a stale poll behind) and give // up instead of leaking the observer if it never appears. if (pick()) return const observer = new MutationObserver(() => { if (pick()) observer.disconnect() }) observer.observe(document.body, { childList: true, subtree: true }) setTimeout(() => observer.disconnect(), 1000) } if (!phone) return null const title = selectedWorkspace?.title ?? t('allWorkspaces') return ( <> {/* Hero (new blank session) renders no conversation.session.header, so the * header-slot back button doesn't exist there. This floating fallback is * hidden by CSS whenever the real header back is mounted. */} {view === 'session' && ( )}
    {iconHref !== undefined && !iconBroken && ( setIconBroken(true)} /> )}
    downloadSessionLog(id as SessionId)} onCustomize={() => setSheet('chips')} />
      {rows.map((row) => { const dot = dotState(row, pending.has(row.id)) const status = statusLine(row, dot, t) const initial = row.displayTitle.trim().charAt(0).toUpperCase() return ( setSwipeOpen(open ? row.id : null)} onArchive={() => onArchiveRow(row.id)} archiveLabel={t('infoArchive')} > {(guardClick) => ( )} ) })} {rows.length === 0 &&
    • {t('noSessions')}
    • }
    {sheet !== null && (
    setSheet(null)} /> {/* Two sheets share this one container (chrome + S6 drag-to-close * + mask-click-close, all keyed off data-mobile-nav="home-sheet" * generically — effects/gestures.ts): the workspace switcher * (role="menu", unchanged) and the S5 chip-customize toggle list * (MobileHomeChipsSheetBody, no menu semantics — its rows are * switches, not menu items that close the sheet on click). */}
    {sheet === 'filter' && ( <>
    {t('switchWorkspace')}
    {workspaces.items.map((item) => ( ))} )} {sheet === 'chips' && }
    )}
    ) }