{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/769a0dc0074bed3710c78e9fde2aba45c901d291",
        "commitHash": "769a0dc0074bed3710c78e9fde2aba45c901d291",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/245",
        "prTitle": "chore(release): promote customer service fixes dev → test",
        "prBaseHash": "5b2fd34fd48f1bf41963aa5b90dc9e568c1929ed",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38884383415"
      },
      "gitCommit": {
        "shortHash": "769a0dc",
        "hash": "769a0dc0074bed3710c78e9fde2aba45c901d291",
        "subject": "Merge d3b5007acbaa74469ea55fdee97eedd9830058ed into 5b2fd34fd48f1bf41963aa5b90dc9e568c1929ed",
        "body": "Merge d3b5007acbaa74469ea55fdee97eedd9830058ed into 5b2fd34fd48f1bf41963aa5b90dc9e568c1929ed\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1792144856000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792144856000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/workbench/decisions/T013-customer-service.md b/docs/workbench/decisions/T013-customer-service.md\nindex 5c92100..ba561c5 100644\n--- a/docs/workbench/decisions/T013-customer-service.md\n+++ b/docs/workbench/decisions/T013-customer-service.md\n@@ -102,3 +102,11 @@ capture 脚本报告的 20 条 warning 全部为 React Router v6→v7 future fla\n - 底部已存在的「人工」入口复用原 `#wecom` 弹层动作，沿用原有二维码数据与取消/返回行为；在真实 API 模式下，不因 Socket.IO 尚未连接而禁用企微入口。\n - **保留**原应用内人工客服转接 `useHumanChat()/transfer()`、Socket.IO 监听、排队/接入历史逻辑及相关独立测试；此次仅改变用户可见按钮绑定，不再由该按钮触发应用内转接。Mock 人工状态机实现同样保留。\n - `#wecom` 深链仍有效。正式后端联调与原人工转接链路治理另行验收，本改动不声称将企微二维码当成已连接的人工坐席。\n+\n+## 2026-10-16｜客服两条入口重新分工（覆盖 10-12 兼容补丁）\n+\n+- 用户明确要求底部「人工」恢复**真实应用内转人工**：正式 API 模式由 `useHumanChat().transfer()` 调用已存在的转人工接口和 Socket.IO 监听；Socket 连接未就绪时禁用按钮，不伪造在线座席与排队进度。\n+- 标题栏右侧使用小人头图标，独立打开现有 `#wecom` 福利官二维码弹层。正式 API 模式仍取 `brand_welfare_setting` 中真实二维码，且不与应用内转人工混同。\n+- 输入框上方仅保留实际错误及必要的核对操作，移除日常 AI/Socket 状态说明。\n+- 长 OSS URL/富文本气泡限制宽度并允许 `overflow-wrap:anywhere`，确保窄屏 WebView 可完整阅读消息。\n+- 本记录仅更新 **2026-10-16 当前界面决定**；8 月的 T013 原型事实与 10-12 兼容补丁保留作为历史。Mock 排队仍是演示，不视为真实座席接入。真实 App WebView + 真实 API 业务验收在 test 进行。\ndiff --git a/src/components/mobile/ChatMessageList.test.tsx b/src/components/mobile/ChatMessageList.test.tsx\nnew file mode 100644\nindex 0000000..d06498e\n--- /dev/null\n+++ b/src/components/mobile/ChatMessageList.test.tsx\n@@ -0,0 +1,27 @@\n+import { describe, expect, it } from 'vitest'\n+import { render, screen } from '@testing-library/react'\n+import ChatMessageList from './ChatMessageList'\n+\n+const longUrl = 'https://hexxueyantest.oss-cn-guangzhou.aliyuncs.com/storage/20261008/' + 'a'.repeat(90)\n+\n+describe('ChatMessageList mobile layout', () => {\n+  it('constrains plain and sanitized rich-text URLs to the bubble width', () => {\n+    const { container } = render(\n+      <ChatMessageList messages={[\n+        { id: '1', role: 'bot', text: longUrl, status: 'sent' },\n+        { id: '2', role: 'user', text: longUrl, status: 'sent' },\n+        { id: '3', role: 'bot', text: `<p>${longUrl}</p>`, status: 'sent' },\n+      ]} />,\n+    )\n+    expect(screen.getAllByText(longUrl)).toHaveLength(3)\n+    const rows = Array.from(container.querySelectorAll('[data-chat-message]'))\n+    expect(rows).toHaveLength(3)\n+    for (const row of rows) {\n+      expect(row.className).toContain('min-w-0')\n+      const bubble = row.querySelector('[data-chat-bubble]')\n+      expect(bubble?.className).toContain('[overflow-wrap:anywhere]')\n+      expect(bubble?.className).toContain('max-w-full')\n+    }\n+    expect(container.querySelector('[data-chat-rich-text]')?.className).toContain('[overflow-wrap:anywhere]')\n+  })\n+})\ndiff --git a/src/components/mobile/ChatMessageList.tsx b/src/components/mobile/ChatMessageList.tsx\nindex 180ad80..9f1cd2c 100644\n--- a/src/components/mobile/ChatMessageList.tsx\n+++ b/src/components/mobile/ChatMessageList.tsx\n@@ -31,7 +31,7 @@ export default function ChatMessageList({\n             data-chat-message\n             data-chat-role={message.role}\n             data-chat-status={message.status}\n-            className={`flex gap-2 ${isBot ? 'justify-start' : 'justify-end'}`}\n+            className={`flex w-full min-w-0 gap-2 ${isBot ? 'justify-start' : 'justify-end'}`}\n           >\n             {isBot && (\n               <span\n@@ -44,7 +44,8 @@ export default function ChatMessageList({\n \n             <div className={`flex min-w-0 max-w-[76%] flex-col ${isBot ? 'items-start' : 'items-end'}`}>\n               <div\n-                className={`whitespace-pre-wrap break-words rounded-container px-3.5 py-2.5 text-sm leading-6 ${\n+                data-chat-bubble\n+                className={`min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere] rounded-container px-3.5 py-2.5 text-sm leading-6 ${\n                   isBot\n                     ? 'bg-surface text-text-primary shadow-sm'\n                     : `bg-primary text-text-inverse ${message.status === 'sending' ? 'opacity-70' : ''}`\n@@ -53,7 +54,7 @@ export default function ChatMessageList({\n                 {hasChatRichText(message.text) ? (\n                   <div\n                     data-chat-rich-text\n-                    className=\"whitespace-normal [&_blockquote]:border-l-2 [&_blockquote]:pl-2 [&_code]:font-mono [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\"\n+                    className=\"min-w-0 max-w-full whitespace-normal [overflow-wrap:anywhere] [&_blockquote]:border-l-2 [&_blockquote]:pl-2 [&_code]:font-mono [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\"\n                     dangerouslySetInnerHTML={{ __html: sanitizeChatRichText(message.text) }}\n                   />\n                 ) : (\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex eedcf29..6e68042 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -7,7 +7,7 @@ import {\n   useNavigationType,\n   useViewTransitionState,\n } from 'react-router-dom'\n-import { Bell, Settings } from 'lucide-react'\n+import { Bell, Settings, UserRound } from 'lucide-react'\n import BottomNav from '../components/mobile/BottomNav'\n import TitleBar from '../components/mobile/TitleBar'\n import { findRouteByPathname, isLegacyTabPath } from '../app/router/routes'\n@@ -196,6 +196,18 @@ export default function MobileLayout() {\n         {allNotificationsRead ? '全部已读' : '一键已读'}\n       </button>\n     )\n+    : location.pathname === '/service/chat'\n+      ? (\n+        <button\n+          type=\"button\"\n+          aria-label=\"福利官二维码\"\n+          title=\"福利官二维码\"\n+          onClick={() => navigate({ pathname: location.pathname, search: location.search, hash: '#wecom' }, { replace: true, state: location.state })}\n+          className=\"flex h-10 w-10 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\"\n+        >\n+          <UserRound className=\"h-[21px] w-[21px]\" aria-hidden />\n+        </button>\n+      )\n     : route?.titleBarAction === 'settings'\n       ? (\n         <button type=\"button\" aria-label=\"设置\" onClick={() => navigateShell('/settings')} className=\"flex h-9 w-9 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\">\ndiff --git a/src/pages/ApiServiceChat.test.tsx b/src/pages/ApiServiceChat.test.tsx\nindex ca18bcb..de40777 100644\n--- a/src/pages/ApiServiceChat.test.tsx\n+++ b/src/pages/ApiServiceChat.test.tsx\n@@ -64,8 +64,8 @@ describe('#135 real API chat page', () => {\n     expect(screen.queryByText('前面还有 2 位')).toBeNull()\n     expect(screen.queryByText('已收到你的问题，我先为你查询。')).toBeNull()\n     expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(true)\n-    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n-    expect(screen.getByText('AI 可用；人工消息通道尚未连通')).toBeTruthy()\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(screen.queryByText('AI 可用；人工消息通道尚未连通')).toBeNull()\n   })\n \n   it('supports empty, loading, errors, explicit retry and older pagination', () => {\n@@ -105,11 +105,11 @@ describe('#135 real API chat page', () => {\n     expect(input.value).toBe('')\n   })\n \n-  it('shows streaming status, cancellation and human mode without invented agent data', () => {\n+  it('shows streaming cancellation and human mode without routine status copy or invented agent data', () => {\n     mock.chat = { phase: 'streaming', busy: true, blocked: false, humanAwait: false, error: null,\n       messages: [{ id: 'local-bot', role: 'bot', text: '正逐块回复', status: 'sent' }] }\n     const view = mount()\n-    expect(screen.getByText('AI 正在流式回复…')).toBeTruthy()\n+    expect(screen.queryByText('AI 正在流式回复…')).toBeNull()\n     expect(screen.getByText('正逐块回复')).toBeTruthy()\n     fireEvent.click(screen.getByRole('button', { name: '中止等待回复' }))\n     expect(mock.cancel).toHaveBeenCalledOnce()\n@@ -117,7 +117,7 @@ describe('#135 real API chat page', () => {\n \n     mock.chat = { phase: 'human', busy: false, blocked: true, humanAwait: true, error: null, messages: [] }\n     mount()\n-    expect(screen.getByText('已进入人工模式，客服消息连接中断，请检查历史')).toBeTruthy()\n+    expect(screen.queryByText('已进入人工模式，客服消息连接中断，请检查历史')).toBeNull()\n     expect((screen.getByRole('textbox', { name: '输入你的问题' }) as HTMLInputElement).disabled).toBe(true)\n     expect(screen.queryByText('前面还有 2 位')).toBeNull()\n   })\n@@ -132,32 +132,34 @@ describe('#135 real API chat page', () => {\n     expect(mock.reload).toHaveBeenCalledOnce()\n   })\n \n-  it('compatibility: human entry opens the original WeCom QR while keeping real transfer uninvoked', () => {\n+  it('uses the real transfer handler when the Socket.IO channel is ready', () => {\n     mock.human = { status: 'connected', canTransfer: true, transferring: false, active: false, error: null, needsHistoryCheck: false }\n     mount()\n     const button = screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement\n     expect(button.disabled).toBe(false)\n     fireEvent.click(button)\n-    expect(screen.getByRole('img', { name: '企业微信福利官二维码' }).getAttribute('src')).toContain('/real-qr.png')\n-    expect(mock.transfer).not.toHaveBeenCalled()\n-    expect(screen.getByText('AI 可用；可申请转人工，接入坐席状态未知')).toBeTruthy()\n-    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(mock.transfer).toHaveBeenCalledOnce()\n     expect(screen.queryByRole('img', { name: '企业微信福利官二维码' })).toBeNull()\n+    expect(screen.queryByText('AI 可用；可申请转人工，接入坐席状态未知')).toBeNull()\n   })\n \n-  it('does not gate WeCom access on Socket.IO or AI history readiness', () => {\n+  it('disables transfer when the socket is unavailable while keeping QR deep-link independent', () => {\n     mock.state = { status: 'error', message: '客服历史加载失败', messages: [], hasMore: false, loadingMore: false, moreError: null }\n-    mount()\n-    fireEvent.click(screen.getByRole('button', { name: '人工客服' }))\n-    expect(screen.getByRole('img', { name: '企业微信福利官二维码' })).toBeTruthy()\n+    mount('/service/chat#wecom')\n+    const button = screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement\n+    expect(button.disabled).toBe(true)\n+    fireEvent.click(button)\n     expect(mock.transfer).not.toHaveBeenCalled()\n+    expect(screen.getByRole('img', { name: '企业微信福利官二维码' })).toBeTruthy()\n+    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(screen.queryByRole('img', { name: '企业微信福利官二维码' })).toBeNull()\n   })\n \n   it('requires explicit latest-history inspection after uncertain human transfer', () => {\n     mock.human = { status: 'connected', canTransfer: false, transferring: false, active: false,\n       error: '登录状态已变化，人工转接结果尚未确认，请先检查聊天历史', needsHistoryCheck: true }\n     mount()\n-    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n     fireEvent.click(screen.getByRole('button', { name: '核对人工转接记录' }))\n     expect(mock.reload).toHaveBeenCalledOnce()\n     expect(mock.acknowledge).toHaveBeenCalledOnce()\ndiff --git a/src/pages/ApiServiceChat.tsx b/src/pages/ApiServiceChat.tsx\nindex 045b61b..294a6b4 100644\n--- a/src/pages/ApiServiceChat.tsx\n+++ b/src/pages/ApiServiceChat.tsx\n@@ -36,18 +36,13 @@ export default function ApiServiceChat() {\n   }\n   const inputDisabled = history.status !== 'ready' || chat.busy || chat.blocked ||\n     ((chat.humanAwait || human.active) && human.status !== 'connected')\n+  const humanEntryDisabled = history.status !== 'ready' || chat.busy || chat.blocked ||\n+    chat.humanAwait || human.active || !human.canTransfer\n   const messages = combineChatMessages(history.messages, chat.messages)\n   const location = useLocation()\n   const navigate = useNavigate()\n   const [wecomOpen, setWecomOpen] = useState(false)\n \n-  // Compatibility: reuse the exact #wecom action previously owned by the titlebar.\n-  // useHumanChat()/human.transfer() and its real Socket.IO flow are deliberately retained.\n-  const openWecom = () => {\n-    navigate({ pathname: location.pathname, search: location.search, hash: '#wecom' },\n-      { replace: true, state: location.state })\n-  }\n-\n   const closeWecom = () => {\n     setWecomOpen(false)\n     if (location.hash === '#wecom') {\n@@ -60,11 +55,11 @@ export default function ApiServiceChat() {\n   }, [location.hash])\n \n   return (\n-    <PageContainer className=\"flex min-h-full flex-col pb-0\" inset={false}>\n+    <PageContainer className=\"flex min-h-full min-w-0 w-full flex-col pb-0\" inset={false}>\n       <div className=\"px-4 pt-3\">\n         <p className=\"text-center text-xs text-text-tertiary\">AI 客服 {CHAT_BOT.name} 为您服务</p>\n       </div>\n-      <section className=\"flex-1 px-4 pb-4 pt-4\" aria-label=\"客服聊天历史\" data-human-stage={chat.humanAwait || human.active ? 'pending' : 'idle'}>\n+      <section className=\"min-w-0 w-full flex-1 px-4 pb-4 pt-4\" aria-label=\"客服聊天历史\" data-human-stage={chat.humanAwait || human.active ? 'pending' : 'idle'}>\n         {history.status === 'loading' ? (\n           <p role=\"status\" className=\"py-8 text-center text-sm text-text-secondary\">正在加载客服历史…</p>\n         ) : history.status === 'error' ? (\n@@ -98,23 +93,11 @@ export default function ApiServiceChat() {\n       </section>\n \n       <div className=\"sticky bottom-0 border-t border-border-subtle bg-background px-4 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3\">\n-        {chat.error ? (\n+        {chat.error && (\n           <div role=\"alert\" className=\"mb-3 rounded-container bg-surface px-3 py-2 text-xs text-danger-text\">\n             <p>{chat.error}</p>\n             <Button variant=\"outline\" onClick={() => { chat.clear(); history.reload() }}>检查最新历史记录</Button>\n           </div>\n-        ) : (\n-          <div role=\"status\" className=\"mb-2 text-center text-xs text-text-secondary\">\n-            {chat.phase === 'sending' ? '正在发送消息…'\n-              : chat.phase === 'streaming' ? 'AI 正在流式回复…'\n-              : chat.phase === 'syncing' ? '正在核对服务端消息记录…'\n-              : human.transferring ? '正在请求转人工…'\n-              : (chat.humanAwait || human.active) ? (human.status === 'connected'\n-                ? '已请求人工服务，等待客服真实回复（暂无接入人数或时间信息）'\n-                : '已进入人工模式，客服消息连接中断，请检查历史')\n-              : human.status === 'connected' ? 'AI 可用；可申请转人工，接入坐席状态未知'\n-              : 'AI 可用；人工消息通道尚未连通'}\n-          </div>\n         )}\n         {human.error && <div role=\"alert\" className=\"mb-2 text-center text-xs text-danger-text\">\n           <p>{human.error}</p>\n@@ -126,12 +109,14 @@ export default function ApiServiceChat() {\n         </div>}\n         <div className=\"flex items-end gap-2\">\n           <button type=\"button\" data-chat-human-entry\n-            onClick={openWecom}\n+            onClick={() => { void human.transfer() }}\n+            disabled={humanEntryDisabled}\n             aria-label=\"人工客服\"\n+            title={humanEntryDisabled ? '人工通道暂不可用' : '转接人工客服'}\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm disabled:opacity-50\">\n             <Headset className=\"h-4 w-4\" aria-hidden />人工\n           </button>\n-          <div className=\"flex min-h-11 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n+          <div className=\"flex min-h-11 min-w-0 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n             <input type=\"text\" value={draft} maxLength={5000} autoComplete=\"off\"\n               onChange={event => setDraft(event.target.value)}\n               onKeyDown={event => {\n@@ -139,7 +124,7 @@ export default function ApiServiceChat() {\n               }}\n               disabled={inputDisabled} aria-label=\"输入你的问题\"\n               placeholder={(chat.humanAwait || human.active) ? '向人工客服发消息（HTTP 发送）' : '请输入你的问题'}\n-              className=\"h-11 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\" />\n+              className=\"h-11 min-w-0 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\" />\n           </div>\n           {chat.busy ? (\n             <button type=\"button\" data-chat-cancel onClick={chat.cancel} aria-label=\"中止等待回复\"\n@@ -155,10 +140,9 @@ export default function ApiServiceChat() {\n           )}\n         </div>\n       </div>\n-      {/* 「人工」入口现复用原 #wecom 弹层；真实 human.transfer()/Socket.IO 链路保持原样。 */}\n       <BottomSheet\n         open={wecomOpen}\n-        title={CHAT_HUMAN_PROMPT.title}\n+        title=\"品牌福利官\"\n         onClose={closeWecom}\n         actions={<div className=\"flex justify-center\"><Button variant=\"ghost\" onClick={closeWecom}>{CHAT_HUMAN_PROMPT.cancelLabel}</Button></div>}\n       >\ndiff --git a/src/pages/ServiceChat.compat.test.tsx b/src/pages/ServiceChat.compat.test.tsx\nindex 9b9d57a..719b583 100644\n--- a/src/pages/ServiceChat.compat.test.tsx\n+++ b/src/pages/ServiceChat.compat.test.tsx\n@@ -37,17 +37,27 @@ afterEach(() => {\n })\n \n describe('legacy/mock ServiceChat compatibility', () => {\n-  it('opens the old WeCom sheet from 人工 without starting the preserved local queue', () => {\n+  it('restores the original human button without opening the separate WeCom QR', () => {\n     render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n-    const button = screen.getByRole('button', { name: '人工' })\n-    expect(button.hasAttribute('disabled')).toBe(false)\n+    const button = screen.getByRole('button', { name: '人工' }) as HTMLButtonElement\n+    expect(button.disabled).toBe(false)\n     fireEvent.click(button)\n-    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n-    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('idle')\n-    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('queuing')\n+    expect(button.disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(true)\n     expect(screen.queryByText('原企微二维码区域')).toBeNull()\n-    fireEvent.click(button)\n-    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+  })\n+\n+  it('keeps a pending message unsent when the user switches to a human queue', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n+    const input = screen.getByRole('textbox', { name: '输入你的问题' }) as HTMLInputElement\n+    fireEvent.change(input, { target: { value: '待发送的问题' } })\n+    const sendButton = screen.getByRole('button', { name: '发送' }) as HTMLButtonElement\n+    expect(sendButton.disabled).toBe(false)\n+    fireEvent.click(screen.getByRole('button', { name: '人工' }))\n+    expect(sendButton.disabled).toBe(true)\n+    fireEvent.click(sendButton)\n+    expect(input.value).toBe('待发送的问题')\n   })\n \n   it('keeps mock AI input available and original typed 人工 transfer reachable', () => {\ndiff --git a/src/pages/ServiceChat.tsx b/src/pages/ServiceChat.tsx\nindex b258929..501b70b 100644\n--- a/src/pages/ServiceChat.tsx\n+++ b/src/pages/ServiceChat.tsx\n@@ -50,14 +50,6 @@ function MockServiceChat() {\n \n   /** T013R5：企微二维码弹层 —— 与壳层 TitleBar 联动，靠 `#wecom` hash 触发 */\n   const [wecomOpen, setWecomOpen] = useState(false)\n-  // Compatibility: the visible \"人工\" entry opens the original #wecom sheet.\n-  // Keep requestHuman() and the existing human-stage implementation unchanged.\n-  const openWecom = () => {\n-    navigate(\n-      { pathname: location.pathname, search: location.search, hash: '#wecom' },\n-      { replace: true, state: location.state },\n-    )\n-  }\n   const closeWecom = () => {\n     setWecomOpen(false)\n     /* H016：通过 Router replace 清 hash，保留当前 history entry/state，不绕过 React Router。 */\n@@ -150,7 +142,7 @@ function MockServiceChat() {\n \n   const send = () => {\n     const text = draft.trim()\n-    if (!text) return\n+    if (!text || humanStage === 'queuing') return\n     setDraft('')\n \n     /** T013R4：输入「人工客服」等同于点击页内「人工」按钮，触发人工客服状态机 */\n@@ -184,15 +176,15 @@ function MockServiceChat() {\n       : CHAT_BOT.inputPlaceholder\n \n   return (\n-    <PageContainer className=\"flex min-h-full flex-col pb-0\" inset={false}>\n-      {/* T013R5：「企微客服」pill 移回壳层 TitleBar 右侧，页内顶部区只保留居中小字。 */}\n+    <PageContainer className=\"flex min-h-full min-w-0 w-full flex-col pb-0\" inset={false}>\n+      {/* 标题栏提供独立的福利官二维码入口；底部人工按钮只负责聊天转接。 */}\n       <div className=\"px-4 pt-3\">\n         <p className=\"text-center text-xs text-text-tertiary\">\n           AI 客服 {CHAT_BOT.name} 为您服务\n         </p>\n       </div>\n \n-      <div className=\"flex-1 px-4 pb-4 pt-4\" data-human-stage={humanStage}>\n+      <div className=\"min-w-0 w-full flex-1 px-4 pb-4 pt-4\" data-human-stage={humanStage}>\n         <ChatMessageList messages={messages} onRetry={retry} />\n       </div>\n \n@@ -201,14 +193,15 @@ function MockServiceChat() {\n           <button\n             type=\"button\"\n             data-chat-human-entry\n-            onClick={openWecom}\n+            onClick={requestHuman}\n+            disabled={humanStage !== 'idle'}\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm active:bg-surface-selected disabled:opacity-50\"\n           >\n             <Headset className=\"h-4 w-4\" aria-hidden />\n             人工\n           </button>\n \n-          <div className=\"flex min-h-11 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n+          <div className=\"flex min-h-11 min-w-0 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n             <input\n               type=\"text\"\n               inputMode=\"text\"\n@@ -224,7 +217,7 @@ function MockServiceChat() {\n               placeholder={inputPlaceholder}\n               disabled={inputDisabled}\n               aria-label=\"输入你的问题\"\n-              className=\"h-11 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\"\n+              className=\"h-11 min-w-0 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\"\n             />\n           </div>\n \n@@ -232,7 +225,7 @@ function MockServiceChat() {\n             type=\"button\"\n             data-chat-send\n             onClick={send}\n-            disabled={!draft.trim()}\n+            disabled={inputDisabled || !draft.trim()}\n             aria-label=\"发送\"\n             className=\"flex h-11 w-11 flex-none items-center justify-center rounded-full bg-primary text-text-inverse active:bg-primary-pressed disabled:bg-disabled disabled:text-text-disabled\"\n           >\n@@ -241,11 +234,9 @@ function MockServiceChat() {\n         </div>\n       </div>\n \n-      {/* 兼容性处理：右上企微入口隐藏；底部「人工」复用原 #wecom 弹层。\n-        * 既有 requestHuman()／人工状态机保留，单独治理，不在此变更删除。 */}\n       <BottomSheet\n         open={wecomOpen}\n-        title={CHAT_HUMAN_PROMPT.title}\n+        title=\"品牌福利官\"\n         onClose={closeWecom}\n         actions={\n           <div className=\"flex justify-center\">\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex b22f4cb..9ef231c 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -89,6 +89,20 @@ test.describe('@formal-h5 active route smoke', () => {\n   }\n })\n \n+test('@formal-h5 chat long OSS URLs stay inside narrow WebView bubbles', async ({ page }) => {\n+  await page.setViewportSize({ width: 320, height: 700 })\n+  await page.goto('/service/chat', { waitUntil: 'domcontentloaded' })\n+  const longUrl = 'https://hexxueyantest.oss-cn-guangzhou.aliyuncs.com/storage/20261008/' + 'a'.repeat(90)\n+  await page.getByRole('textbox', { name: '输入你的问题' }).fill(longUrl)\n+  await page.getByRole('textbox', { name: '输入你的问题' }).press('Enter')\n+  await expect(page.locator('[data-chat-role=\"user\"]')).toContainText(longUrl)\n+  await expectHealthyFormalRoute(page)\n+  const overflowBubbles = await page.locator('[data-chat-bubble]').evaluateAll((nodes) =>\n+    nodes.filter((node) => node.scrollWidth > node.clientWidth + 1).length,\n+  )\n+  expect(overflowBubbles).toBe(0)\n+})\n+\n test('@formal-h5 first-level tab uses CSS fade without root snapshot', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n \n@@ -153,12 +167,14 @@ test('@formal-h5 H021 title-bar actions remain available', async ({ page }) => {\n \n   await page.goto('/service/chat', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n-  // Compat: the TitleBar WeCom pill is hidden; the original QR sheet is opened by bottom 人工.\n-  await expect(page.locator('[data-chat-wecom-entry]')).toHaveCount(0)\n+  // The real human action stays in the chat; the QR is a distinct titlebar action.\n   const humanEntry = page.locator('[data-chat-human-entry]')\n   await expect(humanEntry).toBeVisible()\n   await expect(humanEntry).toBeEnabled()\n   await humanEntry.click()\n+  await expect(page.locator('[data-human-stage]')).toHaveAttribute('data-human-stage', 'queuing')\n+  await expect(page.locator('[data-chat-human-sheet]')).toHaveCount(0)\n+  await page.getByRole('button', { name: '福利官二维码' }).click()\n   await expect(page.locator('[data-chat-human-sheet]')).toBeVisible()\n   await page.getByRole('button', { name: '取消' }).click()\n   await expect(page.locator('[data-chat-human-sheet]')).toHaveCount(0)",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/769a0dc0074bed3710c78e9fde2aba45c901d291",
            "commitHash": "769a0dc0074bed3710c78e9fde2aba45c901d291",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/245",
            "prTitle": "chore(release): promote customer service fixes dev → test",
            "prBaseHash": "5b2fd34fd48f1bf41963aa5b90dc9e568c1929ed",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38884383415"
          },
          "gitCommit": {
            "shortHash": "769a0dc",
            "hash": "769a0dc0074bed3710c78e9fde2aba45c901d291",
            "subject": "Merge d3b5007acbaa74469ea55fdee97eedd9830058ed into 5b2fd34fd48f1bf41963aa5b90dc9e568c1929ed",
            "body": "Merge d3b5007acbaa74469ea55fdee97eedd9830058ed into 5b2fd34fd48f1bf41963aa5b90dc9e568c1929ed\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1792144856000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792144856000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/workbench/decisions/T013-customer-service.md b/docs/workbench/decisions/T013-customer-service.md\nindex 5c92100..ba561c5 100644\n--- a/docs/workbench/decisions/T013-customer-service.md\n+++ b/docs/workbench/decisions/T013-customer-service.md\n@@ -102,3 +102,11 @@ capture 脚本报告的 20 条 warning 全部为 React Router v6→v7 future fla\n - 底部已存在的「人工」入口复用原 `#wecom` 弹层动作，沿用原有二维码数据与取消/返回行为；在真实 API 模式下，不因 Socket.IO 尚未连接而禁用企微入口。\n - **保留**原应用内人工客服转接 `useHumanChat()/transfer()`、Socket.IO 监听、排队/接入历史逻辑及相关独立测试；此次仅改变用户可见按钮绑定，不再由该按钮触发应用内转接。Mock 人工状态机实现同样保留。\n - `#wecom` 深链仍有效。正式后端联调与原人工转接链路治理另行验收，本改动不声称将企微二维码当成已连接的人工坐席。\n+\n+## 2026-10-16｜客服两条入口重新分工（覆盖 10-12 兼容补丁）\n+\n+- 用户明确要求底部「人工」恢复**真实应用内转人工**：正式 API 模式由 `useHumanChat().transfer()` 调用已存在的转人工接口和 Socket.IO 监听；Socket 连接未就绪时禁用按钮，不伪造在线座席与排队进度。\n+- 标题栏右侧使用小人头图标，独立打开现有 `#wecom` 福利官二维码弹层。正式 API 模式仍取 `brand_welfare_setting` 中真实二维码，且不与应用内转人工混同。\n+- 输入框上方仅保留实际错误及必要的核对操作，移除日常 AI/Socket 状态说明。\n+- 长 OSS URL/富文本气泡限制宽度并允许 `overflow-wrap:anywhere`，确保窄屏 WebView 可完整阅读消息。\n+- 本记录仅更新 **2026-10-16 当前界面决定**；8 月的 T013 原型事实与 10-12 兼容补丁保留作为历史。Mock 排队仍是演示，不视为真实座席接入。真实 App WebView + 真实 API 业务验收在 test 进行。\ndiff --git a/src/components/mobile/ChatMessageList.test.tsx b/src/components/mobile/ChatMessageList.test.tsx\nnew file mode 100644\nindex 0000000..d06498e\n--- /dev/null\n+++ b/src/components/mobile/ChatMessageList.test.tsx\n@@ -0,0 +1,27 @@\n+import { describe, expect, it } from 'vitest'\n+import { render, screen } from '@testing-library/react'\n+import ChatMessageList from './ChatMessageList'\n+\n+const longUrl = 'https://hexxueyantest.oss-cn-guangzhou.aliyuncs.com/storage/20261008/' + 'a'.repeat(90)\n+\n+describe('ChatMessageList mobile layout', () => {\n+  it('constrains plain and sanitized rich-text URLs to the bubble width', () => {\n+    const { container } = render(\n+      <ChatMessageList messages={[\n+        { id: '1', role: 'bot', text: longUrl, status: 'sent' },\n+        { id: '2', role: 'user', text: longUrl, status: 'sent' },\n+        { id: '3', role: 'bot', text: `<p>${longUrl}</p>`, status: 'sent' },\n+      ]} />,\n+    )\n+    expect(screen.getAllByText(longUrl)).toHaveLength(3)\n+    const rows = Array.from(container.querySelectorAll('[data-chat-message]'))\n+    expect(rows).toHaveLength(3)\n+    for (const row of rows) {\n+      expect(row.className).toContain('min-w-0')\n+      const bubble = row.querySelector('[data-chat-bubble]')\n+      expect(bubble?.className).toContain('[overflow-wrap:anywhere]')\n+      expect(bubble?.className).toContain('max-w-full')\n+    }\n+    expect(container.querySelector('[data-chat-rich-text]')?.className).toContain('[overflow-wrap:anywhere]')\n+  })\n+})\ndiff --git a/src/components/mobile/ChatMessageList.tsx b/src/components/mobile/ChatMessageList.tsx\nindex 180ad80..9f1cd2c 100644\n--- a/src/components/mobile/ChatMessageList.tsx\n+++ b/src/components/mobile/ChatMessageList.tsx\n@@ -31,7 +31,7 @@ export default function ChatMessageList({\n             data-chat-message\n             data-chat-role={message.role}\n             data-chat-status={message.status}\n-            className={`flex gap-2 ${isBot ? 'justify-start' : 'justify-end'}`}\n+            className={`flex w-full min-w-0 gap-2 ${isBot ? 'justify-start' : 'justify-end'}`}\n           >\n             {isBot && (\n               <span\n@@ -44,7 +44,8 @@ export default function ChatMessageList({\n \n             <div className={`flex min-w-0 max-w-[76%] flex-col ${isBot ? 'items-start' : 'items-end'}`}>\n               <div\n-                className={`whitespace-pre-wrap break-words rounded-container px-3.5 py-2.5 text-sm leading-6 ${\n+                data-chat-bubble\n+                className={`min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere] rounded-container px-3.5 py-2.5 text-sm leading-6 ${\n                   isBot\n                     ? 'bg-surface text-text-primary shadow-sm'\n                     : `bg-primary text-text-inverse ${message.status === 'sending' ? 'opacity-70' : ''}`\n@@ -53,7 +54,7 @@ export default function ChatMessageList({\n                 {hasChatRichText(message.text) ? (\n                   <div\n                     data-chat-rich-text\n-                    className=\"whitespace-normal [&_blockquote]:border-l-2 [&_blockquote]:pl-2 [&_code]:font-mono [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\"\n+                    className=\"min-w-0 max-w-full whitespace-normal [overflow-wrap:anywhere] [&_blockquote]:border-l-2 [&_blockquote]:pl-2 [&_code]:font-mono [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\"\n                     dangerouslySetInnerHTML={{ __html: sanitizeChatRichText(message.text) }}\n                   />\n                 ) : (\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex eedcf29..6e68042 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -7,7 +7,7 @@ import {\n   useNavigationType,\n   useViewTransitionState,\n } from 'react-router-dom'\n-import { Bell, Settings } from 'lucide-react'\n+import { Bell, Settings, UserRound } from 'lucide-react'\n import BottomNav from '../components/mobile/BottomNav'\n import TitleBar from '../components/mobile/TitleBar'\n import { findRouteByPathname, isLegacyTabPath } from '../app/router/routes'\n@@ -196,6 +196,18 @@ export default function MobileLayout() {\n         {allNotificationsRead ? '全部已读' : '一键已读'}\n       </button>\n     )\n+    : location.pathname === '/service/chat'\n+      ? (\n+        <button\n+          type=\"button\"\n+          aria-label=\"福利官二维码\"\n+          title=\"福利官二维码\"\n+          onClick={() => navigate({ pathname: location.pathname, search: location.search, hash: '#wecom' }, { replace: true, state: location.state })}\n+          className=\"flex h-10 w-10 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\"\n+        >\n+          <UserRound className=\"h-[21px] w-[21px]\" aria-hidden />\n+        </button>\n+      )\n     : route?.titleBarAction === 'settings'\n       ? (\n         <button type=\"button\" aria-label=\"设置\" onClick={() => navigateShell('/settings')} className=\"flex h-9 w-9 items-center justify-center rounded-full text-text-primary active:bg-[rgba(89,55,15,0.06)]\">\ndiff --git a/src/pages/ApiServiceChat.test.tsx b/src/pages/ApiServiceChat.test.tsx\nindex ca18bcb..de40777 100644\n--- a/src/pages/ApiServiceChat.test.tsx\n+++ b/src/pages/ApiServiceChat.test.tsx\n@@ -64,8 +64,8 @@ describe('#135 real API chat page', () => {\n     expect(screen.queryByText('前面还有 2 位')).toBeNull()\n     expect(screen.queryByText('已收到你的问题，我先为你查询。')).toBeNull()\n     expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(true)\n-    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n-    expect(screen.getByText('AI 可用；人工消息通道尚未连通')).toBeTruthy()\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(screen.queryByText('AI 可用；人工消息通道尚未连通')).toBeNull()\n   })\n \n   it('supports empty, loading, errors, explicit retry and older pagination', () => {\n@@ -105,11 +105,11 @@ describe('#135 real API chat page', () => {\n     expect(input.value).toBe('')\n   })\n \n-  it('shows streaming status, cancellation and human mode without invented agent data', () => {\n+  it('shows streaming cancellation and human mode without routine status copy or invented agent data', () => {\n     mock.chat = { phase: 'streaming', busy: true, blocked: false, humanAwait: false, error: null,\n       messages: [{ id: 'local-bot', role: 'bot', text: '正逐块回复', status: 'sent' }] }\n     const view = mount()\n-    expect(screen.getByText('AI 正在流式回复…')).toBeTruthy()\n+    expect(screen.queryByText('AI 正在流式回复…')).toBeNull()\n     expect(screen.getByText('正逐块回复')).toBeTruthy()\n     fireEvent.click(screen.getByRole('button', { name: '中止等待回复' }))\n     expect(mock.cancel).toHaveBeenCalledOnce()\n@@ -117,7 +117,7 @@ describe('#135 real API chat page', () => {\n \n     mock.chat = { phase: 'human', busy: false, blocked: true, humanAwait: true, error: null, messages: [] }\n     mount()\n-    expect(screen.getByText('已进入人工模式，客服消息连接中断，请检查历史')).toBeTruthy()\n+    expect(screen.queryByText('已进入人工模式，客服消息连接中断，请检查历史')).toBeNull()\n     expect((screen.getByRole('textbox', { name: '输入你的问题' }) as HTMLInputElement).disabled).toBe(true)\n     expect(screen.queryByText('前面还有 2 位')).toBeNull()\n   })\n@@ -132,32 +132,34 @@ describe('#135 real API chat page', () => {\n     expect(mock.reload).toHaveBeenCalledOnce()\n   })\n \n-  it('compatibility: human entry opens the original WeCom QR while keeping real transfer uninvoked', () => {\n+  it('uses the real transfer handler when the Socket.IO channel is ready', () => {\n     mock.human = { status: 'connected', canTransfer: true, transferring: false, active: false, error: null, needsHistoryCheck: false }\n     mount()\n     const button = screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement\n     expect(button.disabled).toBe(false)\n     fireEvent.click(button)\n-    expect(screen.getByRole('img', { name: '企业微信福利官二维码' }).getAttribute('src')).toContain('/real-qr.png')\n-    expect(mock.transfer).not.toHaveBeenCalled()\n-    expect(screen.getByText('AI 可用；可申请转人工，接入坐席状态未知')).toBeTruthy()\n-    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(mock.transfer).toHaveBeenCalledOnce()\n     expect(screen.queryByRole('img', { name: '企业微信福利官二维码' })).toBeNull()\n+    expect(screen.queryByText('AI 可用；可申请转人工，接入坐席状态未知')).toBeNull()\n   })\n \n-  it('does not gate WeCom access on Socket.IO or AI history readiness', () => {\n+  it('disables transfer when the socket is unavailable while keeping QR deep-link independent', () => {\n     mock.state = { status: 'error', message: '客服历史加载失败', messages: [], hasMore: false, loadingMore: false, moreError: null }\n-    mount()\n-    fireEvent.click(screen.getByRole('button', { name: '人工客服' }))\n-    expect(screen.getByRole('img', { name: '企业微信福利官二维码' })).toBeTruthy()\n+    mount('/service/chat#wecom')\n+    const button = screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement\n+    expect(button.disabled).toBe(true)\n+    fireEvent.click(button)\n     expect(mock.transfer).not.toHaveBeenCalled()\n+    expect(screen.getByRole('img', { name: '企业微信福利官二维码' })).toBeTruthy()\n+    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(screen.queryByRole('img', { name: '企业微信福利官二维码' })).toBeNull()\n   })\n \n   it('requires explicit latest-history inspection after uncertain human transfer', () => {\n     mock.human = { status: 'connected', canTransfer: false, transferring: false, active: false,\n       error: '登录状态已变化，人工转接结果尚未确认，请先检查聊天历史', needsHistoryCheck: true }\n     mount()\n-    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n     fireEvent.click(screen.getByRole('button', { name: '核对人工转接记录' }))\n     expect(mock.reload).toHaveBeenCalledOnce()\n     expect(mock.acknowledge).toHaveBeenCalledOnce()\ndiff --git a/src/pages/ApiServiceChat.tsx b/src/pages/ApiServiceChat.tsx\nindex 045b61b..294a6b4 100644\n--- a/src/pages/ApiServiceChat.tsx\n+++ b/src/pages/ApiServiceChat.tsx\n@@ -36,18 +36,13 @@ export default function ApiServiceChat() {\n   }\n   const inputDisabled = history.status !== 'ready' || chat.busy || chat.blocked ||\n     ((chat.humanAwait || human.active) && human.status !== 'connected')\n+  const humanEntryDisabled = history.status !== 'ready' || chat.busy || chat.blocked ||\n+    chat.humanAwait || human.active || !human.canTransfer\n   const messages = combineChatMessages(history.messages, chat.messages)\n   const location = useLocation()\n   const navigate = useNavigate()\n   const [wecomOpen, setWecomOpen] = useState(false)\n \n-  // Compatibility: reuse the exact #wecom action previously owned by the titlebar.\n-  // useHumanChat()/human.transfer() and its real Socket.IO flow are deliberately retained.\n-  const openWecom = () => {\n-    navigate({ pathname: location.pathname, search: location.search, hash: '#wecom' },\n-      { replace: true, state: location.state })\n-  }\n-\n   const closeWecom = () => {\n     setWecomOpen(false)\n     if (location.hash === '#wecom') {\n@@ -60,11 +55,11 @@ export default function ApiServiceChat() {\n   }, [location.hash])\n \n   return (\n-    <PageContainer className=\"flex min-h-full flex-col pb-0\" inset={false}>\n+    <PageContainer className=\"flex min-h-full min-w-0 w-full flex-col pb-0\" inset={false}>\n       <div className=\"px-4 pt-3\">\n         <p className=\"text-center text-xs text-text-tertiary\">AI 客服 {CHAT_BOT.name} 为您服务</p>\n       </div>\n-      <section className=\"flex-1 px-4 pb-4 pt-4\" aria-label=\"客服聊天历史\" data-human-stage={chat.humanAwait || human.active ? 'pending' : 'idle'}>\n+      <section className=\"min-w-0 w-full flex-1 px-4 pb-4 pt-4\" aria-label=\"客服聊天历史\" data-human-stage={chat.humanAwait || human.active ? 'pending' : 'idle'}>\n         {history.status === 'loading' ? (\n           <p role=\"status\" className=\"py-8 text-center text-sm text-text-secondary\">正在加载客服历史…</p>\n         ) : history.status === 'error' ? (\n@@ -98,23 +93,11 @@ export default function ApiServiceChat() {\n       </section>\n \n       <div className=\"sticky bottom-0 border-t border-border-subtle bg-background px-4 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3\">\n-        {chat.error ? (\n+        {chat.error && (\n           <div role=\"alert\" className=\"mb-3 rounded-container bg-surface px-3 py-2 text-xs text-danger-text\">\n             <p>{chat.error}</p>\n             <Button variant=\"outline\" onClick={() => { chat.clear(); history.reload() }}>检查最新历史记录</Button>\n           </div>\n-        ) : (\n-          <div role=\"status\" className=\"mb-2 text-center text-xs text-text-secondary\">\n-            {chat.phase === 'sending' ? '正在发送消息…'\n-              : chat.phase === 'streaming' ? 'AI 正在流式回复…'\n-              : chat.phase === 'syncing' ? '正在核对服务端消息记录…'\n-              : human.transferring ? '正在请求转人工…'\n-              : (chat.humanAwait || human.active) ? (human.status === 'connected'\n-                ? '已请求人工服务，等待客服真实回复（暂无接入人数或时间信息）'\n-                : '已进入人工模式，客服消息连接中断，请检查历史')\n-              : human.status === 'connected' ? 'AI 可用；可申请转人工，接入坐席状态未知'\n-              : 'AI 可用；人工消息通道尚未连通'}\n-          </div>\n         )}\n         {human.error && <div role=\"alert\" className=\"mb-2 text-center text-xs text-danger-text\">\n           <p>{human.error}</p>\n@@ -126,12 +109,14 @@ export default function ApiServiceChat() {\n         </div>}\n         <div className=\"flex items-end gap-2\">\n           <button type=\"button\" data-chat-human-entry\n-            onClick={openWecom}\n+            onClick={() => { void human.transfer() }}\n+            disabled={humanEntryDisabled}\n             aria-label=\"人工客服\"\n+            title={humanEntryDisabled ? '人工通道暂不可用' : '转接人工客服'}\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm disabled:opacity-50\">\n             <Headset className=\"h-4 w-4\" aria-hidden />人工\n           </button>\n-          <div className=\"flex min-h-11 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n+          <div className=\"flex min-h-11 min-w-0 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n             <input type=\"text\" value={draft} maxLength={5000} autoComplete=\"off\"\n               onChange={event => setDraft(event.target.value)}\n               onKeyDown={event => {\n@@ -139,7 +124,7 @@ export default function ApiServiceChat() {\n               }}\n               disabled={inputDisabled} aria-label=\"输入你的问题\"\n               placeholder={(chat.humanAwait || human.active) ? '向人工客服发消息（HTTP 发送）' : '请输入你的问题'}\n-              className=\"h-11 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\" />\n+              className=\"h-11 min-w-0 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\" />\n           </div>\n           {chat.busy ? (\n             <button type=\"button\" data-chat-cancel onClick={chat.cancel} aria-label=\"中止等待回复\"\n@@ -155,10 +140,9 @@ export default function ApiServiceChat() {\n           )}\n         </div>\n       </div>\n-      {/* 「人工」入口现复用原 #wecom 弹层；真实 human.transfer()/Socket.IO 链路保持原样。 */}\n       <BottomSheet\n         open={wecomOpen}\n-        title={CHAT_HUMAN_PROMPT.title}\n+        title=\"品牌福利官\"\n         onClose={closeWecom}\n         actions={<div className=\"flex justify-center\"><Button variant=\"ghost\" onClick={closeWecom}>{CHAT_HUMAN_PROMPT.cancelLabel}</Button></div>}\n       >\ndiff --git a/src/pages/ServiceChat.compat.test.tsx b/src/pages/ServiceChat.compat.test.tsx\nindex 9b9d57a..719b583 100644\n--- a/src/pages/ServiceChat.compat.test.tsx\n+++ b/src/pages/ServiceChat.compat.test.tsx\n@@ -37,17 +37,27 @@ afterEach(() => {\n })\n \n describe('legacy/mock ServiceChat compatibility', () => {\n-  it('opens the old WeCom sheet from 人工 without starting the preserved local queue', () => {\n+  it('restores the original human button without opening the separate WeCom QR', () => {\n     render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n-    const button = screen.getByRole('button', { name: '人工' })\n-    expect(button.hasAttribute('disabled')).toBe(false)\n+    const button = screen.getByRole('button', { name: '人工' }) as HTMLButtonElement\n+    expect(button.disabled).toBe(false)\n     fireEvent.click(button)\n-    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n-    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('idle')\n-    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('queuing')\n+    expect(button.disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(true)\n     expect(screen.queryByText('原企微二维码区域')).toBeNull()\n-    fireEvent.click(button)\n-    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+  })\n+\n+  it('keeps a pending message unsent when the user switches to a human queue', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n+    const input = screen.getByRole('textbox', { name: '输入你的问题' }) as HTMLInputElement\n+    fireEvent.change(input, { target: { value: '待发送的问题' } })\n+    const sendButton = screen.getByRole('button', { name: '发送' }) as HTMLButtonElement\n+    expect(sendButton.disabled).toBe(false)\n+    fireEvent.click(screen.getByRole('button', { name: '人工' }))\n+    expect(sendButton.disabled).toBe(true)\n+    fireEvent.click(sendButton)\n+    expect(input.value).toBe('待发送的问题')\n   })\n \n   it('keeps mock AI input available and original typed 人工 transfer reachable', () => {\ndiff --git a/src/pages/ServiceChat.tsx b/src/pages/ServiceChat.tsx\nindex b258929..501b70b 100644\n--- a/src/pages/ServiceChat.tsx\n+++ b/src/pages/ServiceChat.tsx\n@@ -50,14 +50,6 @@ function MockServiceChat() {\n \n   /** T013R5：企微二维码弹层 —— 与壳层 TitleBar 联动，靠 `#wecom` hash 触发 */\n   const [wecomOpen, setWecomOpen] = useState(false)\n-  // Compatibility: the visible \"人工\" entry opens the original #wecom sheet.\n-  // Keep requestHuman() and the existing human-stage implementation unchanged.\n-  const openWecom = () => {\n-    navigate(\n-      { pathname: location.pathname, search: location.search, hash: '#wecom' },\n-      { replace: true, state: location.state },\n-    )\n-  }\n   const closeWecom = () => {\n     setWecomOpen(false)\n     /* H016：通过 Router replace 清 hash，保留当前 history entry/state，不绕过 React Router。 */\n@@ -150,7 +142,7 @@ function MockServiceChat() {\n \n   const send = () => {\n     const text = draft.trim()\n-    if (!text) return\n+    if (!text || humanStage === 'queuing') return\n     setDraft('')\n \n     /** T013R4：输入「人工客服」等同于点击页内「人工」按钮，触发人工客服状态机 */\n@@ -184,15 +176,15 @@ function MockServiceChat() {\n       : CHAT_BOT.inputPlaceholder\n \n   return (\n-    <PageContainer className=\"flex min-h-full flex-col pb-0\" inset={false}>\n-      {/* T013R5：「企微客服」pill 移回壳层 TitleBar 右侧，页内顶部区只保留居中小字。 */}\n+    <PageContainer className=\"flex min-h-full min-w-0 w-full flex-col pb-0\" inset={false}>\n+      {/* 标题栏提供独立的福利官二维码入口；底部人工按钮只负责聊天转接。 */}\n       <div className=\"px-4 pt-3\">\n         <p className=\"text-center text-xs text-text-tertiary\">\n           AI 客服 {CHAT_BOT.name} 为您服务\n         </p>\n       </div>\n \n-      <div className=\"flex-1 px-4 pb-4 pt-4\" data-human-stage={humanStage}>\n+      <div className=\"min-w-0 w-full flex-1 px-4 pb-4 pt-4\" data-human-stage={humanStage}>\n         <ChatMessageList messages={messages} onRetry={retry} />\n       </div>\n \n@@ -201,14 +193,15 @@ function MockServiceChat() {\n           <button\n             type=\"button\"\n             data-chat-human-entry\n-            onClick={openWecom}\n+            onClick={requestHuman}\n+            disabled={humanStage !== 'idle'}\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm active:bg-surface-selected disabled:opacity-50\"\n           >\n             <Headset className=\"h-4 w-4\" aria-hidden />\n             人工\n           </button>\n \n-          <div className=\"flex min-h-11 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n+          <div className=\"flex min-h-11 min-w-0 flex-1 items-center rounded-container border-2 border-transparent bg-surface px-3 focus-within:border-border-focused\">\n             <input\n               type=\"text\"\n               inputMode=\"text\"\n@@ -224,7 +217,7 @@ function MockServiceChat() {\n               placeholder={inputPlaceholder}\n               disabled={inputDisabled}\n               aria-label=\"输入你的问题\"\n-              className=\"h-11 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\"\n+              className=\"h-11 min-w-0 w-full bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed\"\n             />\n           </div>\n \n@@ -232,7 +225,7 @@ function MockServiceChat() {\n             type=\"button\"\n             data-chat-send\n             onClick={send}\n-            disabled={!draft.trim()}\n+            disabled={inputDisabled || !draft.trim()}\n             aria-label=\"发送\"\n             className=\"flex h-11 w-11 flex-none items-center justify-center rounded-full bg-primary text-text-inverse active:bg-primary-pressed disabled:bg-disabled disabled:text-text-disabled\"\n           >\n@@ -241,11 +234,9 @@ function MockServiceChat() {\n         </div>\n       </div>\n \n-      {/* 兼容性处理：右上企微入口隐藏；底部「人工」复用原 #wecom 弹层。\n-        * 既有 requestHuman()／人工状态机保留，单独治理，不在此变更删除。 */}\n       <BottomSheet\n         open={wecomOpen}\n-        title={CHAT_HUMAN_PROMPT.title}\n+        title=\"品牌福利官\"\n         onClose={closeWecom}\n         actions={\n           <div className=\"flex justify-center\">\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex b22f4cb..9ef231c 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -89,6 +89,20 @@ test.describe('@formal-h5 active route smoke', () => {\n   }\n })\n \n+test('@formal-h5 chat long OSS URLs stay inside narrow WebView bubbles', async ({ page }) => {\n+  await page.setViewportSize({ width: 320, height: 700 })\n+  await page.goto('/service/chat', { waitUntil: 'domcontentloaded' })\n+  const longUrl = 'https://hexxueyantest.oss-cn-guangzhou.aliyuncs.com/storage/20261008/' + 'a'.repeat(90)\n+  await page.getByRole('textbox', { name: '输入你的问题' }).fill(longUrl)\n+  await page.getByRole('textbox', { name: '输入你的问题' }).press('Enter')\n+  await expect(page.locator('[data-chat-role=\"user\"]')).toContainText(longUrl)\n+  await expectHealthyFormalRoute(page)\n+  const overflowBubbles = await page.locator('[data-chat-bubble]').evaluateAll((nodes) =>\n+    nodes.filter((node) => node.scrollWidth > node.clientWidth + 1).length,\n+  )\n+  expect(overflowBubbles).toBe(0)\n+})\n+\n test('@formal-h5 first-level tab uses CSS fade without root snapshot', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n \n@@ -153,12 +167,14 @@ test('@formal-h5 H021 title-bar actions remain available', async ({ page }) => {\n \n   await page.goto('/service/chat', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n-  // Compat: the TitleBar WeCom pill is hidden; the original QR sheet is opened by bottom 人工.\n-  await expect(page.locator('[data-chat-wecom-entry]')).toHaveCount(0)\n+  // The real human action stays in the chat; the QR is a distinct titlebar action.\n   const humanEntry = page.locator('[data-chat-human-entry]')\n   await expect(humanEntry).toBeVisible()\n   await expect(humanEntry).toBeEnabled()\n   await humanEntry.click()\n+  await expect(page.locator('[data-human-stage]')).toHaveAttribute('data-human-stage', 'queuing')\n+  await expect(page.locator('[data-chat-human-sheet]')).toHaveCount(0)\n+  await page.getByRole('button', { name: '福利官二维码' }).click()\n   await expect(page.locator('[data-chat-human-sheet]')).toBeVisible()\n   await page.getByRole('button', { name: '取消' }).click()\n   await expect(page.locator('[data-chat-human-sheet]')).toHaveCount(0)",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 51,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T10:02:06.294Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1137,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T10:02:06.309Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1182,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T10:02:06.404Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 883,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T10:02:07.937Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 936,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T10:02:07.978Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 856,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T10:02:08.837Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 874,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T10:02:08.932Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-16T10:02:05.294Z",
    "duration": 4570.152,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}