From 3f4f09bfff74c149f1dc2bf98cfcbbdf6e6e7500 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:40:36 -0700 Subject: [PATCH] fix(v2): a phone first login lands in the workspace thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 390 a new account stopped on the pods list: the auto-select effect returns early on `phone` (the #1578 direction-C rule, "the phone never redirects — /v2 is the pods list page there"), so the workspace thread with Scout's welcome and the composer was one tap away and easy to miss. 18 of 29 recent signups never sent a first message. The list is the right landing for a phone that has a pod to return to, so that stays. It is the wrong landing for a device that has never opened one, which is every first login — there the list has nothing to return to. So the phone runs the same resolution (own invite-only workspace, else pods[0]) for that one case, with `replace`. The redirect is self-limiting: it lands on the param shell, whose paramPodId effect writes LAST_POD_KEY, so the next /v2 finds the key set and stays on the list, and `replace` means browser-back does not re-resolve it either. No new screen, no toast, no banner; the first-run hero keeps rendering where it does. Two jsdom cases on V2Layout with the phone media query mocked (both named in ux-lead's ruling), plus the pre-existing "phone /v2 is the pods list page" case for the device that does have history. Mutation-proven three ways: restoring the early return reds both new cases, ignoring the device reds the pre-existing one, and dropping `replace` reds the back-step case. --- .../v2/__tests__/V2LayoutSelection.test.tsx | 90 ++++++++++++++++++- frontend/src/v2/components/V2Layout.tsx | 18 +++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx index 9ca8bd453d..eb51c83c43 100644 --- a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx +++ b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { - MemoryRouter, Route, Routes, useLocation, + MemoryRouter, Route, Routes, useLocation, useNavigate, } from 'react-router-dom'; import V2Layout from '../components/V2Layout'; import type { V2Pod } from '../hooks/useV2Pods'; @@ -52,6 +52,31 @@ const CurrentPath = () => { return {location.pathname}; }; +// A path probe that can also step back through the router's own history, which +// is the only way jsdom can tell a `replace` from a `push`. +const CurrentPathWithBack = () => { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + {location.pathname} + + + ); +}; + +const usePhoneViewport = () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: jest.fn((query: string) => ({ + matches: query === '(max-width: 760px)', + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })), + }); +}; + const hqPod: V2Pod = { _id: 'hq', name: 'Commonly HQ', @@ -86,6 +111,21 @@ const renderAutoLayout = () => render( , ); +// The app's real shell: `/v2` mounts the auto selector, and the pod route mounts +// the param shell (V2App.tsx:127 / :165), which is the instance that records the +// visit. A test that only mounts the auto layout cannot see LAST_POD_KEY at all. +const renderAutoIntoParamShell = () => render( + + + } /> + } + /> + + , +); + describe('V2Layout default pod selection', () => { beforeEach(() => { jest.clearAllMocks(); @@ -119,6 +159,54 @@ describe('V2Layout default pod selection', () => { expect(screen.queryByRole('button', { name: 'toggle inspector' })).not.toBeInTheDocument(); }); + test('a phone first login lands in the workspace thread, and the list is where you come back to', async () => { + // TASK-144. The list is the right landing for a phone that has a pod to + // return to; a device that has never opened one has nothing to list, so the + // welcome thread is the first screen. The same redirect is what records the + // pod — which is why the back arrow lands on a list that then stays put. + usePhoneViewport(); + expect(localStorage.getItem('v2:lastPodId')).toBeNull(); + + const first = renderAutoIntoParamShell(); + await waitFor(() => { + expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/workspace'); + }); + // Written by the param shell that the redirect lands on — the same effect + // that runs when a returning user taps a pod. + await waitFor(() => expect(localStorage.getItem('v2:lastPodId')).toBe('workspace')); + first.unmount(); + + // ...and the next /v2 on this device is the list, with no second redirect. + renderAutoLayout(); + await waitFor(() => { + expect(screen.getByTestId('pods-sidebar')).toHaveAttribute('data-variant', 'page'); + }); + expect(screen.queryByTestId('current-path')).not.toBeInTheDocument(); + }); + + test('a phone first login replaces, so back does not bounce through the list', async () => { + usePhoneViewport(); + render( + + + } /> + } + /> + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/workspace'); + }); + fireEvent.click(screen.getByRole('button', { name: 'back' })); + // A push would leave /v2 underneath, so one step back would re-resolve the + // landing; a replace leaves nothing to step back into. + expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/workspace'); + }); + test('keeps the create-pod query on /v2 until the sidebar finishes the round trip', async () => { render( diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx index 942fd8b504..f80cda8bd5 100644 --- a/frontend/src/v2/components/V2Layout.tsx +++ b/frontend/src/v2/components/V2Layout.tsx @@ -147,11 +147,21 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { recordPodVisit(paramPodId); }, [paramPodId]); - // Desktop only: resume the last valid pod. A new user without history lands - // in their self-created invite-only workspace rather than the auto-joined HQ. - // The phone never redirects: `/v2` is the pods list page there. + // Resume the last valid pod; a new user without history lands in their + // self-created invite-only workspace rather than the auto-joined HQ. + // + // The phone normally stays on the list — `/v2` IS the pods list page there + // (#1578 direction C) — and that stays true for anyone who has opened a pod on + // this device. The exception is a device that has never opened one, which is + // every first login: the list has nothing to return to, and the workspace + // thread with its welcome and composer is the actual first screen. So the same + // resolution runs there too, exactly once. The redirect is self-limiting: it + // sets LAST_POD_KEY (the paramPodId effect above), so the next `/v2` — the + // thread's back arrow — finds the key set and stays on the list, and the + // redirect is a `replace`, so browser-back does not bounce through it either. useEffect(() => { - if (selectionMode !== 'auto' || paramPodId || loading || phone || createFromConnectors) return; + if (selectionMode !== 'auto' || paramPodId || loading || createFromConnectors) return; + if (phone && readLastPodId()) return; if (pods.length === 0) return; const lastPodId = readLastPodId();