@allbluecn/web-app 0.23.3 → 0.23.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/package.json +3 -3
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
  3. package/src/components/desktop/__tests__/cloud-login-rollback.test.tsx +161 -0
  4. package/src/components/desktop/cloud-login.tsx +29 -26
  5. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
  6. package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
  7. package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
  8. package/src/components/desktop-agent/agent-chat.tsx +221 -0
  9. package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
  10. package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
  11. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
  12. package/src/config/sidebar-routes.tsx +14 -1
  13. package/src/lib/changelog/sdk-versions.ts +1 -1
  14. package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
  15. package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
  16. package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
  17. package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
  18. package/src/lib/desktop-agent/availability.ts +39 -0
  19. package/src/lib/desktop-agent/connection.ts +72 -0
  20. package/src/lib/desktop-agent/errors.ts +21 -0
  21. package/src/lib/desktop-agent/tool-result.ts +170 -0
  22. package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
  23. package/src/lib/desktop-bridge-types.ts +22 -1
  24. package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
  25. package/src/lib/hotkeys/registry.ts +9 -1
  26. package/src/lib/queries/README.md +1 -0
  27. package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
  28. package/src/lib/queries/desktop-agent.ts +25 -0
  29. package/src/paraglide/messages/_index.d.ts +42 -0
  30. package/src/paraglide/messages/_index.js +42 -0
  31. package/src/paraglide/messages/agent_desktop_chatcwd1.d.ts +19 -0
  32. package/src/paraglide/messages/agent_desktop_chatcwd1.js +30 -0
  33. package/src/paraglide/messages/agent_desktop_chatemptyhint2.d.ts +17 -0
  34. package/src/paraglide/messages/agent_desktop_chatemptyhint2.js +30 -0
  35. package/src/paraglide/messages/agent_desktop_chatemptytitle2.d.ts +17 -0
  36. package/src/paraglide/messages/agent_desktop_chatemptytitle2.js +30 -0
  37. package/src/paraglide/messages/agent_desktop_chatindependenthint2.d.ts +17 -0
  38. package/src/paraglide/messages/agent_desktop_chatindependenthint2.js +30 -0
  39. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.d.ts +17 -0
  40. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.js +30 -0
  41. package/src/paraglide/messages/agent_desktop_chatplaceholder1.d.ts +17 -0
  42. package/src/paraglide/messages/agent_desktop_chatplaceholder1.js +30 -0
  43. package/src/paraglide/messages/agent_desktop_chatsend1.d.ts +17 -0
  44. package/src/paraglide/messages/agent_desktop_chatsend1.js +30 -0
  45. package/src/paraglide/messages/agent_desktop_chatstop1.d.ts +17 -0
  46. package/src/paraglide/messages/agent_desktop_chatstop1.js +30 -0
  47. package/src/paraglide/messages/agent_desktop_chatthinking1.d.ts +17 -0
  48. package/src/paraglide/messages/agent_desktop_chatthinking1.js +30 -0
  49. package/src/paraglide/messages/agent_desktop_errorconnection1.d.ts +17 -0
  50. package/src/paraglide/messages/agent_desktop_errorconnection1.js +30 -0
  51. package/src/paraglide/messages/agent_desktop_errorforbidden1.d.ts +17 -0
  52. package/src/paraglide/messages/agent_desktop_errorforbidden1.js +30 -0
  53. package/src/paraglide/messages/agent_desktop_errorgeneric1.d.ts +19 -0
  54. package/src/paraglide/messages/agent_desktop_errorgeneric1.js +30 -0
  55. package/src/paraglide/messages/agent_desktop_errorunauthorized1.d.ts +17 -0
  56. package/src/paraglide/messages/agent_desktop_errorunauthorized1.js +30 -0
  57. package/src/paraglide/messages/agent_desktop_errorunavailable1.d.ts +17 -0
  58. package/src/paraglide/messages/agent_desktop_errorunavailable1.js +30 -0
  59. package/src/paraglide/messages/agent_desktop_pagedescription1.d.ts +17 -0
  60. package/src/paraglide/messages/agent_desktop_pagedescription1.js +30 -0
  61. package/src/paraglide/messages/agent_desktop_title.d.ts +16 -0
  62. package/src/paraglide/messages/agent_desktop_title.js +29 -0
  63. package/src/paraglide/messages/agent_desktop_toolcountfiles2.d.ts +19 -0
  64. package/src/paraglide/messages/agent_desktop_toolcountfiles2.js +30 -0
  65. package/src/paraglide/messages/agent_desktop_toolcounthits2.d.ts +19 -0
  66. package/src/paraglide/messages/agent_desktop_toolcounthits2.js +30 -0
  67. package/src/paraglide/messages/agent_desktop_toolcountlines2.d.ts +19 -0
  68. package/src/paraglide/messages/agent_desktop_toolcountlines2.js +30 -0
  69. package/src/paraglide/messages/agent_desktop_toolemptydir2.d.ts +17 -0
  70. package/src/paraglide/messages/agent_desktop_toolemptydir2.js +30 -0
  71. package/src/paraglide/messages/agent_desktop_toolerror1.d.ts +17 -0
  72. package/src/paraglide/messages/agent_desktop_toolerror1.js +30 -0
  73. package/src/paraglide/messages/agent_desktop_toolfind1.d.ts +19 -0
  74. package/src/paraglide/messages/agent_desktop_toolfind1.js +30 -0
  75. package/src/paraglide/messages/agent_desktop_toolgrep1.d.ts +19 -0
  76. package/src/paraglide/messages/agent_desktop_toolgrep1.js +30 -0
  77. package/src/paraglide/messages/agent_desktop_toolls1.d.ts +19 -0
  78. package/src/paraglide/messages/agent_desktop_toolls1.js +30 -0
  79. package/src/paraglide/messages/agent_desktop_toolmore1.d.ts +19 -0
  80. package/src/paraglide/messages/agent_desktop_toolmore1.js +30 -0
  81. package/src/paraglide/messages/agent_desktop_toolnomatches2.d.ts +17 -0
  82. package/src/paraglide/messages/agent_desktop_toolnomatches2.js +30 -0
  83. package/src/paraglide/messages/agent_desktop_toolother1.d.ts +19 -0
  84. package/src/paraglide/messages/agent_desktop_toolother1.js +30 -0
  85. package/src/paraglide/messages/agent_desktop_toolread1.d.ts +19 -0
  86. package/src/paraglide/messages/agent_desktop_toolread1.js +30 -0
  87. package/src/paraglide/messages/agent_desktop_toolrunning1.d.ts +17 -0
  88. package/src/paraglide/messages/agent_desktop_toolrunning1.js +30 -0
  89. package/src/paraglide/messages/agent_desktop_tooltruncated1.d.ts +17 -0
  90. package/src/paraglide/messages/agent_desktop_tooltruncated1.js +30 -0
  91. package/src/paraglide/messages/agent_desktop_workspaceadd1.d.ts +17 -0
  92. package/src/paraglide/messages/agent_desktop_workspaceadd1.js +30 -0
  93. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.d.ts +17 -0
  94. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.js +30 -0
  95. package/src/paraglide/messages/agent_desktop_workspacedescription1.d.ts +17 -0
  96. package/src/paraglide/messages/agent_desktop_workspacedescription1.js +30 -0
  97. package/src/paraglide/messages/agent_desktop_workspaceempty1.d.ts +17 -0
  98. package/src/paraglide/messages/agent_desktop_workspaceempty1.js +30 -0
  99. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.d.ts +17 -0
  100. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.js +30 -0
  101. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.d.ts +17 -0
  102. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.js +30 -0
  103. package/src/paraglide/messages/agent_desktop_workspaceremove1.d.ts +17 -0
  104. package/src/paraglide/messages/agent_desktop_workspaceremove1.js +30 -0
  105. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.d.ts +17 -0
  106. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.js +30 -0
  107. package/src/paraglide/messages/agent_desktop_workspacetitle1.d.ts +17 -0
  108. package/src/paraglide/messages/agent_desktop_workspacetitle1.js +30 -0
  109. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.d.ts +17 -0
  110. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.js +30 -0
  111. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +17 -0
  112. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +30 -0
  113. package/src/paraglide/messages/sync_bind_rolled_back.d.ts +16 -0
  114. package/src/paraglide/messages/sync_bind_rolled_back.js +29 -0
  115. package/src/routeTree.gen.ts +23 -0
  116. package/src/routes/-tests/agent-gate.test.ts +45 -0
  117. package/src/routes/_nav/agent/route.lazy.tsx +63 -0
  118. package/src/routes/_nav/agent/route.tsx +21 -0
  119. package/src/routes/api/sync/account-status.ts +4 -2
  120. package/src/routes/api/sync/push.ts +11 -3
  121. package/src/server/__tests__/desktop-cloud-identity.test.ts +331 -0
  122. package/src/server/__tests__/desktop-session-merge.test.ts +42 -0
  123. package/src/server/__tests__/exchange-remote-token.test.ts +92 -10
  124. package/src/server/__tests__/remote-auth.test.ts +39 -0
  125. package/src/server/desktop-bootstrap.ts +16 -5
  126. package/src/server/desktop-cloud-identity.ts +247 -0
  127. package/src/server/remote-auth.ts +33 -1
  128. package/src/server/serverFns/auth/session.ts +27 -2
  129. package/src/server/serverFns/sync/transfer.ts +28 -27
  130. package/src/server/serverFns/workspace-kb.ts +10 -0
  131. package/src/server/sync/__tests__/ownership-transfer.test.ts +261 -48
  132. package/src/server/sync/__tests__/sync-first-push.e2e.test.ts +349 -0
  133. package/src/server/sync/account-status-cloud.ts +41 -0
  134. package/src/server/sync/ownership-transfer.ts +88 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.23.3",
3
+ "version": "0.23.5",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -60,9 +60,9 @@
60
60
  "dependencies": {
61
61
  "@ag-ui/core": "1.0.0",
62
62
  "@allbluecn/collab-core": "0.2.0",
63
- "@allbluecn/database": "0.12.2",
63
+ "@allbluecn/database": "0.12.3",
64
64
  "@allbluecn/kernel": "0.12.0",
65
- "@allbluecn/sync-core": "0.6.0",
65
+ "@allbluecn/sync-core": "0.7.0",
66
66
  "@ariakit/react": "^0.4.28",
67
67
  "@auth/core": "^0.41.2",
68
68
  "@auth/prisma-adapter": "^2.11.2",
@@ -92,6 +92,8 @@ function coveredByBoardRule(rel: string): boolean {
92
92
  * 新增业务页面时二者必居其一:在此登记,或在 BOARD_RULES 增加板块规则。
93
93
  */
94
94
  const PAGES_WITHOUT_BOARDS = [
95
+ // 本地文件助手(桌面本地优先 P3-B5):页面自带对话区,非业务板块,无全局 AI 板块上下文
96
+ "agent",
95
97
  "bugs",
96
98
  "chat",
97
99
  "changelog",
@@ -0,0 +1,161 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 桌面云端登录的回滚路径(C2 / 裁定 R4):
6
+ // ① 非空账号 → logoutRemoteFn + clearCloudSession 被调、不进入 bind(transferOwnershipFn
7
+ // 未调、bindBegin 未调)、不 reload(query 缓存清空留在 guest 视图);
8
+ // ② 探测不可达(与 ① 同路,提示文案分路);
9
+ // ③ 旧壳无 bind 三 IPC(不探测)→ 同 ① 回滚;
10
+ // ④ 空账号 → 正常 bind 流程(不回滚、不 logout)。
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
13
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
14
+ import type { ReactNode } from 'react'
15
+
16
+ const H = vi.hoisted(() => ({
17
+ exchangeRemoteTokenFn: vi.fn(),
18
+ logoutRemoteFn: vi.fn(),
19
+ probeCloudAccountStatusFn: vi.fn(),
20
+ transferOwnershipFn: vi.fn(),
21
+ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
22
+ bridge: null as unknown,
23
+ reload: vi.fn(),
24
+ }))
25
+
26
+ vi.mock('@/lib/desktop-bridge-types', () => ({ getDesktopBridge: () => H.bridge }))
27
+ vi.mock('@/server/serverFns/auth/session', () => ({
28
+ exchangeRemoteTokenFn: H.exchangeRemoteTokenFn,
29
+ logoutRemoteFn: H.logoutRemoteFn,
30
+ }))
31
+ vi.mock('@/server/serverFns/sync/transfer', () => ({
32
+ probeCloudAccountStatusFn: H.probeCloudAccountStatusFn,
33
+ transferOwnershipFn: H.transferOwnershipFn,
34
+ }))
35
+ vi.mock('@/components/ui/toast', () => ({ toast: H.toast }))
36
+
37
+ import { DesktopCloudLogin } from '../cloud-login'
38
+
39
+ type BridgeOverrides = Partial<{
40
+ bindBegin: (() => Promise<{ ok: boolean }>) | undefined
41
+ bindDone: (() => Promise<{ ok: boolean }>) | undefined
42
+ bindFail: (() => Promise<{ ok: boolean }>) | undefined
43
+ clearCloudSession: (() => Promise<{ ok: boolean }>) | undefined
44
+ }>
45
+
46
+ function makeBridge(overrides: BridgeOverrides = {}) {
47
+ return {
48
+ cloudLogin: vi.fn(async () => ({ ok: true, userId: 'cloud-1' })),
49
+ exchangeCloudSession: vi.fn(async () => ({ ok: true, token: 'tok-1' })),
50
+ bindBegin: vi.fn(async () => ({ ok: true })),
51
+ bindDone: vi.fn(async () => ({ ok: true })),
52
+ bindFail: vi.fn(async () => ({ ok: true })),
53
+ clearCloudSession: vi.fn(async () => ({ ok: true })),
54
+ ...overrides,
55
+ }
56
+ }
57
+
58
+ function wrapper({ children }: { children: ReactNode }) {
59
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
60
+ return <QueryClientProvider client={client}>{children}</QueryClientProvider>
61
+ }
62
+
63
+ /** 走完「输入 → 提交」并等激活链路结束 */
64
+ async function submitLogin() {
65
+ render(<DesktopCloudLogin />, { wrapper })
66
+ fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'me@example.com' } })
67
+ // 密码框无 accessible label 关联(htmlFor 指向 desktop-cloud-password,Label 有)
68
+ fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret' } })
69
+ fireEvent.click(screen.getByRole('button', { name: '登录云端' }))
70
+ await waitFor(() => {
71
+ expect(H.exchangeRemoteTokenFn).toHaveBeenCalled()
72
+ })
73
+ // 让后续 await 链(probe → 回滚 / bind)跑完
74
+ await waitFor(() => {
75
+ expect(
76
+ H.logoutRemoteFn.mock.calls.length + H.probeCloudAccountStatusFn.mock.calls.length,
77
+ ).toBeGreaterThan(0)
78
+ })
79
+ }
80
+
81
+ beforeEach(() => {
82
+ vi.clearAllMocks()
83
+ H.exchangeRemoteTokenFn.mockResolvedValue({ success: true, userId: 'cloud-1', planName: 'free' })
84
+ H.logoutRemoteFn.mockResolvedValue({ success: true })
85
+ H.transferOwnershipFn.mockResolvedValue({ tables: 1, rows: 1 })
86
+ H.bridge = makeBridge()
87
+ // jsdom 不支持导航:替换 location.reload 为可断言 spy(不可配置时静默跳过)
88
+ try {
89
+ Object.defineProperty(window, 'location', {
90
+ configurable: true,
91
+ value: { ...window.location, reload: H.reload },
92
+ })
93
+ } catch {
94
+ /* jsdom 限制:断言改为「未进入成功分支」 */
95
+ }
96
+ })
97
+
98
+ afterEach(() => {
99
+ cleanup()
100
+ })
101
+
102
+ describe('DesktopCloudLogin 不可转移路径回滚(C2 / 裁定 R4)', () => {
103
+ it('①非空账号:回滚云身份(logoutRemoteFn + clearCloudSession),不进入 bind', async () => {
104
+ H.probeCloudAccountStatusFn.mockResolvedValue({ empty: false })
105
+ const bridge = H.bridge as ReturnType<typeof makeBridge>
106
+
107
+ await submitLogin()
108
+
109
+ await waitFor(() => {
110
+ expect(H.logoutRemoteFn).toHaveBeenCalledWith({ data: {} })
111
+ expect(bridge.clearCloudSession).toHaveBeenCalled()
112
+ })
113
+ expect(H.transferOwnershipFn).not.toHaveBeenCalled()
114
+ expect(bridge.bindBegin).not.toHaveBeenCalled()
115
+ // 提示:非空账号 + 回滚说明;不清缓存即不会回到云态
116
+ expect(H.toast.info).toHaveBeenCalledTimes(2)
117
+ })
118
+
119
+ it('②探测不可达:同样回滚(宁可不转不可错转)', async () => {
120
+ H.probeCloudAccountStatusFn.mockResolvedValue({ empty: false, unreachable: true })
121
+ const bridge = H.bridge as ReturnType<typeof makeBridge>
122
+
123
+ await submitLogin()
124
+
125
+ await waitFor(() => {
126
+ expect(H.logoutRemoteFn).toHaveBeenCalledTimes(1)
127
+ expect(bridge.clearCloudSession).toHaveBeenCalledTimes(1)
128
+ })
129
+ expect(bridge.bindBegin).not.toHaveBeenCalled()
130
+ expect(H.transferOwnershipFn).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('③旧壳无 bind 三 IPC:不探测、直接回滚', async () => {
134
+ const bridge = makeBridge({ bindBegin: undefined, bindDone: undefined, bindFail: undefined })
135
+ H.bridge = bridge
136
+
137
+ await submitLogin()
138
+
139
+ await waitFor(() => {
140
+ expect(H.logoutRemoteFn).toHaveBeenCalledTimes(1)
141
+ expect(bridge.clearCloudSession).toHaveBeenCalledTimes(1)
142
+ })
143
+ expect(H.probeCloudAccountStatusFn).not.toHaveBeenCalled()
144
+ expect(H.transferOwnershipFn).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('④空账号:进入 bind 流程,不回滚', async () => {
148
+ H.probeCloudAccountStatusFn.mockResolvedValue({ empty: true })
149
+ const bridge = H.bridge as ReturnType<typeof makeBridge>
150
+
151
+ await submitLogin()
152
+
153
+ await waitFor(() => {
154
+ expect(bridge.bindBegin).toHaveBeenCalledTimes(1)
155
+ expect(H.transferOwnershipFn).toHaveBeenCalledWith({ data: { cloudUserId: 'cloud-1' } })
156
+ expect(bridge.bindDone).toHaveBeenCalledTimes(1)
157
+ })
158
+ expect(H.logoutRemoteFn).not.toHaveBeenCalled()
159
+ expect(bridge.clearCloudSession).not.toHaveBeenCalled()
160
+ })
161
+ })
@@ -13,9 +13,12 @@
13
13
  // P2 A8b 归属转移串联:exchange 成功后 claim 探测(probeCloudAccountStatusFn
14
14
  // 代持云会话 token 问 SaaS 账号是否空账号)——空账号进入 bind 流程(bindBegin
15
15
  // 停写快照 → transferOwnershipFn 单事务翻转 guest→cloud(身份字段省略,server
16
- // 侧自取 SaaS profile)→ bindDone 清快照触发 push → reload);非空账号一期不
17
- // 支持转移(提示后仍 reload,云身份生效、本地数据保留 guest 属主);转移失败
18
- // bindFail 恢复快照回 guest 态(toast 提示,不 reload)。
16
+ // 侧自取 SaaS profile)→ bindDone 清快照触发 push → reload)。
17
+ // P3 收尾 C2(裁定 R4):非空账号 / 探测不可达 / 旧壳无 bind 三 IPC 一律**回滚
18
+ // 云身份**(logoutRemoteFn 清 remote-auth + clearCloudSession 清 cloud-session
19
+ // + 清 query 缓存),留在 guest 会话并提示「一期仅支持空账号」——不再像旧版
20
+ // 那样激活云身份后 reload(那会留下「云态零数据 + 写入 P2003」的坏态)。
21
+ // 转移失败 bindFail 恢复快照回 guest 态(toast 提示,不 reload)。
19
22
  // 独立于 web 登录页——web 表单走本地 serverFn authjs,二者不混用。
20
23
  import { useEffect, useState } from "react"
21
24
  import { useQueryClient } from "@tanstack/react-query"
@@ -75,11 +78,14 @@ export function DesktopCloudLogin() {
75
78
  return
76
79
  }
77
80
 
78
- // claim 探测(A8b):探测异常按非空处理(宁可不转不可错转)。旧壳无
79
- // bind 三方法时同样只激活云身份不转移(优雅降级,A10 行为)。
81
+ // 不可转移判定(C2 / 裁定 R4):非空账号 / 探测不可达 / 旧壳不支持一期
82
+ // 均**不得**把用户带进「云身份已激活但本地数据仍属 guest」的坏态——
83
+ // 回滚云身份(清 remote-auth + cloud-session)留在 guest 会话,提示后
84
+ // 不 reload(query 缓存清空即回到 guest 数据视图)。
80
85
  const { bindBegin, bindDone, bindFail } = bridge
86
+ const shellSupported = Boolean(bindBegin && bindDone && bindFail)
81
87
  let probe: { empty: boolean; unreachable?: boolean } = { empty: false, unreachable: true }
82
- if (bindBegin && bindDone && bindFail) {
88
+ if (shellSupported) {
83
89
  try {
84
90
  probe = await probeCloudAccountStatusFn()
85
91
  } catch {
@@ -87,30 +93,27 @@ export function DesktopCloudLogin() {
87
93
  }
88
94
  }
89
95
 
90
- if (!(bindBegin && bindDone && bindFail)) {
91
- // 旧壳降级(无 bind 三 IPC):只激活云身份不转移(A10 行为),提示后
92
- // 仍 reload 生效(本地数据保留 guest 属主)。与探测不可达分路如实提示。
93
- toast.info(m.sync_bind_shell_unsupported())
94
- setOk(true)
95
- queryClient.clear()
96
- window.location.reload()
97
- return
98
- }
99
- if (!probe.empty) {
100
- // 非空账号:一期不支持转移——云身份已激活,仍 reload 生效(本地数据
101
- // 保留 guest 属主,二期做云端 bootstrap 下载)。探测不可达与「确认
102
- // 非空」分路提示(不可达 ≠ 账号有数据,勿误导)。
103
- toast.info(probe.unreachable ? m.sync_bind_probe_unreachable() : m.sync_bind_nonempty())
104
- setOk(true)
96
+ if (!shellSupported || !probe.empty) {
97
+ await logoutRemoteFn({ data: {} }).catch(() => null)
98
+ await bridge.clearCloudSession?.().catch(() => null)
105
99
  queryClient.clear()
106
- window.location.reload()
100
+ toast.info(
101
+ !shellSupported
102
+ ? m.sync_bind_shell_unsupported()
103
+ : probe.unreachable
104
+ ? m.sync_bind_probe_unreachable()
105
+ : m.sync_bind_nonempty(),
106
+ )
107
+ toast.info(m.sync_bind_rolled_back())
107
108
  return
108
109
  }
109
110
 
110
- // 空账号 → 归属转移 bind 流程:main 侧停写快照(bindBegin)→ renderer 调
111
- // 转移 serverFn(localhost cookie 过鉴权;身份字段省略,server 自取
112
- // profile)→ 成功 bindDone(清快照 + 触发 push);失败 bindFail(停
113
- // backend 还原快照重启,回 guest 态)。
111
+ // 归属转移 bind 流程:main 侧停写快照(bindBegin)→ renderer 调转移
112
+ // serverFn(localhost cookie 过鉴权;身份字段省略,server 自取 profile)
113
+ // → 成功 bindDone(清快照 + 触发 push);失败 bindFail(停 backend 还原
114
+ // 快照重启,回 guest 态)。此处 shellSupported 必为 true(false 已在上方
115
+ // 回滚分支返回)——守卫只为收窄可选方法类型。
116
+ if (!bindBegin || !bindDone || !bindFail) return
114
117
  setBinding(true)
115
118
  const begin = await bindBegin()
116
119
  if (!begin.ok) {
@@ -0,0 +1,194 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手 · 对话区(桌面本地优先 P3-B5):useChat 接线(懒取端点的 connection
6
+ // 包装 + 稳定 threadId + forwardedProps.cwd)、消息渲染(空 assistant 消息跳过、
7
+ // 工具调用折叠卡按 toolCallId 关联结果)、输入(Enter 发送 / Shift+Enter /
8
+ // 输入法组合)、中止(按钮 + agent-abort 热键仅运行中生效)、错误文案映射。
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
11
+ import { HotkeysProvider } from '@tanstack/react-hotkeys'
12
+ import type { UIMessage } from '@tanstack/ai-client'
13
+ import { TooltipProvider } from '@/components/ui/tooltip'
14
+ import { appHotkeys } from '@/lib/hotkeys/registry'
15
+ import { m } from '@/paraglide/messages.js'
16
+
17
+ const chat = vi.hoisted(() => ({
18
+ messages: [] as unknown[],
19
+ isLoading: false,
20
+ error: undefined as Error | undefined,
21
+ sendMessage: vi.fn(async (_text: string) => {}),
22
+ stop: vi.fn(),
23
+ options: [] as Array<Record<string, unknown>>,
24
+ }))
25
+
26
+ vi.mock('@tanstack/ai-react', () => ({
27
+ useChat: (options: Record<string, unknown>) => {
28
+ chat.options.push(options)
29
+ return {
30
+ messages: chat.messages,
31
+ isLoading: chat.isLoading,
32
+ error: chat.error,
33
+ sendMessage: chat.sendMessage,
34
+ stop: chat.stop,
35
+ }
36
+ },
37
+ }))
38
+ // markdown 渲染器替身(react-markdown/shiki 与本组件行为无关,保测试轻量)
39
+ vi.mock('@/components/harness/ai-message/components/markdown-content', () => ({
40
+ MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
41
+ }))
42
+
43
+ import { AgentChat } from '../agent-chat'
44
+
45
+ const piResult = (text: string) => ({ content: [{ type: 'text', text }], details: undefined })
46
+
47
+ function renderChat(props: { cwd?: string | null; onRunningChange?: (running: boolean) => void } = {}) {
48
+ const ui = (cwd: string | null) => (
49
+ <HotkeysProvider appHotkeys={appHotkeys}>
50
+ <TooltipProvider>
51
+ <AgentChat cwd={cwd} onRunningChange={props.onRunningChange} />
52
+ </TooltipProvider>
53
+ </HotkeysProvider>
54
+ )
55
+ const cwd = props.cwd === undefined ? '/Users/x/proj' : props.cwd
56
+ const utils = render(ui(cwd))
57
+ return { ...utils, rerenderWith: (next: string | null) => utils.rerender(ui(next)) }
58
+ }
59
+
60
+ beforeEach(() => {
61
+ chat.messages = []
62
+ chat.isLoading = false
63
+ chat.error = undefined
64
+ chat.options = []
65
+ chat.sendMessage.mockClear()
66
+ chat.stop.mockClear()
67
+ })
68
+
69
+ afterEach(() => cleanup())
70
+
71
+ describe('AgentChat · useChat 接线', () => {
72
+ it('connection 为懒取端点的包装(含 connect)、threadId 跨渲染稳定、forwardedProps 携带 cwd', () => {
73
+ const { rerenderWith } = renderChat({ cwd: '/Users/x/proj' })
74
+ const first = chat.options[0]!
75
+ expect(typeof (first.connection as { connect?: unknown }).connect).toBe('function')
76
+ expect(typeof first.threadId).toBe('string')
77
+ expect(first.forwardedProps).toEqual({ cwd: '/Users/x/proj' })
78
+ rerenderWith('/Users/x/proj')
79
+ const last = chat.options.at(-1)!
80
+ expect(last.threadId).toBe(first.threadId)
81
+ expect(last.connection).toBe(first.connection)
82
+ })
83
+ })
84
+
85
+ describe('AgentChat · 消息渲染', () => {
86
+ it('user 气泡 + assistant 正文;pi 回显产生的空 assistant 消息被跳过', () => {
87
+ chat.messages = [
88
+ { id: 'u1', role: 'user', parts: [{ type: 'text', content: '列出目录' }] },
89
+ { id: 'msg-1', role: 'assistant', parts: [] },
90
+ { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', content: ' ' }] },
91
+ { id: 'msg-3', role: 'assistant', parts: [{ type: 'text', content: '目录里有 a.txt' }] },
92
+ ] satisfies UIMessage[]
93
+ renderChat()
94
+ expect(screen.getByText('列出目录')).toBeInTheDocument()
95
+ expect(screen.getByText('目录里有 a.txt')).toBeInTheDocument()
96
+ const rendered = screen.getAllByTestId('agent-message').map((el) => el.getAttribute('data-role'))
97
+ expect(rendered).toEqual(['user', 'assistant'])
98
+ })
99
+
100
+ it('工具调用:tool-call 渲染折叠卡,同 toolCallId 的 tool-result 不单独渲染', () => {
101
+ chat.messages = [
102
+ { id: 'u1', role: 'user', parts: [{ type: 'text', content: '总结项目' }] },
103
+ {
104
+ id: 'msg-2',
105
+ role: 'assistant',
106
+ parts: [
107
+ { type: 'tool-call', id: 'call_ls_1', name: 'ls', arguments: '{"path":"."}', state: 'complete', output: piResult('README.md\nsrc/') },
108
+ { type: 'tool-result', toolCallId: 'call_ls_1', content: JSON.stringify(piResult('README.md\nsrc/')), state: 'complete' },
109
+ ],
110
+ },
111
+ ] satisfies UIMessage[]
112
+ renderChat()
113
+ expect(screen.getAllByTestId('agent-tool-card-trigger')).toHaveLength(1)
114
+ expect(screen.getByText(m.agent_desktop_toolLs({ target: '.' }))).toBeInTheDocument()
115
+ expect(screen.getByText(m.agent_desktop_toolCountFiles({ count: 2 }))).toBeInTheDocument()
116
+ })
117
+
118
+ it('空对话:引导空态', () => {
119
+ renderChat()
120
+ expect(screen.getByText(m.agent_desktop_chatEmptyTitle())).toBeInTheDocument()
121
+ })
122
+ })
123
+
124
+ describe('AgentChat · 输入与发送', () => {
125
+ it('Enter 发送(trim)并清空输入;Shift+Enter 与输入法组合中的 Enter 不发送', () => {
126
+ renderChat()
127
+ const textarea = screen.getByRole('textbox')
128
+ fireEvent.change(textarea, { target: { value: ' 总结这个项目 ' } })
129
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
130
+ fireEvent.keyDown(textarea, { key: 'Enter', isComposing: true })
131
+ expect(chat.sendMessage).not.toHaveBeenCalled()
132
+ fireEvent.keyDown(textarea, { key: 'Enter' })
133
+ expect(chat.sendMessage).toHaveBeenCalledWith('总结这个项目')
134
+ expect(textarea).toHaveValue('')
135
+ })
136
+
137
+ it('发送按钮:空输入禁用,有内容可点', () => {
138
+ renderChat()
139
+ const send = screen.getByRole('button', { name: m.agent_desktop_chatSend() })
140
+ expect(send).toBeDisabled()
141
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: '找出所有 TODO' } })
142
+ fireEvent.click(screen.getByRole('button', { name: m.agent_desktop_chatSend() }))
143
+ expect(chat.sendMessage).toHaveBeenCalledWith('找出所有 TODO')
144
+ })
145
+
146
+ it('无授权目录(cwd=null):输入禁用 + 引导先选目录', () => {
147
+ renderChat({ cwd: null })
148
+ expect(screen.getByRole('textbox')).toBeDisabled()
149
+ expect(screen.getByTestId('agent-chat-cwd')).toHaveTextContent(m.agent_desktop_chatNoWorkspace())
150
+ })
151
+ })
152
+
153
+ describe('AgentChat · 中止(按钮 + agent-abort 热键)', () => {
154
+ it('运行中:显示中止按钮(替代发送)并调用 stop;思考指示可见', () => {
155
+ chat.isLoading = true
156
+ renderChat()
157
+ expect(screen.queryByRole('button', { name: m.agent_desktop_chatSend() })).toBeNull()
158
+ fireEvent.click(screen.getByRole('button', { name: m.agent_desktop_chatStop() }))
159
+ expect(chat.stop).toHaveBeenCalledTimes(1)
160
+ expect(screen.getByTestId('agent-chat-thinking')).toBeInTheDocument()
161
+ })
162
+
163
+ it('运行中按 Mod+. → stop(声明即绑定:appHotkeys["agent-abort"])', () => {
164
+ chat.isLoading = true
165
+ renderChat()
166
+ act(() => {
167
+ fireEvent.keyDown(document.body, { key: '.', code: 'Period', ctrlKey: true })
168
+ })
169
+ expect(chat.stop).toHaveBeenCalledTimes(1)
170
+ })
171
+
172
+ it('未运行时 Mod+. 不触发 stop(enabled 门控)', () => {
173
+ renderChat()
174
+ act(() => {
175
+ fireEvent.keyDown(document.body, { key: '.', code: 'Period', ctrlKey: true })
176
+ })
177
+ expect(chat.stop).not.toHaveBeenCalled()
178
+ })
179
+
180
+ it('运行态变化通知父级(锁定目录切换用)', () => {
181
+ const onRunningChange = vi.fn()
182
+ chat.isLoading = true
183
+ renderChat({ onRunningChange })
184
+ expect(onRunningChange).toHaveBeenLastCalledWith(true)
185
+ })
186
+ })
187
+
188
+ describe('AgentChat · 错误文案', () => {
189
+ it('401 令牌失效 → 可读提示(role=alert)', () => {
190
+ chat.error = Object.assign(new Error('HTTP error! status: 401'), { name: 'StreamHttpError', status: 401 })
191
+ renderChat()
192
+ expect(screen.getByRole('alert')).toHaveTextContent(m.agent_desktop_errorUnauthorized())
193
+ })
194
+ })
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手 · 工具调用折叠卡(桌面本地优先 P3-B5):默认折叠,头部展示
6
+ // 「工具 + 目标 + 计数」;展开渲染文件名(ls/find)、命中行(grep)、文件内容
7
+ // (read);运行中 / 错误 / 空结果 / 截断各态。part 形态取自 TanStack processor
8
+ // 处理 B3 mapper 事件后的真实结果(tool-call part.output = pi AgentToolResult)。
9
+ import { afterEach, describe, expect, it } from 'vitest'
10
+ import type { ComponentProps } from 'react'
11
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
12
+ import { m } from '@/paraglide/messages.js'
13
+ import { AgentToolCard } from '../agent-tool-card'
14
+
15
+ type Part = ComponentProps<typeof AgentToolCard>['part']
16
+ type Result = NonNullable<ComponentProps<typeof AgentToolCard>['result']>
17
+
18
+ const piResult = (text: string) => ({ content: [{ type: 'text', text }], details: undefined })
19
+
20
+ function toolCall(name: string, args: Record<string, unknown>, extra: Partial<Part> = {}): Part {
21
+ return {
22
+ type: 'tool-call',
23
+ id: `call-${name}`,
24
+ name,
25
+ arguments: JSON.stringify(args),
26
+ state: 'input-streaming',
27
+ ...extra,
28
+ } as Part
29
+ }
30
+
31
+ const trigger = () => screen.getByTestId('agent-tool-card-trigger')
32
+
33
+ afterEach(() => cleanup())
34
+
35
+ describe('AgentToolCard', () => {
36
+ it('grep 默认折叠:头部「搜索 模式」+ 命中计数,命中行不渲染', () => {
37
+ render(
38
+ <AgentToolCard
39
+ part={toolCall('grep', { pattern: 'TODO' }, {
40
+ state: 'complete',
41
+ output: piResult('src/a.ts:12: const TODO = 1\ndocs/b.md:3: TODO: write docs'),
42
+ })}
43
+ />,
44
+ )
45
+ expect(screen.getByText(m.agent_desktop_toolGrep({ target: 'TODO' }))).toBeInTheDocument()
46
+ expect(screen.getByText(m.agent_desktop_toolCountHits({ count: 2 }))).toBeInTheDocument()
47
+ expect(trigger()).toHaveAttribute('aria-expanded', 'false')
48
+ expect(screen.queryByText('const TODO = 1')).toBeNull()
49
+ })
50
+
51
+ it('grep 展开:命中行渲染「文件:行号」+ 行内容', () => {
52
+ render(
53
+ <AgentToolCard
54
+ part={toolCall('grep', { pattern: 'TODO' }, {
55
+ state: 'complete',
56
+ output: piResult('src/a.ts:12: const TODO = 1\ndocs/b.md:3: TODO: write docs'),
57
+ })}
58
+ />,
59
+ )
60
+ fireEvent.click(trigger())
61
+ expect(trigger()).toHaveAttribute('aria-expanded', 'true')
62
+ expect(screen.getByText('src/a.ts:12')).toBeInTheDocument()
63
+ expect(screen.getByText('const TODO = 1')).toBeInTheDocument()
64
+ expect(screen.getByText('docs/b.md:3')).toBeInTheDocument()
65
+ })
66
+
67
+ it('ls 展开:文件名列表(目录保留 / 后缀),target 缺省为 .', () => {
68
+ render(<AgentToolCard part={toolCall('ls', {}, { state: 'complete', output: piResult('README.md\nsrc/') })} />)
69
+ expect(screen.getByText(m.agent_desktop_toolLs({ target: '.' }))).toBeInTheDocument()
70
+ expect(screen.getByText(m.agent_desktop_toolCountFiles({ count: 2 }))).toBeInTheDocument()
71
+ fireEvent.click(trigger())
72
+ expect(screen.getByText('README.md')).toBeInTheDocument()
73
+ expect(screen.getByText('src/')).toBeInTheDocument()
74
+ })
75
+
76
+ it('read:tool-call part 无 output 时回落 tool-result part 内容(JSON 串)', () => {
77
+ render(
78
+ <AgentToolCard
79
+ part={toolCall('read', { path: 'notes.md' }, { state: 'input-complete' })}
80
+ result={{
81
+ type: 'tool-result',
82
+ toolCallId: 'call-read',
83
+ content: JSON.stringify(piResult('# Notes\nhello')),
84
+ state: 'complete',
85
+ } as Result}
86
+ />,
87
+ )
88
+ expect(screen.getByText(m.agent_desktop_toolRead({ target: 'notes.md' }))).toBeInTheDocument()
89
+ expect(screen.getByText(m.agent_desktop_toolCountLines({ count: 2 }))).toBeInTheDocument()
90
+ fireEvent.click(trigger())
91
+ expect(screen.getByText(/# Notes/)).toBeInTheDocument()
92
+ })
93
+
94
+ it('运行中(尚无结果):执行中指示,不可展开', () => {
95
+ render(<AgentToolCard part={toolCall('find', { pattern: '**/*.ts' })} />)
96
+ expect(screen.getByText(m.agent_desktop_toolFind({ target: '**/*.ts' }))).toBeInTheDocument()
97
+ expect(screen.getByText(m.agent_desktop_toolRunning())).toBeInTheDocument()
98
+ fireEvent.click(trigger())
99
+ expect(trigger()).toHaveAttribute('aria-expanded', 'false')
100
+ })
101
+
102
+ it('错误态(output-error):失败徽标,展开显示错误原文', () => {
103
+ render(
104
+ <AgentToolCard
105
+ part={toolCall('read', { path: 'secret.txt' }, { state: 'error', output: piResult('ENOENT: no such file') })}
106
+ />,
107
+ )
108
+ expect(screen.getByText(m.agent_desktop_toolError())).toBeInTheDocument()
109
+ fireEvent.click(trigger())
110
+ expect(screen.getByText('ENOENT: no such file')).toBeInTheDocument()
111
+ })
112
+
113
+ it('空结果哨兵:grep 无匹配 → 「无匹配结果」,不可展开', () => {
114
+ render(
115
+ <AgentToolCard
116
+ part={toolCall('grep', { pattern: 'zzz' }, { state: 'complete', output: piResult('No matches found') })}
117
+ />,
118
+ )
119
+ expect(screen.getByText(m.agent_desktop_toolNoMatches())).toBeInTheDocument()
120
+ fireEvent.click(trigger())
121
+ expect(trigger()).toHaveAttribute('aria-expanded', 'false')
122
+ })
123
+
124
+ it('pi 截断提示 → 「已截断」徽标;超过展示上限 → 「还有 N 项未显示」', () => {
125
+ const many = Array.from({ length: 53 }, (_, i) => `f${i}.txt`).join('\n')
126
+ render(
127
+ <AgentToolCard
128
+ part={toolCall('find', { pattern: '*.txt' }, {
129
+ state: 'complete',
130
+ output: piResult(`${many}\n\n[1000 results limit reached]`),
131
+ })}
132
+ />,
133
+ )
134
+ expect(screen.getByText(m.agent_desktop_toolTruncated())).toBeInTheDocument()
135
+ fireEvent.click(trigger())
136
+ expect(screen.getByText(m.agent_desktop_toolMore({ count: 3 }))).toBeInTheDocument()
137
+ })
138
+ })