Просмотр исходного кода

Merge pull request #3969 from deepseek-harness/refine/connecting-ui

feat(sidebar): refine the connection indicator states and interaction
Yifffan 1 неделя назад
Родитель
Сommit
ef39ebd0bb

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md
+2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b
+2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5

+ 29 - 0
.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md

@@ -0,0 +1,29 @@
+# Agent Note: Connection indicator state and interaction refinements
+
+Status: implemented
+
+English | [中文](2026-09-10-connection-indicator-refinements.zh.md)
+
+## Problem
+
+The sidebar connection pill hid its affordance behind a hover swap: outage and retry-attempt states replaced their label with **Reconnect now** on hover or focus, so every state had to reserve the widest supplied label to keep the control from resizing. A retry that resolved in under a second flickered the connecting pill in and out, and state changes and unmounts jumped with no transition.
+
+## Decision
+
+**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha.
+
+**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration.
+
+## Alternatives considered
+
+**Animating width changes.** A FLIP-style measured pixel transition (remember the old width, pin it, transition to the new measurement) needs a layout effect and imperative style writes; the fade-only change reads calm enough without them.
+
+**Swapping to the retry glyph only on hover.** Showing the retry glyph permanently states the affordance without requiring any pointer interaction, matching the static label; a hover cross-fade adds interaction-dependent state and conveys nothing extra.
+
+**Scaling on enter/exit.** A 0.98 scale beside the opacity fades reads as jitter at 12px text, so only opacity animates.
+
+**Naming manual and automatic attempts differently.** `ConnectionController.emitState` deduplicates repeated `connecting` states across backoff attempts, so the shell cannot observe attempt boundaries: a shell-held manual-retry flag either flips the label mid-hold or sticks across later automatic attempts. Distinguishing the copy correctly requires the connection layer to expose the attempt origin, which this change does not need — both attempt kinds read the same label.
+
+## Consequences
+
+`ConnectionIndicator`'s `reconnectLabel` prop and its size-reservation spans are removed from the pre-stable API; the sole consumer (`ui-settings-general`) is updated in the same change. `settings-root.client.spec.tsx` pins the 800ms hold, the single attempt label held steady through the hold, and the visibility-based confirmation window; `atoms.client.spec.tsx` pins the exit-duration unmount; `lifecycle-chrome.e2e.ts` and its ARIA golden replay the recovery flow in a real browser. Both packages' READMEs restate the interaction.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 连接指示器状态与交互细化
+
+Status: implemented
+
+[English](2026-09-10-connection-indicator-refinements.md) | 中文
+
+## Problem
+
+侧边栏连接药丸把操作提示藏在悬停切换里:断连与重试状态在悬停或聚焦时把文案替换为**立即重连**,因此每个状态都要为最宽的 label 预留空间以避免控件变形。一次不到一秒就恢复的重试会让连接中药丸闪现闪没,状态切换和消失也没有任何过渡、十分突兀。
+
+## Decision
+
+**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。
+
+**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。
+
+## Alternatives considered
+
+**给宽度变化加动画。** FLIP 式的像素测量过渡(记住旧宽度、钉住、过渡到新测量值)需要一个 layout effect 和命令式样式写入;纯淡入淡出已足够平静,无需这些。
+
+**仅在悬停时切换为重试图形。** 常驻显示重试图形无需任何指针交互就说明了操作,与静态文案一致;悬停交叉渐变引入依赖交互的状态却不传达更多信息。
+
+**进出场缩放。** 0.98 的缩放叠加在透明度淡入淡出上,在 12px 文字上读起来像抖动,因此只保留透明度。
+
+**手动与自动尝试使用不同命名。** `ConnectionController.emitState` 会去重退避尝试之间重复的 `connecting` 状态,外壳观察不到尝试边界:外壳自持的手动重试标志要么在驻留中途翻转文案,要么在后续自动尝试中一直滞留。要正确区分文案需要连接层暴露尝试来源,而本次变更并不需要——两种尝试显示同一文案。
+
+## Consequences
+
+`ConnectionIndicator` 的 `reconnectLabel` prop 及其占位 span 从 pre-stable API 中移除;唯一消费者(`ui-settings-general`)在同一变更中更新。`settings-root.client.spec.tsx` 固定 800ms 驻留、驻留期间保持不变的单一尝试文案,以及按可见时刻起算的确认窗口;`atoms.client.spec.tsx` 固定退出时长后的卸载;`lifecycle-chrome.e2e.ts` 及其 ARIA golden 在真实浏览器中回放恢复流程。两个包的 README 重述了该交互。

+ 8 - 15
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -428,22 +428,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await recoveryPage.context().setOffline(false)
       await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true)
       const connecting = recoveryPage.getByRole('button', {
-        name: 'Reconnecting automatically, reconnect now', exact: true,
+        name: 'Reconnecting, reconnect now', exact: true,
       })
       await connecting.waitFor({ timeout: 10_000 })
       expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       const connectingGeometry = await connectionIndicatorGeometry(connecting)
       expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
-      // Animated dots must remain hidden with their state label during hover.
-      await connecting.evaluate((element) => {
-        for (const animation of element.getAnimations({ subtree: true })) {
-          if (!(animation instanceof CSSAnimation)) continue
-          animation.pause()
-          animation.currentTime = 1_250
-        }
-      })
+      // Hover keeps the state label; the pill never swaps copy or resizes.
       await connecting.hover()
-      expect(await connecting.innerText()).toBe('Reconnect now')
+      expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)
       await recoveryPage.mouse.move(0, 0)
 
@@ -461,7 +454,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       const indicator = connecting
       expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
       expect(await connectionIndicatorTextAlignment(indicator)).toBe('left')
-      await indicator.hover()
       const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE)
       const style = await indicator.evaluate((element) => {
@@ -498,11 +490,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await connecting.waitFor()
       await recoveryPage.clock.fastForward(500)
       await expect.poll(() => sockets.length).toBe(11)
-      const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
       await indicator.hover()
-      expect(await indicator.innerText()).toBe('Reconnect now')
+      expect(await indicator.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
-      expect(hoverBackground).toBe(idleBackground)
       await recoveryPage.mouse.down()
       await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor))
         .not.toBe(hoverBackground)
@@ -513,7 +503,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       const recovered = recoveryPage.getByRole('status')
       await recovered.waitFor({ timeout: 10_000 })
       expect(await recovered.innerText()).toBe('Connected')
-      expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry)
+      // The pill sizes to its current label; chrome height and icon box stay fixed.
+      const recoveredGeometry = await connectionIndicatorGeometry(recovered)
+      expect(recoveredGeometry.outer[3]).toBe(connectingGeometry.outer[3])
+      expect(recoveredGeometry.icon).toEqual(connectingGeometry.icon)
       expect(await connectionIndicatorTextAlignment(recovered)).toBe('left')
       await recoveryPage.clock.fastForward(2_000)
       await recovered.waitFor({ state: 'detached', timeout: 5_000 })

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 1d3e6c6d0774ed78bb0a1952ba57e5f607f24b47
-README.zh.md: e7f0419939076b1276586cfba28129b7df1ba355
+README.md: d22fa2eed35457c0599fd41473102b1aebe4bd2e
+README.zh.md: b53cf76dc8b8a253da83d1e94b6f9f1b5abf19cc

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/client/ui-primitives/README.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/client/ui-primitives/README.zh.md


+ 42 - 26
packages/client/ui-primitives/src/ConnectionIndicator.module.css

@@ -4,25 +4,49 @@
   grid-template-columns: 14px max-content;
   align-items: center;
   column-gap: 4px;
-  height: 32px;
-  padding: 0 10px;
+  height: 28px;
+  padding: 0 8px;
   box-sizing: border-box;
-  border: none;
-  border-radius: 8px;
+  border: 1px solid transparent;
+  border-radius: 13px;
   font-family: inherit;
   font-size: 12px;
   font-weight: 500;
   line-height: 18px;
   white-space: nowrap;
-  transition: background-color 160ms ease-out, color 160ms ease-out;
+  transition:
+    background-color 160ms ease-out,
+    color 160ms ease-out,
+    border-color 160ms ease-out,
+    opacity 150ms ease-out;
+  animation: indicator-enter 150ms ease-out;
+}
+
+.leaving {
+  opacity: 0;
+}
+
+@keyframes indicator-enter {
+  from {
+    opacity: 0;
+  }
 }
 
 .warning {
   background: var(--dsw-alias-state-warn-tertiary);
   color: var(--dsw-alias-state-warn-label);
+  border-color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 20%, transparent);
   cursor: pointer;
 }
 
+.warning:hover {
+  background: color-mix(
+    in srgb,
+    var(--dsw-alias-state-warn-tertiary),
+    var(--dsw-alias-state-warn-primary) 6%
+  );
+}
+
 .warning:active {
   background: color-mix(
     in srgb,
@@ -39,6 +63,7 @@
 .success {
   background: var(--dsw-alias-state-success-tertiary);
   color: var(--dsw-alias-state-success-primary);
+  border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent);
 }
 
 .icon {
@@ -49,35 +74,20 @@
 }
 
 .label {
-  display: grid;
   text-align: left;
 }
 
-.stateLabel,
-.hoverLabel,
-.sizeLabel {
-  grid-area: 1 / 1;
-}
-
-.sizeLabel {
-  visibility: hidden;
-}
-
-.warning:is(:hover, :focus-visible) .stateLabel {
-  visibility: hidden;
-}
-
-.hoverLabel {
-  visibility: hidden;
+.spinner {
+  animation: spinner-rotate 0.9s linear infinite;
 }
 
-.warning:is(:hover, :focus-visible) .hoverLabel {
-  visibility: visible;
+@keyframes spinner-rotate {
+  to { transform: rotate(360deg); }
 }
 
 .dots {
   display: inline-block;
-  width: 1.5em;
+  width: 1em;
   text-align: left;
 }
 
@@ -100,8 +110,14 @@
 }
 
 @media (prefers-reduced-motion: reduce) {
+  .indicator,
   .secondDot,
-  .thirdDot {
+  .thirdDot,
+  .spinner {
     animation: none;
   }
+
+  .indicator {
+    transition: none;
+  }
 }

+ 51 - 43
packages/client/ui-primitives/src/ConnectionIndicator.tsx

@@ -1,4 +1,5 @@
-import { IconCheckOutline16, IconWarningOutline16 } from './icons/index.tsx'
+import { useEffect, useState } from 'react'
+import { IconCheckOutline16, IconLoadingOutline16, IconRefreshOutline14 } from './icons/index.tsx'
 import css from './ConnectionIndicator.module.css'
 
 /** Visual state rendered by {@link ConnectionIndicator}. */
@@ -7,11 +8,16 @@ export type ConnectionIndicatorState =
   | 'connecting'
   | 'recovered'
 
+/** Exit-transition length; keep equal to the `.leaving` transition duration in the stylesheet. */
+const EXIT_MS = 150
+
 /**
- * Render an inline connection-recovery control.
+ * Render an inline connection-recovery control. The outage and retry-attempt
+ * states are one button whose static label already names the retry action;
+ * clicking it requests an immediate reconnect. The indicator animates in on
+ * appearance and fades out for {@link EXIT_MS} before unmounting.
  * @param props.state - visible outage, retry-attempt, or recovered state.
- * @param props.disconnectedLabel - localized outage text.
- * @param props.reconnectLabel - localized action text shown on hover or focus.
+ * @param props.disconnectedLabel - localized outage text naming the retry action.
  * @param props.connectingLabel - localized retry text followed by the attempt dots.
  * @param props.recoveredLabel - localized recovery confirmation.
  * @param props.reconnectActionLabel - accessible label for the outage action.
@@ -22,7 +28,6 @@ export type ConnectionIndicatorState =
 export function ConnectionIndicator({
   state,
   disconnectedLabel,
-  reconnectLabel,
   connectingLabel,
   recoveredLabel,
   reconnectActionLabel,
@@ -31,63 +36,66 @@ export function ConnectionIndicator({
 }: {
   state: ConnectionIndicatorState | undefined
   disconnectedLabel: string
-  reconnectLabel: string
   connectingLabel: string
   recoveredLabel: string
   reconnectActionLabel: string
   restartActionLabel: string
   onReconnect: () => void
 }) {
-  if (state === undefined) return null
-  const sizeLabels = (
-    <>
-      <span className={css.sizeLabel} aria-hidden="true">{disconnectedLabel}</span>
-      <span className={css.sizeLabel} aria-hidden="true">{reconnectLabel}</span>
-      <span className={css.sizeLabel} aria-hidden="true">
-        {connectingLabel}<span className={css.dots}>...</span>
-      </span>
-      <span className={css.sizeLabel} aria-hidden="true">{recoveredLabel}</span>
-    </>
-  )
-  if (state === 'recovered') {
+  const [rendered, setRendered] = useState(state)
+  const leaving = state === undefined && rendered !== undefined
+  useEffect(() => {
+    if (state !== undefined) {
+      setRendered(state)
+      return
+    }
+    if (rendered === undefined) return
+    const timeout = window.setTimeout(() => { setRendered(undefined) }, EXIT_MS)
+    return () => { window.clearTimeout(timeout) }
+  }, [state, rendered])
+
+  if (rendered === undefined) return null
+  const leavingClass = leaving ? ` ${css.leaving}` : ''
+  if (rendered === 'recovered') {
     return (
-      <div className={`${css.indicator} ${css.success}`} role="status" aria-label={recoveredLabel}>
+      <div
+        className={`${css.indicator} ${css.success}${leavingClass}`}
+        role="status"
+        aria-label={recoveredLabel}
+      >
         <span className={css.icon} aria-hidden="true"><IconCheckOutline16 size={14} /></span>
-        <span className={css.label}>
-          {sizeLabels}
-          <span className={css.stateLabel}>{recoveredLabel}</span>
-        </span>
+        <span className={css.label}>{recoveredLabel}</span>
       </div>
     )
   }
 
-  const connecting = state === 'connecting'
+  const connecting = rendered === 'connecting'
   return (
     <button
       type="button"
-      className={`${css.indicator} ${css.warning}`}
-      data-phase={state}
+      className={`${css.indicator} ${css.warning}${leavingClass}`}
+      data-phase={rendered}
       aria-label={connecting ? restartActionLabel : reconnectActionLabel}
       onClick={onReconnect}
     >
-      <span className={css.icon} aria-hidden="true"><IconWarningOutline16 size={14} /></span>
+      <span className={css.icon} aria-hidden="true">
+        {connecting
+          ? <IconLoadingOutline16 size={14} className={css.spinner} />
+          : <IconRefreshOutline14 size={14} />}
+      </span>
       <span className={css.label}>
-        {sizeLabels}
-        <span className={css.stateLabel}>
-          {connecting
-            ? (
-              <>
-                {connectingLabel}
-                <span className={css.dots} aria-hidden="true">
-                  <span>.</span>
-                  <span className={css.secondDot}>.</span>
-                  <span className={css.thirdDot}>.</span>
-                </span>
-              </>
-            )
-            : disconnectedLabel}
-        </span>
-        <span className={css.hoverLabel}>{reconnectLabel}</span>
+        {connecting
+          ? (
+            <>
+              {connectingLabel}
+              <span className={css.dots} aria-hidden="true">
+                <span>.</span>
+                <span className={css.secondDot}>.</span>
+                <span className={css.thirdDot}>.</span>
+              </span>
+            </>
+          )
+          : disconnectedLabel}
       </span>
     </button>
   )

+ 25 - 4
packages/client/ui-primitives/tests/atoms.client.spec.tsx

@@ -462,8 +462,7 @@ describe('ConnectionIndicator', () => {
   it('renders outage, attempt progress, and recovered states without a native tooltip', () => {
     const reconnect = vi.fn()
     const labels = {
-      disconnectedLabel: 'Disconnected',
-      reconnectLabel: 'Reconnect',
+      disconnectedLabel: 'Disconnected, retry',
       connectingLabel: 'Connecting',
       recoveredLabel: 'Connected',
       reconnectActionLabel: 'Disconnected, reconnect now',
@@ -476,8 +475,7 @@ describe('ConnectionIndicator', () => {
     expect(container.firstChild).toBeNull()
     rerender(<ConnectionIndicator state="disconnected" {...labels} />)
     const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' })
-    expect(indicator.textContent).toContain('Disconnected')
-    expect(indicator.textContent).toContain('Reconnect')
+    expect(indicator.textContent).toContain('Disconnected, retry')
     expect(indicator.hasAttribute('title')).toBe(false)
     expect(indicator.querySelector('svg')).toBeTruthy()
     fireEvent.click(indicator)
@@ -491,4 +489,27 @@ describe('ConnectionIndicator', () => {
     expect(screen.queryByRole('button')).toBeNull()
     expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
   })
+
+  it('fades out for the exit duration before unmounting', () => {
+    vi.useFakeTimers()
+    try {
+      const labels = {
+        disconnectedLabel: 'Disconnected, retry',
+        connectingLabel: 'Connecting',
+        recoveredLabel: 'Connected',
+        reconnectActionLabel: 'Disconnected, reconnect now',
+        restartActionLabel: 'Connecting, restart now',
+        onReconnect: vi.fn(),
+      }
+      const { container, rerender } = render(
+        <ConnectionIndicator state="disconnected" {...labels} />,
+      )
+      rerender(<ConnectionIndicator state={undefined} {...labels} />)
+      expect(screen.getByRole('button', { name: 'Disconnected, reconnect now' })).toBeTruthy()
+      act(() => { vi.advanceTimersByTime(150) })
+      expect(container.firstChild).toBeNull()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
 })

+ 2 - 2
packages/client/ui-settings-general/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
-README.md: 845d9c48dce2264d478f0ac854ef85a480828a14
-README.zh.md: 814082f94156e44b45e015e67acb7a281ba2e7e9
+README.md: af68c42ad111f74e68037436e18a5be57195a64b
+README.zh.md: f929df66a13cde7beb63721922f2a3f4aa90ba43

+ 2 - 2
packages/client/ui-settings-general/README.md

@@ -25,7 +25,7 @@ Use this package to give the dsh web client a Settings panel, connection-recover
 <a id="use-this-package"></a>
 ## Use this package
 
-Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension. Automatic recovery shows **Reconnecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
+Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows a spinner beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
 
 ### The General section
 
@@ -55,7 +55,7 @@ The navigation is a projection of the `settings.section` ledger; nav labels may
 
 ### Connection recovery
 
-The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the two-second recovered-state timer.
+The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the 800ms minimum-visible hold for the connecting state and the two-second recovered-state timer, which starts when the recovered pill becomes visible after the hold.
 
 ### Document availability
 

+ 2 - 2
packages/client/ui-settings-general/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;自动恢复期间显示**自动重连中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
+用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示 spinner 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
 
 ### 「通用」分区
 
@@ -55,7 +55,7 @@ kind: "package-reference"
 
 ### 连接恢复
 
-外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;2 秒恢复状态计时器归外壳所有
+外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;连接中状态的 800ms 最短可见驻留与 2 秒恢复确认计时器归外壳所有;恢复计时从驻留结束、恢复药丸实际可见时开始
 
 ### 文档可用性
 

+ 32 - 4
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -23,6 +23,9 @@ import css from './SettingsRoot.module.css'
 
 const RECOVERY_CONFIRMATION_MS = 2_000
 
+/** Minimum visible time for the connecting pill; shorter attempts read as flicker. */
+const CONNECTING_MIN_VISIBLE_MS = 800
+
 /** Nav glyph by section id; unknown ids fall back to the settings gear. */
 function navIcon(id: string) {
   if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
@@ -113,6 +116,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   const [activeId, setActiveId] = useState<string | undefined>(undefined)
   const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
   const [showRecovery, setShowRecovery] = useState(false)
+  const [holdConnecting, setHoldConnecting] = useState(false)
+  const connectingShownAt = useRef<number | undefined>(undefined)
   const triggerButton = useRef<HTMLButtonElement | null>(null)
   const wasOpen = useRef(open)
   const close = useCallback(() => {
@@ -157,8 +162,32 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
     }
     if (previous !== 'disconnected' && previous !== 'connecting') return
     setShowRecovery(true)
+  }, [connectionState])
+
+  // The confirmation window starts when the recovered pill becomes visible,
+  // which the connecting minimum-visible hold can delay past the transition.
+  useLayoutEffect(() => {
+    if (!showRecovery || holdConnecting) return
     const timeout = window.setTimeout(() => { setShowRecovery(false) }, RECOVERY_CONFIRMATION_MS)
     return () => { window.clearTimeout(timeout) }
+  }, [showRecovery, holdConnecting])
+
+  useLayoutEffect(() => {
+    if (connectionState === 'connecting') {
+      connectingShownAt.current = Date.now()
+      return
+    }
+    const shownAt = connectingShownAt.current
+    if (shownAt === undefined) return
+    connectingShownAt.current = undefined
+    const remaining = CONNECTING_MIN_VISIBLE_MS - (Date.now() - shownAt)
+    if (remaining <= 0) return
+    setHoldConnecting(true)
+    const timeout = window.setTimeout(() => { setHoldConnecting(false) }, remaining)
+    return () => {
+      window.clearTimeout(timeout)
+      setHoldConnecting(false)
+    }
   }, [connectionState])
 
   const completeOnboardingStep = useCallback((id: string) => {
@@ -169,10 +198,10 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   }, [])
 
   let connectionIndicator: ConnectionIndicatorState | undefined
-  if (connectionState === 'disconnected') {
-    connectionIndicator = 'disconnected'
-  } else if (connectionState === 'connecting') {
+  if (connectionState === 'connecting' || holdConnecting) {
     connectionIndicator = 'connecting'
+  } else if (connectionState === 'disconnected') {
+    connectionIndicator = 'disconnected'
   } else if (showRecovery) {
     connectionIndicator = 'recovered'
   }
@@ -194,7 +223,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
         <ConnectionIndicator
           state={wide ? connectionIndicator : undefined}
           disconnectedLabel={t('connection.error')}
-          reconnectLabel={t('connection.retry')}
           connectingLabel={t('connection.connecting')}
           recoveredLabel={t('connection.connected')}
           reconnectActionLabel={t('connection.reconnect')}

+ 4 - 6
packages/client/ui-settings-general/src/client/locales.ts

@@ -8,12 +8,11 @@ export const zh = {
   'openDocument': '打开配置文件',
   'openDocument.error': '无法打开配置文件',
   'general.nav': '通用设置',
-  'connection.error': '连接异常',
-  'connection.retry': '立即重连',
-  'connection.connecting': '自动重连中',
+  'connection.error': '连接异常,刷新重试',
+  'connection.connecting': '重新连接中',
   'connection.connected': '连接成功',
   'connection.reconnect': '连接异常,点击立即重连',
-  'connection.restart': '连接中断,正在自动重试,点击立即重连',
+  'connection.restart': '连接中断,正在重试,点击立即重连',
 } satisfies Record<string, string>
 
 /** The settings namespace key union. */
@@ -28,9 +27,8 @@ export const en = {
   'openDocument.error': 'Could not open configuration file',
   'general.nav': 'General',
   'connection.error': 'Disconnected',
-  'connection.retry': 'Reconnect now',
   'connection.connecting': 'Reconnecting',
   'connection.connected': 'Connected',
   'connection.reconnect': 'Disconnected, reconnect now',
-  'connection.restart': 'Reconnecting automatically, reconnect now',
+  'connection.restart': 'Reconnecting, reconnect now',
 } satisfies Record<SettingsKey, string>

+ 2 - 2
packages/client/ui-settings-general/tests/apply.client.spec.ts

@@ -118,8 +118,8 @@ describe('ui-settings-general apply', () => {
     settings.mutate.mockResolvedValueOnce(ok(english))
     const t = c.ctx.locale.bind(NS)
     expect(t('title')).toBe('设置')
-    expect(t('connection.error')).toBe('连接异常')
-    expect(t('connection.connecting')).toBe('自动重连中')
+    expect(t('connection.error')).toBe('连接异常,刷新重试')
+    expect(t('connection.connecting')).toBe('重中')
     expect(t('connection.connected')).toBe('连接成功')
     c.ctx.locale.setLocale('en')
     expect(t('close')).toBe('Close')

+ 44 - 1
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -162,14 +162,57 @@ describe('SettingsRoot trigger', () => {
     expect(mounted.reconnect).toHaveBeenCalledOnce()
 
     mounted.setConnectionState('connecting')
-    expect(screen.getByRole('button', { name: 'Reconnecting automatically, reconnect now' }).textContent)
+    expect(screen.getByRole('button', { name: 'Reconnecting, reconnect now' }).textContent)
       .toContain('Reconnecting...')
 
+    // An attempt that resolves instantly still shows the connecting pill for
+    // its 800ms minimum before the confirmation replaces it.
     mounted.setConnectionState('connected')
+    expect(screen.queryByRole('status')).toBeNull()
+    act(() => { vi.advanceTimersByTime(800) })
     expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
+    // The confirmation window is measured from visibility, not the transition.
     act(() => { vi.advanceTimersByTime(1_999) })
     expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
+    // The confirmation window closes at 2s, then the pill fades for 150ms.
+    act(() => { vi.advanceTimersByTime(1) })
+    act(() => { vi.advanceTimersByTime(150) })
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('keeps the attempt label steady through the hold and confirms for the full window', () => {
+    vi.useFakeTimers()
+    const mounted = mount({ dictionary: zh })
+    mounted.setConnectionState('connecting')
+    const attempt = screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' })
+    expect(attempt.textContent).toContain('重新连接中')
+    fireEvent.click(attempt)
+    expect(mounted.reconnect).toHaveBeenCalledOnce()
+    expect(attempt.textContent).toContain('重新连接中')
+    // An attempt that resolves mid-hold keeps its label until the hold ends.
+    act(() => { vi.advanceTimersByTime(100) })
+    mounted.setConnectionState('connected')
+    expect(screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' }).textContent)
+      .toContain('重新连接中')
+    act(() => { vi.advanceTimersByTime(700) })
+    expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
+    // The full two-second confirmation follows the delayed appearance.
+    act(() => { vi.advanceTimersByTime(1_999) })
+    expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
     act(() => { vi.advanceTimersByTime(1) })
+    act(() => { vi.advanceTimersByTime(150) })
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('skips the hold when the attempt already stayed visible long enough', () => {
+    vi.useFakeTimers()
+    const mounted = mount()
+    mounted.setConnectionState('connecting')
+    act(() => { vi.advanceTimersByTime(800) })
+    mounted.setConnectionState('connected')
+    expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
+    act(() => { vi.advanceTimersByTime(2_000) })
+    act(() => { vi.advanceTimersByTime(150) })
     expect(screen.queryByRole('status')).toBeNull()
   })
 

+ 1 - 1
snapshots/web/lifecycle-chrome/connection-error.expected.md

@@ -1,4 +1,4 @@
 - button "Settings":
   - img
   - text: Settings
-- button "Reconnecting automatically, reconnect now": Reconnect now
+- button "Reconnecting, reconnect now": Reconnecting

Некоторые файлы не были показаны из-за большого количества измененных файлов