Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Mobile-Web Resume Card 错位与重开卡在配对页设计

Date: 2026-07-22
Scope: `src/mobile-web`(`PairingPage.tsx`, `sessions.scss`);合同测试可落在 `src/web-ui` 既有 mobile source contract 套件

## 背景

1. 会话列表顶部「继续上次会话」卡片文案居中、meta 左对齐,视觉错位。
2. 关闭 mobile-web 后再打开,页面长期停在「正在连接并配对...」转圈,无法进入会话或重试。

## 根因

### A. Resume Card 错位

- 卡片是 `<button>`,浏览器默认 `text-align: center`。
- 同页 `.session-list__create-btn` 已显式 `text-align: left`,resume card 漏写。
- 标签/标题继承居中;`.session-list__resume-meta` 为 flex 仍靠左 → 与截图一致。

### B. 重开卡在配对

`PairingPage` 自动重连 effect 依赖 `attemptPair`。首次挂载会:

1. `setConnectionStatus('pairing')` 并启动 `attemptPair`
2. `setMobileInstallId(...)` 触发重渲染 → `attemptPair` 引用变化(deps 含 `mobileInstallId`)
3. effect 再次执行:再次 `setConnectionStatus('pairing')` + `setError(null)`,但 `autoReconnectAttemptedRef` 已为 true → **不再发起配对**

若第一次 `attemptPair` 已失败并写入 `error`,步骤 3 会把状态打回 `pairing` 且清空错误,表单不显示 → 永久转圈。

快速失败(离线、二维码过期 404、身份校验拒绝)最容易踩中该竞态。

## 方案

### A. Resume Card UI(方案 A:左对齐横向卡片)

对齐 `.session-list__create-btn`:

- `.session-list__resume-card`:`width: 100%`、`text-align: left`、补齐 button 文本色继承
- `.session-list__resume-body`:`display: flex; flex-direction: column`,稳定纵向节奏
- 不改 DOM 结构(图标 | 文案栈 | 箭头)

### B. 自动重连生命周期

1. **挂载一次性 bootstrap**:用 `attemptPairRef` 持有最新 `attemptPair`;bootstrap effect 仅运行一次(或用 `bootstrappedRef` 防重入),不再把 `attemptPair` 放进依赖导致状态回滚。
2. **仅在真正发起配对时设 `pairing`**:bootstrap / 手动连接进入尝试时设置;禁止「空转」地把状态重置为 `pairing`。
3. **去掉 `mobileInstallId` 对 `attemptPair` 的依赖**:installId 已通过 `options.installId` / `getOrCreateInstallId()` 传入。
4. **世代/挂载守卫**:async 返回后若组件已卸载或已被更新一代配对取代,不再写 store / 调 `onPaired`,避免 StrictMode 或重复尝试污染 UI。
5. **失败必须可恢复**:失败后保持 `connectionStatus === 'error'` 并展示表单 + 错误文案,允许手动重试。

## 非目标

- 不在此任务持久化账号密码或扩展账号模式无密码自动重连(既有产品规则:`auth=account` 禁止无密码自动重连)。
- 不把 pairing target(room/pk)迁入 localStorage(仍依赖扫码 URL hash)。

## 验证

- `pnpm --dir src/mobile-web run type-check`
- `pnpm run build:mobile-web`
- 合同测试:断言 PairingPage 不再在依赖 `attemptPair` 的 effect 里无条件 `setConnectionStatus('pairing')`;存在 mount-once / ref 引导的自动重连路径
- 手工:
1. 非账号模式配对成功 → 关页再开:应自动配对进入会话,或失败后显示表单而非永转圈
2. Resume card:标签/标题/meta 左对齐,与图标同一阅读轴
3. 账号模式(`auth=account`):重开仍显示密码表单,不自动转圈
83 changes: 59 additions & 24 deletions src/mobile-web/src/pages/PairingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,22 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
} = useMobileStore();
const [userId, setUserId] = useState('');
const [password, setPassword] = useState('');
const [mobileInstallId, setMobileInstallId] = useState('');
const [submitting, setSubmitting] = useState(false);
const [failureCount, setFailureCount] = useState(0);
const [lockUntil, setLockUntil] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now());
const autoReconnectAttemptedRef = useRef(false);
const failureCountRef = useRef(0);
const lockUntilRef = useRef<number | null>(null);
// Generation token so a superseded or unmounted pairing attempt cannot
// overwrite UI after a later bootstrap/manual attempt owns the page.
const pairAttemptGenerationRef = useRef(0);
const attemptPairRef = useRef<(
providedUserId: string,
providedPassword: string,
options?: { autoReconnect?: boolean; installId?: string },
) => Promise<void>>(async () => {});
const onPairedRef = useRef(onPaired);
onPairedRef.current = onPaired;

const pairingTarget = useMemo(() => resolvePairingTarget(), []);
const requiresAccountAuth = pairingTarget.accountAuth;
Expand All @@ -162,54 +170,66 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
// trailing spaces exactly as entered.
const passwordValue = providedPassword;
const autoReconnect = options?.autoReconnect === true;
const currentInstallId = options?.installId || mobileInstallId || getOrCreateInstallId();
// Prefer the explicit installId from the caller; fall back to the stable
// localStorage-backed id. Do not close over React state here — that used
// to recreate this callback and re-trigger bootstrap side effects.
const currentInstallId = options?.installId || getOrCreateInstallId();
const activeLockUntil = lockUntilRef.current;
const lockActive = !!activeLockUntil && activeLockUntil > Date.now();
const currentRemainingLockSeconds = lockActive
? Math.max(1, Math.ceil((activeLockUntil - Date.now()) / 1000))
: 0;
const attemptGeneration = ++pairAttemptGenerationRef.current;
const isCurrentAttempt = () => pairAttemptGenerationRef.current === attemptGeneration;

if (!roomId
|| !desktopPublicKey
|| !validPairingSecret(roomId, desktopPublicKey)
|| !pairingTarget.httpBaseUrl) {
if (!isCurrentAttempt()) return;
setError(t('pairing.invalidQrCode'));
setConnectionStatus('error');
return;
}
if (!userIdValue) {
if (!isCurrentAttempt()) return;
setError(requiresAccountAuth ? t('pairing.usernameRequired') : t('pairing.userIdRequired'));
setConnectionStatus('error');
return;
}
if (userIdValue.length > 128 || passwordValue.length > 1024) {
if (!isCurrentAttempt()) return;
setError(t('pairing.fieldsTooLong'));
setConnectionStatus('error');
return;
}
if (requiresAccountAuth && !passwordValue) {
if (!isCurrentAttempt()) return;
setError(t('pairing.passwordRequired'));
setConnectionStatus('error');
return;
}
if (!autoReconnect && lockActive) {
if (!isCurrentAttempt()) return;
setError(t('pairing.tooManyAttempts', { seconds: currentRemainingLockSeconds }));
setConnectionStatus('error');
return;
}

setMobileInstallId(currentInstallId);
setSubmitting(true);
setError(null);
setConnectionStatus('pairing');

const client = new RelayHttpClient(pairingTarget.httpBaseUrl, roomId);

try {
setError(null);
setConnectionStatus('pairing');
const initialSync = await client.pair(desktopPublicKey, {
userId: userIdValue,
mobileInstallId: currentInstallId,
password: requiresAccountAuth ? passwordValue : undefined,
});
if (!isCurrentAttempt()) return;

setConnectionStatus('paired');
localStorage.setItem(MOBILE_USER_ID_KEY, userIdValue);
localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY);
Expand Down Expand Up @@ -259,6 +279,7 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
window.setTimeout(() => resolve(false), 10_000);
}),
]);
if (!isCurrentAttempt()) return;
const homeDeviceId = client.homeDeviceId;
if (delegated && homeDeviceId) {
store.setControlTarget({ deviceId: homeDeviceId, deviceName: null, isHome: true });
Expand Down Expand Up @@ -290,8 +311,10 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
// single-device pairing; continue without device switching.
}

onPaired(client, sessionMgr);
if (!isCurrentAttempt()) return;
onPairedRef.current(client, sessionMgr);
} catch (e: any) {
if (!isCurrentAttempt()) return;
const rawErrorMessage = e?.message || '';
const errorMessage = rawErrorMessage.includes('timed out')
? t('pairing.requestTimedOut')
Expand Down Expand Up @@ -326,10 +349,11 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
}
setConnectionStatus('error');
} finally {
setSubmitting(false);
if (isCurrentAttempt()) {
setSubmitting(false);
}
}
}, [
mobileInstallId,
pairingTarget.httpBaseUrl,
pairingTarget.pk,
pairingTarget.room,
Expand All @@ -340,6 +364,12 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
t,
]);

attemptPairRef.current = attemptPair;

// Mount-once bootstrap: restore form fields and optionally auto-reconnect.
// Must NOT depend on `attemptPair` identity — a later callback recreation
// used to reset status to `pairing` without starting a new request, which
// left the page spinning forever after a fast reconnect failure.
useEffect(() => {
const savedUserId = localStorage.getItem(MOBILE_USER_ID_KEY)?.trim() ?? '';
const qrUsername = pairingTarget.accountUsername?.trim() ?? '';
Expand All @@ -359,24 +389,30 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
&& !!pairingTarget.room
&& !!pairingTarget.pk;
setUserId(prefilledUserId);
setMobileInstallId(currentInstallId);
setFailureCount(normalizedLockUntil ? persistedFailureCount : 0);
setLockUntil(normalizedLockUntil);
setConnectionStatus(shouldAutoReconnect ? 'pairing' : 'idle');
setError(null);
if (shouldAutoReconnect && !autoReconnectAttemptedRef.current) {
autoReconnectAttemptedRef.current = true;
void attemptPair(savedUserId, '', { autoReconnect: true, installId: currentInstallId });

if (shouldAutoReconnect) {
// Show the spinner immediately; attemptPair also sets pairing when the
// network attempt actually starts (after validation).
setConnectionStatus('pairing');
void attemptPairRef.current(savedUserId, '', {
autoReconnect: true,
installId: currentInstallId,
});
} else {
setConnectionStatus('idle');
}
}, [
attemptPair,
pairingTarget.accountUsername,
pairingTarget.pk,
pairingTarget.room,
requiresAccountAuth,
setConnectionStatus,
setError,
]);

return () => {
// Invalidate in-flight pairing so unmount / StrictMode remount cannot
// apply stale success/error onto the next page instance.
pairAttemptGenerationRef.current += 1;
};
// pairingTarget is resolved once from the URL hash on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-once bootstrap
}, []);

useEffect(() => {
failureCountRef.current = failureCount;
Expand Down Expand Up @@ -406,7 +442,6 @@ const PairingPage: React.FC<PairingPageProps> = ({ onPaired }) => {
}, [lockUntil]);

const handleConnect = async () => {
autoReconnectAttemptedRef.current = true;
await attemptPair(userId, password, { autoReconnect: false });
};

Expand Down
12 changes: 10 additions & 2 deletions src/mobile-web/src/styles/components/sessions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,14 @@
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1.5px solid var(--color-accent-200);
@include squircle(18px);
background: var(--color-accent-50);
color: var(--color-text-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: transform var(--motion-fast) var(--easing-standard),
background var(--motion-fast) var(--easing-standard);
Expand Down Expand Up @@ -224,14 +228,18 @@
.session-list__resume-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
text-align: left;
}

.session-list__resume-label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-accent-500);
margin-bottom: 2px;
}

.session-list__resume-name {
Expand All @@ -247,7 +255,7 @@
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
margin-top: 2px;
min-width: 0;
overflow: hidden;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,17 @@ describe('mobile control-target UI ownership contracts', () => {
expect(pairingSource).toContain('!client.isControlTargetCurrent(target)');
expect(pairingSource).toContain('client.pairedDeviceId !== homeDeviceId');
});

it('bootstraps pairing auto-reconnect once without resetting to a stuck spinner', () => {
expect(pairingSource).toContain('attemptPairRef.current');
expect(pairingSource).toContain('pairAttemptGenerationRef');
expect(pairingSource).toContain('mount-once bootstrap');
expect(pairingSource).toContain('eslint-disable-next-line react-hooks/exhaustive-deps -- mount-once bootstrap');
// Regression: depending on attemptPair and unconditionally setting pairing
// after a failed reconnect left the page spinning with no retry form.
expect(pairingSource).not.toContain('autoReconnectAttemptedRef');
expect(pairingSource).not.toMatch(
/setConnectionStatus\(shouldAutoReconnect \? 'pairing' : 'idle'\)/,
);
});
});
Loading