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();