@allbluecn/web-app 0.23.6 → 0.24.0

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 (91) hide show
  1. package/package.json +2 -2
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +0 -2
  3. package/src/components/harness/ai-chat-history/components/conversation-item.tsx +6 -0
  4. package/src/components/harness/ai-chat-history/types.ts +4 -0
  5. package/src/components/harness/chat-ui.tsx +20 -0
  6. package/src/components/harness/composer/harness-composer.tsx +43 -10
  7. package/src/components/harness/composer/harness-engine-control.tsx +129 -0
  8. package/src/components/harness/harness-workbench-page.tsx +129 -17
  9. package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
  10. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -9
  11. package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
  12. package/src/components/shared-kit/auth/auth-client.ts +4 -0
  13. package/src/config/sidebar-routes.tsx +0 -13
  14. package/src/lib/__tests__/desktop-logout.test.ts +239 -0
  15. package/src/lib/__tests__/session.test.ts +85 -2
  16. package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
  17. package/src/lib/auth/anonymous-safe.ts +5 -0
  18. package/src/lib/auth/page-gate.ts +11 -1
  19. package/src/lib/changelog/sdk-versions.ts +1 -1
  20. package/src/lib/desktop-env.ts +25 -0
  21. package/src/lib/desktop-logout.ts +117 -0
  22. package/src/lib/hotkeys/__tests__/registry.test.ts +4 -11
  23. package/src/lib/hotkeys/registry.ts +0 -8
  24. package/src/lib/session.ts +70 -0
  25. package/src/paraglide/README.md +1 -1
  26. package/src/paraglide/messages/_index.d.ts +12 -2
  27. package/src/paraglide/messages/_index.js +12 -2
  28. package/src/paraglide/messages/{agent_desktop_title.d.ts → harness_engine_cloud.d.ts} +5 -5
  29. package/src/paraglide/messages/harness_engine_cloud.js +29 -0
  30. package/src/paraglide/messages/harness_engine_label.d.ts +16 -0
  31. package/src/paraglide/messages/harness_engine_label.js +29 -0
  32. package/src/paraglide/messages/harness_engine_local.d.ts +16 -0
  33. package/src/paraglide/messages/harness_engine_local.js +29 -0
  34. package/src/paraglide/messages/harness_engine_localbadge1.d.ts +17 -0
  35. package/src/paraglide/messages/harness_engine_localbadge1.js +30 -0
  36. package/src/paraglide/messages/harness_engine_localhint1.d.ts +17 -0
  37. package/src/paraglide/messages/harness_engine_localhint1.js +30 -0
  38. package/src/paraglide/messages/harness_engine_localnoattachments2.d.ts +17 -0
  39. package/src/paraglide/messages/harness_engine_localnoattachments2.js +30 -0
  40. package/src/paraglide/messages/harness_engine_switchlocked1.d.ts +17 -0
  41. package/src/paraglide/messages/harness_engine_switchlocked1.js +30 -0
  42. package/src/paraglide/messages/harness_localworkspace_choose1.d.ts +17 -0
  43. package/src/paraglide/messages/harness_localworkspace_choose1.js +30 -0
  44. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.d.ts +17 -0
  45. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.js +30 -0
  46. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.d.ts +17 -0
  47. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.js +30 -0
  48. package/src/paraglide/messages/harness_localworkspace_manage1.d.ts +17 -0
  49. package/src/paraglide/messages/harness_localworkspace_manage1.js +30 -0
  50. package/src/paraglide/messages/harness_localworkspace_required1.d.ts +17 -0
  51. package/src/paraglide/messages/harness_localworkspace_required1.js +30 -0
  52. package/src/routeTree.gen.ts +0 -23
  53. package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
  54. package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
  55. package/src/routes/_login/login/route.tsx +18 -3
  56. package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
  57. package/src/routes/_nav/harness/$threadId.lazy.tsx +19 -2
  58. package/src/routes/_nav/harness/$threadId.tsx +8 -2
  59. package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
  60. package/src/routes/_nav/route.tsx +5 -1
  61. package/src/routes/_nav/settings/account/route.tsx +5 -0
  62. package/src/routes/_nav/settings/team/route.tsx +5 -0
  63. package/src/routes/api/auth/$.ts +87 -2
  64. package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
  65. package/src/server/__tests__/chat-conversations.test.ts +13 -3
  66. package/src/server/__tests__/desktop-bootstrap.test.ts +0 -1
  67. package/src/server/ai-gateway/__tests__/local-agent-adapter.test.ts +110 -0
  68. package/src/server/ai-gateway/__tests__/unified-router-local-agent.test.ts +63 -0
  69. package/src/server/ai-gateway/chat-handler.ts +68 -2
  70. package/src/server/ai-gateway/chat-stream-builder.ts +52 -0
  71. package/src/server/ai-gateway/local-agent-adapter.ts +232 -0
  72. package/src/server/ai-gateway/unified-router.ts +49 -6
  73. package/src/server/desktop-guest.ts +5 -1
  74. package/src/server/serverFns/auth/session.ts +19 -10
  75. package/src/server/serverFns/chat/conversations.ts +122 -1
  76. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +0 -194
  77. package/src/components/desktop-agent/agent-chat.tsx +0 -222
  78. package/src/lib/desktop-agent/__tests__/connection.test.ts +0 -310
  79. package/src/lib/desktop-agent/__tests__/errors.test.ts +0 -43
  80. package/src/lib/desktop-agent/connection.ts +0 -93
  81. package/src/lib/desktop-agent/errors.ts +0 -21
  82. package/src/paraglide/messages/agent_desktop_title.js +0 -29
  83. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +0 -17
  84. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +0 -30
  85. package/src/routes/-tests/agent-gate.test.ts +0 -45
  86. package/src/routes/_nav/agent/route.lazy.tsx +0 -63
  87. package/src/routes/_nav/agent/route.tsx +0 -21
  88. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-tool-card.test.tsx +0 -0
  89. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-workspace-card.test.tsx +0 -0
  90. /package/src/components/{desktop-agent → harness/local-agent}/agent-tool-card.tsx +0 -0
  91. /package/src/components/{desktop-agent → harness/local-agent}/agent-workspace-card.tsx +0 -0
@@ -0,0 +1,185 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
2
+ import type React from 'react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ // 桌面登出分支回归哨兵(A9 反向转移编排 + 会话来源判别):
6
+ // /api/auth/session 改为会回落桌面身份并带 desktopSession 标记后,clientSession
7
+ // 不再为空——旧判据 `bridge && !clientSession` 会把云态登出错走成 authjs signOut
8
+ // (数据留在云属主,桌面再也回不到 guest)。本文件钉死三条分支与编排顺序:
9
+ // ① desktopSession=guest → 本地 guest 登出(不反向转移)
10
+ // ② 无标记(本地 authjs 凭据登录)→ signOut(不反向转移)
11
+ // ③ desktopSession=cloud → 反向转移编排(lock begin → reverse → clear → lock end)
12
+ const H = vi.hoisted(() => ({
13
+ reload: vi.fn(),
14
+ queryClient: { clear: vi.fn(), resetQueries: vi.fn() },
15
+ reverseTransferOwnershipFn: vi.fn(async () => ({ tables: 1, rows: 1 })),
16
+ logoutRemoteFn: vi.fn(async () => ({ success: true })),
17
+ signOut: vi.fn(async () => ({ success: true })),
18
+ transferLock: vi.fn(async (_action?: 'begin' | 'end') => ({ ok: true })),
19
+ clearCloudSession: vi.fn(async () => ({ ok: true })),
20
+ // 由各用例覆盖:useSession() 的返回值
21
+ session: null as unknown,
22
+ }))
23
+
24
+ const CLOUD_SESSION = {
25
+ user: { id: 'cloud-1', name: '云用户', email: 'cloud@example.com', role: 'USER', isGuest: false },
26
+ expires: 'never',
27
+ desktopSession: 'cloud' as const,
28
+ }
29
+ const GUEST_SESSION = {
30
+ user: { id: 'local-guest', name: '本地用户', email: 'guest@localhost', role: 'USER', isGuest: true },
31
+ expires: 'never',
32
+ desktopSession: 'guest' as const,
33
+ }
34
+ const LOCAL_CREDENTIAL_SESSION = {
35
+ user: { id: 'authjs-user-1', name: '本地账号', email: 'local@example.com', role: 'USER', isGuest: false },
36
+ expires: '2026-12-31T00:00:00.000Z',
37
+ }
38
+
39
+ vi.mock('@/lib/auth/auth-client', () => ({
40
+ useSession: () => ({ data: H.session }),
41
+ signOut: H.signOut,
42
+ }))
43
+ vi.mock('@/lib/query-client-seam', () => ({
44
+ getQueryClient: () => H.queryClient,
45
+ }))
46
+ vi.mock('@/components/ui/sidebar', () => ({
47
+ SidebarMenu: ({ children }: { children?: React.ReactNode }) => <ul>{children}</ul>,
48
+ SidebarMenuItem: ({ children }: { children?: React.ReactNode }) => <li>{children}</li>,
49
+ SidebarMenuButton: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
50
+ <button onClick={onClick}>{children}</button>
51
+ ),
52
+ useSidebar: () => ({ isMobile: false }),
53
+ }))
54
+ vi.mock('@/components/ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
55
+ vi.mock('@/server/serverFns/auth/session', () => ({ logoutRemoteFn: H.logoutRemoteFn }))
56
+ vi.mock('@/server/serverFns/sync/transfer', () => ({
57
+ reverseTransferOwnershipFn: H.reverseTransferOwnershipFn,
58
+ }))
59
+ vi.mock('@/lib/desktop-bridge-types', () => ({
60
+ getDesktopBridge: () => ({
61
+ transferLock: H.transferLock,
62
+ clearCloudSession: H.clearCloudSession,
63
+ }),
64
+ }))
65
+ vi.mock('@/components/shared-kit', () => ({
66
+ UserAvatar: ({ name }: { name?: string }) => <div data-testid="avatar">{name}</div>,
67
+ }))
68
+ vi.mock('@/components/magicui/sparkles-text', () => ({
69
+ SparklesText: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
70
+ }))
71
+ vi.mock('@/components/ui/dropdown-menu', () => ({
72
+ DropdownMenu: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
73
+ DropdownMenuTrigger: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
74
+ DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
75
+ DropdownMenuGroup: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
76
+ DropdownMenuItem: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
77
+ <button type="button" onClick={onClick}>
78
+ {children}
79
+ </button>
80
+ ),
81
+ DropdownMenuLabel: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
82
+ DropdownMenuSeparator: () => null,
83
+ }))
84
+ vi.mock('@tanstack/react-router', () => ({
85
+ useRouter: () => ({
86
+ navigate: vi.fn(),
87
+ state: { location: { pathname: '/dashboard', searchStr: '' } },
88
+ }),
89
+ useMatches: () => [],
90
+ }))
91
+
92
+ async function clickLogout() {
93
+ const { UserMenu } = await import('../user-menu')
94
+ render(<UserMenu />)
95
+ fireEvent.click(screen.getByText('退出登录'))
96
+ }
97
+
98
+ /** transferLock 某次调用的 invocationCallOrder(未调用 → -1) */
99
+ function lockOrder(action: 'begin' | 'end'): number {
100
+ const idx = H.transferLock.mock.calls.findIndex((call) => call[0] === action)
101
+ return H.transferLock.mock.invocationCallOrder[idx] ?? -1
102
+ }
103
+
104
+ describe('UserMenu 桌面登出分支', () => {
105
+ beforeEach(() => {
106
+ vi.clearAllMocks()
107
+ try {
108
+ Object.defineProperty(window, 'location', {
109
+ configurable: true,
110
+ value: { ...window.location, reload: H.reload },
111
+ })
112
+ } catch {
113
+ /* jsdom 限制:后续断言只看转移链路 */
114
+ }
115
+ })
116
+
117
+ afterEach(() => {
118
+ cleanup()
119
+ })
120
+
121
+ it('③ desktopSession=cloud:走反向转移编排,不走 authjs signOut,最后整页 reload', async () => {
122
+ H.session = CLOUD_SESSION
123
+ await clickLogout()
124
+
125
+ await waitFor(() => {
126
+ expect(H.reverseTransferOwnershipFn).toHaveBeenCalledWith({ data: { cloudUserId: 'cloud-1' } })
127
+ })
128
+ expect(H.clearCloudSession).toHaveBeenCalledTimes(1)
129
+ expect(H.logoutRemoteFn).toHaveBeenCalledWith({ data: {} })
130
+ expect(H.queryClient.clear).toHaveBeenCalledTimes(1)
131
+ expect(H.signOut).not.toHaveBeenCalled()
132
+ // ④ 整页 reload(reload 后 getSessionFn 回落 guest 会话)
133
+ expect(H.reload).toHaveBeenCalledTimes(1)
134
+ }, 20000)
135
+
136
+ it('③ 编排顺序:lock begin → reverse → clearCloudSession → lock end → reload', async () => {
137
+ H.session = CLOUD_SESSION
138
+ await clickLogout()
139
+
140
+ await waitFor(() => {
141
+ expect(H.reload).toHaveBeenCalledTimes(1)
142
+ })
143
+
144
+ const begin = lockOrder('begin')
145
+ const end = lockOrder('end')
146
+ const reverse = H.reverseTransferOwnershipFn.mock.invocationCallOrder[0]!
147
+ const clear = H.clearCloudSession.mock.invocationCallOrder[0]!
148
+ const reload = H.reload.mock.invocationCallOrder[0]!
149
+
150
+ expect(begin).toBeGreaterThan(0)
151
+ expect(begin).toBeLessThan(reverse)
152
+ expect(reverse).toBeLessThan(clear)
153
+ // 先清云会话再解锁:引擎恢复 tick 前云会话必须已不可用
154
+ expect(clear).toBeLessThan(end)
155
+ expect(end).toBeLessThan(reload)
156
+ }, 20000)
157
+
158
+ it('① desktopSession=guest(local-guest):本地 guest 登出,不触发反向转移', async () => {
159
+ H.session = GUEST_SESSION
160
+ await clickLogout()
161
+
162
+ await waitFor(() => {
163
+ expect(H.logoutRemoteFn).toHaveBeenCalledWith({ data: {} })
164
+ })
165
+ expect(H.reverseTransferOwnershipFn).not.toHaveBeenCalled()
166
+ expect(H.transferLock).not.toHaveBeenCalled()
167
+ expect(H.clearCloudSession).not.toHaveBeenCalled()
168
+ expect(H.queryClient.resetQueries).toHaveBeenCalled()
169
+ expect(H.reload).toHaveBeenCalledTimes(1)
170
+ expect(H.signOut).not.toHaveBeenCalled()
171
+ }, 20000)
172
+
173
+ it('② 本地 authjs 凭据登录(无 desktopSession 标记):走 signOut,不触发反向转移', async () => {
174
+ H.session = LOCAL_CREDENTIAL_SESSION
175
+ await clickLogout()
176
+
177
+ await waitFor(() => {
178
+ expect(H.signOut).toHaveBeenCalledTimes(1)
179
+ })
180
+ expect(H.reverseTransferOwnershipFn).not.toHaveBeenCalled()
181
+ expect(H.transferLock).not.toHaveBeenCalled()
182
+ expect(H.clearCloudSession).not.toHaveBeenCalled()
183
+ expect(H.reload).not.toHaveBeenCalled()
184
+ }, 20000)
185
+ })
@@ -32,7 +32,7 @@ import { NavKnowledge } from "@/components/layout/sidebar-03/nav-knowledge";
32
32
  import { NavWorkspace } from "@/components/layout/sidebar-03/nav-workspace";
33
33
  import { UserMenu } from "@/components/layout/sidebar-03/user-menu";
34
34
  import { AnimatedThemeToggler } from '@/components/magicui/animated-theme-toggler'
35
- import { desktopAgentRoute, workspaceRoutes } from "@/config/sidebar-routes";
35
+ import { workspaceRoutes } from "@/config/sidebar-routes";
36
36
  import type { Route } from "@/config/sidebar-routes";
37
37
  import { PresenceAvatarStack } from "@/components/layout/sidebar-03/presence-avatar-stack";
38
38
  import { SidebarSearchTrigger } from "@/components/layout/sidebar-03/nav-search-trigger"
@@ -40,7 +40,6 @@ import { NavSecondary } from "@/components/layout/sidebar-03/nav-secondary"
40
40
  import { PluginNavItems } from '@/components/plugin-slots/plugin-nav-items'
41
41
  import { isDesktopEdition } from "@/lib/edition-desktop";
42
42
  import { getDesktopBridge } from "@/lib/desktop-bridge-types";
43
- import { useDesktopAgentPresence } from "@/lib/desktop-agent/use-desktop-agent-presence";
44
43
 
45
44
  // presence 渲染工厂(§8.2 消费者两文件之一):nav-main 按 route.id 查
46
45
  // MODULE_ROUTES 得 module key 后回调;module 级单例 map,identity 稳定。
@@ -48,16 +47,10 @@ const routes: Route[] = workspaceRoutes.map((route) => ({
48
47
  ...route,
49
48
  presence: (module: string) => <PresenceAvatarStack module={module} />,
50
49
  }));
51
- // 桌面壳追加本地文件助手入口(P3-B5):module 级常量保 identity 稳定
52
- const routesWithDesktopAgent: Route[] = [...routes, desktopAgentRoute];
53
50
 
54
51
  export function DashboardSidebar() {
55
52
  const { state } = useSidebar();
56
53
  const isCollapsed = state === "collapsed";
57
- // 本地文件助手入口门控(与 /_nav/agent 路由同源判定):SSR / hydration 首帧恒
58
- // pending(不渲染该项,首帧一致),挂载后桌面壳恢复;云 Web / OSS 恒不可见
59
- const desktopAgentPresence = useDesktopAgentPresence();
60
- const navRoutes = desktopAgentPresence === "present" ? routesWithDesktopAgent : routes;
61
54
  // 桌面壳(Electron hiddenInset):红绿灯悬浮在窗口左上角,顶栏重排为
62
55
  // 「第一行红绿灯让位 + 右侧按钮组(整行可拖动窗口)、第二行 logo」。
63
56
  // web/云端 SaaS 布局不变(isDesktopEdition 为构建期常量,死分支消除)。
@@ -156,7 +149,7 @@ export function DashboardSidebar() {
156
149
  )}
157
150
  </SidebarHeader>
158
151
  <SidebarContent className="gap-4 px-2 py-4">
159
- <DashboardNavigation routes={navRoutes} />
152
+ <DashboardNavigation routes={routes} />
160
153
  <NavWorkspace />
161
154
  <NavKnowledge />
162
155
  <PluginNavItems />
@@ -23,11 +23,10 @@ import {
23
23
  Settings,
24
24
  Users,
25
25
  } from "lucide-react";
26
- import type { QueryClient } from "@tanstack/react-query";
27
26
  import { logoutRemoteFn } from '@/server/serverFns/auth/session'
28
27
  import { reverseTransferOwnershipFn } from '@/server/serverFns/sync/transfer'
29
28
  import { getDesktopBridge } from '@/lib/desktop-bridge-types'
30
- import type { DesktopBridgeApi } from '@/lib/desktop-bridge-types'
29
+ import { runDesktopLogout } from '@/lib/desktop-logout'
31
30
  import { toast } from '@/components/ui/toast'
32
31
 
33
32
  import {
@@ -54,53 +53,29 @@ import { SparklesText } from "@/components/magicui/sparkles-text";
54
53
  import { m } from "@/paraglide/messages.js";
55
54
 
56
55
  /**
57
- * 桌面云态登出(桌面本地优先 P2-A9 反向转移编排,裁定顺序):登出 = 把云身份名下
58
- * 数据翻回 guest(数据保留本机),双会话清理后回到 guest 态。
59
- * ① 置转移锁(transferLock,复用 bind 锁文件——引擎每 tick 判定同源,「转移进行中
60
- * 引擎勿 tick」;旧壳无此 IPC 优雅降级:反向转移单事务自身原子,锁为双保险);
61
- * ② reverseTransferOwnershipFn(serverFn:校验 remoteAuth/会话本人 → 单事务
62
- * cloud→guest + outbox/row_state 同事务清空防反向 op 上云 + 重建 guest 行;
63
- * 事务提交后 server 侧已清 remote-auth——崩窗口收敛,A8b 发现 2 的镜像);
64
- * ③ 双会话清理:logoutRemoteFn(幂等 re-clear remote-auth)+ clearCloudSession
65
- * (清 cloud-session.json,引擎 guest 守卫回 idle)→ 清锁(先清云会话再解锁,
66
- * 引擎恢复 tick 前云会话已不可用);
67
- * ④ queryClient.clear() + 整页 reload——reload 后 getSessionFn 回落 guest 会话。
68
- * 失败路径(锁失败 / 转移失败):解锁 + toast sync_logout_failed + 不 reload(留在
69
- * 云态可重试;转移失败单事务已回滚,数据仍是云属主,重试幂等)。模块内局部函数
70
- * (非导出):本文件唯一导出是 UserMenu 组件,勿破坏 Fast Refresh 纯组件边界。
56
+ * 桌面云态登出(P2-A9 反向转移):编排本体在 `@/lib/desktop-logout`(可测纯函数,
57
+ * 失败路径/调用顺序由单测钉死),此处只做**依赖装配**——bridge IPC、serverFn、
58
+ * queryClient 清空与整页 reload。顺序与失败语义见该模块文件头注释。
71
59
  */
72
60
  async function desktopCloudLogout(
73
- bridge: DesktopBridgeApi,
61
+ bridge: NonNullable<ReturnType<typeof getDesktopBridge>>,
74
62
  cloudUserId: string,
75
- queryClient: QueryClient,
63
+ queryClient: { clear: () => void },
76
64
  ): Promise<void> {
77
- toast.info(m.sync_logout_in_progress());
78
- // ① 转移锁
79
- let lock: { ok: boolean; error?: string } = { ok: true };
80
- try {
81
- lock = bridge.transferLock ? await bridge.transferLock("begin") : { ok: true };
82
- } catch {
83
- lock = { ok: false };
84
- }
85
- if (!lock.ok) {
86
- toast.error(m.sync_logout_failed());
87
- return;
88
- }
89
- // ② 反向转移(失败:单事务回滚留在云态,解锁可重试)
90
- try {
91
- await reverseTransferOwnershipFn({ data: { cloudUserId } });
92
- } catch {
93
- try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
94
- toast.error(m.sync_logout_failed());
95
- return;
96
- }
97
- // ③ 双会话清理(均幂等 best-effort:serverFn 已清 remote-auth,此处兜底)
98
- try { await logoutRemoteFn({ data: {} }); } catch { /* 尽力而为 */ }
99
- try { await bridge.clearCloudSession?.(); } catch { /* 尽力而为 */ }
100
- try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
101
- // ④ 缓存清空 + 整页刷新(reload 后 desktop-state 回 guest)
102
- queryClient.clear();
103
- window.location.reload();
65
+ await runDesktopLogout(cloudUserId, {
66
+ // 旧壳无 transferLock IPC:省略 = 置锁成功 / 解锁 no-op(反向转移单事务自身原子)
67
+ lock: bridge.transferLock ? () => bridge.transferLock!("begin") : undefined,
68
+ unlock: bridge.transferLock ? () => bridge.transferLock!("end") : undefined,
69
+ transfer: (id) => reverseTransferOwnershipFn({ data: { cloudUserId: id } }),
70
+ logoutRemote: () => logoutRemoteFn({ data: {} }),
71
+ clearCloudSession: bridge.clearCloudSession ? () => bridge.clearCloudSession!() : undefined,
72
+ clearQueries: () => queryClient.clear(),
73
+ reload: () => window.location.reload(),
74
+ notify: (notice) => {
75
+ if (notice === "in-progress") toast.info(m.sync_logout_in_progress());
76
+ else toast.error(m.sync_logout_failed());
77
+ },
78
+ });
104
79
  }
105
80
 
106
81
  export function UserMenu() {
@@ -137,12 +112,14 @@ export function UserMenu() {
137
112
  window.location.reload();
138
113
  return;
139
114
  }
140
- // 桌面云态(A9):桌面壳存在且无本地 authjs 会话(clientSession 为空)=当前
141
- // 会话是 remote-auth 回落的轻量云身份,登出走反向转移编排(数据翻回 guest)。
142
- // 本地凭据登录(clientSession 非空)与 web 照旧走 signOut 链——云身份回落仅在
143
- // authjs 会话无效时生效,clientSession 为空是云态判别式。
115
+ // 桌面云态(A9):桌面壳存在且会话来自云端回落=当前身份是 remote-auth
116
+ // 的轻量云身份,登出走反向转移编排(数据翻回 guest)。
117
+ // 判据演进(桌面 guest 会话修复):/api/auth/session 现会回落桌面身份并在
118
+ // 响应里带 desktopSession 标记,clientSession 不再为空——以标记为准;旧后端
119
+ // (无标记)保留「clientSession 为空」兜底。本地 authjs 凭据登录(无标记、
120
+ // clientSession 非空)与 web 照旧走 signOut 链。
144
121
  const bridge = getDesktopBridge();
145
- if (bridge && !clientSession && user?.id) {
122
+ if (bridge && (!clientSession || clientSession.desktopSession === 'cloud') && user?.id) {
146
123
  await desktopCloudLogout(bridge, user.id, queryClient);
147
124
  return;
148
125
  }
@@ -36,6 +36,10 @@ export interface Session {
36
36
  timezone?: string | null
37
37
  }
38
38
  expires: string
39
+ /** 桌面端会话来源标记(仅 /api/auth/session 的桌面回落响应携带):
40
+ * guest = 本地影子用户,cloud = remote-auth 云身份。web 部署与本地
41
+ * authjs 凭据登录无此字段——云态登出编排(user-menu)据此区分二者。 */
42
+ desktopSession?: 'guest' | 'cloud'
39
43
  }
40
44
 
41
45
  export function useSession() {
@@ -26,7 +26,6 @@ import {
26
26
  Bug,
27
27
  PenTool,
28
28
  ListTodo,
29
- FolderSearch,
30
29
  } from "lucide-react";
31
30
 
32
31
  export type RouteSubItem = {
@@ -102,15 +101,3 @@ export const workspaceRoutes: Route[] = [
102
101
  link: "/tasks",
103
102
  },
104
103
  ];
105
-
106
- /**
107
- * 本地文件助手(桌面本地优先 P3-B5):依赖桌面壳 AgentService,不进上方常驻列表——
108
- * app-sidebar 经 useDesktopAgentPresence(桌面构建 + desktopBridge,mounted 模式)
109
- * 判定后条件追加,云 Web / OSS 恒不可见(与 /_nav/agent 路由门控同源)。
110
- */
111
- export const desktopAgentRoute: Route = {
112
- id: "desktop-agent",
113
- title: m.agent_desktop_title,
114
- icon: <FolderSearch className="size-4" />,
115
- link: "/agent",
116
- };
@@ -0,0 +1,239 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 桌面云态登出编排单测(A9 反向转移):钉死调用顺序与失败路径语义。
6
+ // 编排是纯依赖注入函数(无 React / i18n / DOM),此处全部替身驱动,测的是
7
+ // 「顺序、短路、best-effort 不阻塞」三件事,而不是各依赖自身实现。
8
+ import { describe, expect, it, vi } from 'vitest'
9
+ import { runDesktopLogout, type DesktopLogoutDeps } from '../desktop-logout'
10
+
11
+ type Log = string[]
12
+
13
+ /** 记录调用顺序的替身工厂(同一 Log 数组串起全部依赖) */
14
+ function makeDeps(
15
+ log: Log,
16
+ overrides: Partial<DesktopLogoutDeps> = {},
17
+ ): DesktopLogoutDeps {
18
+ return {
19
+ lock: async () => {
20
+ log.push('lock')
21
+ return { ok: true }
22
+ },
23
+ transfer: async (userId) => {
24
+ log.push(`transfer:${userId}`)
25
+ },
26
+ logoutRemote: async () => {
27
+ log.push('logoutRemote')
28
+ },
29
+ clearCloudSession: async () => {
30
+ log.push('clearCloudSession')
31
+ },
32
+ unlock: async () => {
33
+ log.push('unlock')
34
+ },
35
+ clearQueries: () => {
36
+ log.push('clearQueries')
37
+ },
38
+ reload: () => {
39
+ log.push('reload')
40
+ },
41
+ ...overrides,
42
+ }
43
+ }
44
+
45
+ describe('runDesktopLogout —— 成功路径', () => {
46
+ it('调用顺序严格:lock → transfer → logoutRemote → clearCloudSession → unlock → clearQueries → reload', async () => {
47
+ const log: Log = []
48
+ const status = await runDesktopLogout('cloud-user-1', makeDeps(log))
49
+
50
+ expect(status).toBe('done')
51
+ expect(log).toEqual([
52
+ 'lock',
53
+ 'transfer:cloud-user-1',
54
+ 'logoutRemote',
55
+ 'clearCloudSession',
56
+ 'unlock',
57
+ 'clearQueries',
58
+ 'reload',
59
+ ])
60
+ })
61
+
62
+ it('notify 先发 in-progress,成功路径不发 failed', async () => {
63
+ const notices: string[] = []
64
+ const status = await runDesktopLogout(
65
+ 'u',
66
+ makeDeps([], { notify: (n) => notices.push(n) }),
67
+ )
68
+
69
+ expect(status).toBe('done')
70
+ expect(notices).toEqual(['in-progress'])
71
+ })
72
+
73
+ it('旧壳(无 transferLock / clearCloudSession IPC):缺省即视为成功,仍走完并 reload', async () => {
74
+ const log: Log = []
75
+ const status = await runDesktopLogout(
76
+ 'u',
77
+ makeDeps(log, {
78
+ lock: undefined,
79
+ unlock: undefined,
80
+ clearCloudSession: undefined,
81
+ }),
82
+ )
83
+
84
+ expect(status).toBe('done')
85
+ expect(log).toEqual(['transfer:u', 'logoutRemote', 'clearQueries', 'reload'])
86
+ })
87
+ })
88
+
89
+ describe('runDesktopLogout —— 失败路径(留在云态可重试)', () => {
90
+ it('置锁返回 ok=false → 不转移、不 reload,通知 failed', async () => {
91
+ const log: Log = []
92
+ const notices: string[] = []
93
+ const status = await runDesktopLogout(
94
+ 'u',
95
+ makeDeps(log, {
96
+ lock: async () => ({ ok: false, error: 'locked' }),
97
+ notify: (n) => notices.push(n),
98
+ }),
99
+ )
100
+
101
+ expect(status).toBe('lock-failed')
102
+ expect(log).toEqual([])
103
+ expect(notices).toEqual(['in-progress', 'failed'])
104
+ })
105
+
106
+ it('置锁抛错 → 与 ok=false 同判(不转移、不 reload)', async () => {
107
+ const log: Log = []
108
+ const status = await runDesktopLogout(
109
+ 'u',
110
+ makeDeps(log, {
111
+ lock: async () => {
112
+ throw new Error('ipc down')
113
+ },
114
+ }),
115
+ )
116
+
117
+ expect(status).toBe('lock-failed')
118
+ expect(log).toEqual([])
119
+ })
120
+
121
+ it('转移失败 → 尽力解锁 + 通知 failed + 不 reload、不清缓存、不清双会话(保留云态可重试)', async () => {
122
+ const log: Log = []
123
+ const notices: string[] = []
124
+ const status = await runDesktopLogout(
125
+ 'u',
126
+ makeDeps(log, {
127
+ transfer: async () => {
128
+ log.push('transfer')
129
+ throw new Error('transfer failed')
130
+ },
131
+ notify: (n) => notices.push(n),
132
+ }),
133
+ )
134
+
135
+ expect(status).toBe('transfer-failed')
136
+ expect(log).toEqual(['lock', 'transfer', 'unlock'])
137
+ expect(notices).toEqual(['in-progress', 'failed'])
138
+ })
139
+
140
+ it('转移失败且解锁也抛错 → 不误报状态、不 reload', async () => {
141
+ const log: Log = []
142
+ const status = await runDesktopLogout(
143
+ 'u',
144
+ makeDeps(log, {
145
+ transfer: async () => {
146
+ throw new Error('transfer failed')
147
+ },
148
+ unlock: async () => {
149
+ log.push('unlock')
150
+ throw new Error('unlock failed')
151
+ },
152
+ }),
153
+ )
154
+
155
+ expect(status).toBe('transfer-failed')
156
+ expect(log).toEqual(['lock', 'unlock'])
157
+ })
158
+ })
159
+
160
+ describe('runDesktopLogout —— best-effort 不阻塞(既有实现语义)', () => {
161
+ it('解锁抛错 → 仍有 clearQueries + reload(reload 后引擎按 guest 守卫重判)', async () => {
162
+ const log: Log = []
163
+ const status = await runDesktopLogout(
164
+ 'u',
165
+ makeDeps(log, {
166
+ unlock: async () => {
167
+ log.push('unlock')
168
+ throw new Error('unlock failed')
169
+ },
170
+ }),
171
+ )
172
+
173
+ expect(status).toBe('done')
174
+ expect(log).toEqual([
175
+ 'lock',
176
+ 'transfer:u',
177
+ 'logoutRemote',
178
+ 'clearCloudSession',
179
+ 'unlock',
180
+ 'clearQueries',
181
+ 'reload',
182
+ ])
183
+ })
184
+
185
+ it('清云会话失败 → 不阻塞解锁 / 清缓存 / reload', async () => {
186
+ const log: Log = []
187
+ const status = await runDesktopLogout(
188
+ 'u',
189
+ makeDeps(log, {
190
+ clearCloudSession: async () => {
191
+ log.push('clearCloudSession')
192
+ throw new Error('clear failed')
193
+ },
194
+ }),
195
+ )
196
+
197
+ expect(status).toBe('done')
198
+ expect(log.slice(-4)).toEqual(['clearCloudSession', 'unlock', 'clearQueries', 'reload'])
199
+ expect(log).toContain('clearQueries')
200
+ })
201
+
202
+ it('清 remote-auth 失败 → 不阻塞后续(云会话清理由 serverFn 侧完成)', async () => {
203
+ const log: Log = []
204
+ const status = await runDesktopLogout(
205
+ 'u',
206
+ makeDeps(log, {
207
+ logoutRemote: async () => {
208
+ log.push('logoutRemote')
209
+ throw new Error('logout remote failed')
210
+ },
211
+ }),
212
+ )
213
+
214
+ expect(status).toBe('done')
215
+ expect(log.slice(-3)).toEqual(['unlock', 'clearQueries', 'reload'])
216
+ })
217
+ })
218
+
219
+ describe('runDesktopLogout —— 依赖调用次数', () => {
220
+ it('成功路径每个依赖恰好一次;notify 只在最前发一次 in-progress', async () => {
221
+ const spy = {
222
+ lock: vi.fn(async () => ({ ok: true })),
223
+ transfer: vi.fn(async () => undefined),
224
+ logoutRemote: vi.fn(async () => undefined),
225
+ clearCloudSession: vi.fn(async () => ({ ok: true })),
226
+ unlock: vi.fn(async () => undefined),
227
+ clearQueries: vi.fn(),
228
+ reload: vi.fn(),
229
+ notify: vi.fn(),
230
+ }
231
+ await runDesktopLogout('cloud', spy)
232
+
233
+ for (const fn of [spy.lock, spy.transfer, spy.logoutRemote, spy.clearCloudSession, spy.unlock, spy.clearQueries, spy.reload]) {
234
+ expect(fn).toHaveBeenCalledTimes(1)
235
+ }
236
+ expect(spy.notify).toHaveBeenCalledTimes(1)
237
+ expect(spy.notify).toHaveBeenCalledWith('in-progress')
238
+ })
239
+ })