@allbluecn/web-app 0.23.2 → 0.23.4

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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
  3. package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
  4. package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
  5. package/src/components/desktop/cloud-login.tsx +108 -6
  6. package/src/components/desktop/desktop-state-listener.tsx +76 -0
  7. package/src/components/desktop/shell-widgets.tsx +12 -5
  8. package/src/components/desktop/sync-indicator.tsx +62 -13
  9. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
  10. package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
  11. package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
  12. package/src/components/desktop-agent/agent-chat.tsx +221 -0
  13. package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
  14. package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
  15. package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
  16. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
  17. package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
  18. package/src/config/sidebar-routes.tsx +14 -1
  19. package/src/lib/__tests__/session.test.ts +95 -0
  20. package/src/lib/auth/auth.config.ts +13 -0
  21. package/src/lib/changelog/sdk-versions.ts +1 -1
  22. package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
  23. package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
  24. package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
  25. package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
  26. package/src/lib/desktop-agent/availability.ts +39 -0
  27. package/src/lib/desktop-agent/connection.ts +72 -0
  28. package/src/lib/desktop-agent/errors.ts +21 -0
  29. package/src/lib/desktop-agent/tool-result.ts +170 -0
  30. package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
  31. package/src/lib/desktop-bridge-types.ts +58 -1
  32. package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
  33. package/src/lib/hotkeys/registry.ts +9 -1
  34. package/src/lib/queries/README.md +1 -0
  35. package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
  36. package/src/lib/queries/desktop-agent.ts +25 -0
  37. package/src/lib/session.ts +15 -1
  38. package/src/paraglide/messages/_index.js +820 -0
  39. package/src/paraglide/messages/en.js +260 -0
  40. package/src/paraglide/messages/zh.js +260 -0
  41. package/src/routeTree.gen.ts +23 -0
  42. package/src/routes/-tests/agent-gate.test.ts +45 -0
  43. package/src/routes/_nav/agent/route.lazy.tsx +63 -0
  44. package/src/routes/_nav/agent/route.tsx +21 -0
  45. package/src/routes/api/sync/account-status.ts +5 -21
  46. package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
  47. package/src/server/__tests__/remote-auth.test.ts +131 -1
  48. package/src/server/desktop-bootstrap.ts +19 -3
  49. package/src/server/desktop-session-merge.ts +51 -0
  50. package/src/server/remote-auth.ts +61 -3
  51. package/src/server/serverFns/auth/session.ts +28 -28
  52. package/src/server/serverFns/sync/transfer.ts +168 -0
  53. package/src/server/serverfn-public-allowlist.ts +4 -0
  54. package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
  55. package/src/server/sync/account-status.ts +36 -0
  56. package/src/server/sync/ownership-transfer.ts +291 -0
  57. package/src/server/sync/saas-profile.ts +70 -0
@@ -5,8 +5,13 @@
5
5
  // 桌面同步状态指示器(迁移设计 §3.7 最小 UI):顶栏小圆点 + 同步状态。
6
6
  // 仅 DEPLOY_TARGET=desktop 渲染;云端部署零成本(组件返回 null)。
7
7
  // 数据源:desktopBridge.onSyncState(backend parentPort 上报的引擎状态机)。
8
+ // A11 三态徽标:desktopBridge.desktopState 查询 + onDesktopState 订阅(main 侧
9
+ // desktop-state 状态机),展示数据属主三态(guest/transferring/cloud)——位置
10
+ // 跟随既有指示器(本组件扩展,不新造摆放位置),位于同步相位行上方。
8
11
  import { useEffect, useState } from "react"
9
12
  import { getDesktopBridge } from "@/lib/desktop-bridge-types"
13
+ import type { DesktopStateKind } from "@/lib/desktop-bridge-types"
14
+ import { m } from "@/paraglide/messages.js"
10
15
 
11
16
  interface SyncState {
12
17
  phase: "idle" | "pushing" | "pulling"
@@ -21,8 +26,30 @@ const LABELS: Record<SyncState["phase"], string> = {
21
26
  pulling: "拉取中",
22
27
  }
23
28
 
29
+ // 三态徽标形态(跟随既有 LOCAL 徽标 = 圆点 + 文案):guest 灰(本地属主)、
30
+ // transferring 琥珀脉冲(转移进行中)、cloud 绿(云属主,与「已同步」同色系)
31
+ const STATE_DOT: Record<DesktopStateKind, string> = {
32
+ guest: "bg-muted-foreground",
33
+ transferring: "bg-amber-500 animate-pulse",
34
+ cloud: "bg-emerald-500",
35
+ }
36
+
37
+ // 文案在渲染时解析(paraglide 按请求/cookie locale——模块顶层调用会把 locale
38
+ // 固化在 import 时刻,语言切换后不更新);非导出模块函数保 Fast Refresh 纯组件边界
39
+ function stateLabelOf(kind: DesktopStateKind): string {
40
+ switch (kind) {
41
+ case "guest":
42
+ return m.desktop_state_guest()
43
+ case "transferring":
44
+ return m.desktop_state_transferring()
45
+ case "cloud":
46
+ return m.desktop_state_cloud()
47
+ }
48
+ }
49
+
24
50
  export function DesktopSyncIndicator() {
25
51
  const [state, setState] = useState<SyncState | null>(null)
52
+ const [deskState, setDeskState] = useState<DesktopStateKind | null>(null)
26
53
  const [mounted, setMounted] = useState(false)
27
54
 
28
55
  useEffect(() => {
@@ -30,31 +57,53 @@ export function DesktopSyncIndicator() {
30
57
  const bridge = getDesktopBridge()
31
58
  if (!bridge) return
32
59
  const off = bridge.onSyncState((s) => setState(s as SyncState))
33
- return off
60
+ // A11 三态:查询补当前态(页面 reload 后推送事件已错过)+ 订阅变更推送
61
+ void bridge.desktopState
62
+ ?.()
63
+ .then((p) => setDeskState(p.kind))
64
+ .catch(() => null)
65
+ const offState = bridge.onDesktopState?.((p) => setDeskState(p.kind))
66
+ return () => {
67
+ off()
68
+ offState?.()
69
+ }
34
70
  }, [])
35
71
 
36
72
  if (!mounted || !getDesktopBridge()) return null
37
- if (!state) return null
73
+ if (!state && !deskState) return null
38
74
 
39
- const hasError = state.lastError !== null
40
- const busy = state.phase === "pushing" || state.phase === "pulling"
75
+ const hasError = state?.lastError !== null && state?.lastError !== undefined
76
+ const busy = state?.phase === "pushing" || state?.phase === "pulling"
41
77
  const dotColor = hasError ? "bg-destructive" : busy ? "bg-amber-500 animate-pulse" : "bg-emerald-500"
42
78
  const label = hasError
43
79
  ? "同步失败"
44
80
  : busy
45
- ? LABELS[state.phase]
46
- : state.pending > 0
81
+ ? LABELS[state!.phase]
82
+ : state && state.pending > 0
47
83
  ? `待同步 ${state.pending}`
48
84
  : LABELS.idle
49
85
 
50
86
  return (
51
- <div
52
- className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground"
53
- title={hasError ? `同步失败:${state.lastError}` : `上次同步 ${state.lastSyncedAt ?? "—"}`}
54
- data-testid="desktop-sync-indicator"
55
- >
56
- <span className={`inline-block size-2 rounded-full ${dotColor}`} aria-hidden />
57
- <span>{label}</span>
87
+ <div className="space-y-0.5" data-testid="desktop-sync-indicator">
88
+ {deskState && (
89
+ <div
90
+ className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground"
91
+ data-testid="desktop-state-badge"
92
+ data-desktop-state={deskState}
93
+ >
94
+ <span className={`inline-block size-2 rounded-full ${STATE_DOT[deskState]}`} aria-hidden />
95
+ <span>{stateLabelOf(deskState)}</span>
96
+ </div>
97
+ )}
98
+ {state && (
99
+ <div
100
+ className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground"
101
+ title={hasError ? `同步失败:${state.lastError}` : `上次同步 ${state.lastSyncedAt ?? "—"}`}
102
+ >
103
+ <span className={`inline-block size-2 rounded-full ${dotColor}`} aria-hidden />
104
+ <span>{label}</span>
105
+ </div>
106
+ )}
58
107
  </div>
59
108
  )
60
109
  }
@@ -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
+ })
@@ -0,0 +1,189 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手 · 授权目录卡(桌面本地优先 P3-B5):真实 QueryClient + 可控 fake
6
+ // bridge——listWorkspaces 走 queryOptions 工厂;pickDirectory 成功 → invalidate
7
+ // 重取清单 + 选中新目录;取消(null)不动;removeWorkspace 成功 → invalidate;
8
+ // 旧壳(无三方法)降级提示;agent 终态不可用(presence absent)同样降级;门控
9
+ // 探测/重试中(pending)按加载态;IPC 失败 toast。
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
12
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
13
+ import { TooltipProvider } from '@/components/ui/tooltip'
14
+ import { m } from '@/paraglide/messages.js'
15
+
16
+ const state = vi.hoisted(() => ({
17
+ bridge: null as unknown,
18
+ toastError: vi.fn(),
19
+ presence: 'present' as 'pending' | 'present' | 'absent',
20
+ }))
21
+
22
+ vi.mock('@/lib/desktop-bridge-types', () => ({
23
+ getDesktopBridge: () => state.bridge,
24
+ }))
25
+ // 门控判定(agentPort 装配)在 availability 侧的用例见 use-desktop-agent-presence.test.tsx;
26
+ // 本文件固定 presence 值,专注卡片自身的降级/交互。
27
+ vi.mock('@/lib/desktop-agent/use-desktop-agent-presence', () => ({
28
+ useDesktopAgentPresence: () => state.presence,
29
+ }))
30
+ vi.mock('@/components/ui/toast', () => ({
31
+ toast: { error: state.toastError, success: vi.fn() },
32
+ }))
33
+
34
+ import { AgentWorkspaceCard } from '../agent-workspace-card'
35
+
36
+ function makeBridge(initial: string[]) {
37
+ let workspaces = [...initial]
38
+ return {
39
+ listWorkspaces: vi.fn(async () => [...workspaces]),
40
+ pickDirectory: vi.fn(async (): Promise<string | null> => null),
41
+ removeWorkspace: vi.fn(async (path: string) => {
42
+ workspaces = workspaces.filter((w) => w !== path)
43
+ }),
44
+ /** 测试辅助:模拟 main 侧 pickDirectory 写入清单 */
45
+ authorize(path: string) {
46
+ workspaces = [...workspaces, path]
47
+ },
48
+ }
49
+ }
50
+
51
+ function renderCard(props: { selected?: string | null; onSelect?: (p: string) => void; disabled?: boolean } = {}) {
52
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
53
+ const onSelect = props.onSelect ?? vi.fn()
54
+ const { container } = render(
55
+ <QueryClientProvider client={client}>
56
+ <TooltipProvider>
57
+ <AgentWorkspaceCard selected={props.selected ?? null} onSelect={onSelect} disabled={props.disabled} />
58
+ </TooltipProvider>
59
+ </QueryClientProvider>,
60
+ )
61
+ return { onSelect, container }
62
+ }
63
+
64
+ beforeEach(() => {
65
+ state.toastError.mockReset()
66
+ })
67
+
68
+ afterEach(() => {
69
+ cleanup()
70
+ state.bridge = null
71
+ state.presence = 'present'
72
+ })
73
+
74
+ describe('AgentWorkspaceCard', () => {
75
+ it('渲染授权清单:目录名 + 完整路径,当前目录单选选中', async () => {
76
+ state.bridge = makeBridge(['/Users/x/proj-a', '/Users/x/proj-b'])
77
+ renderCard({ selected: '/Users/x/proj-b' })
78
+ expect(await screen.findByText('proj-a')).toBeInTheDocument()
79
+ expect(screen.getByText('/Users/x/proj-a')).toBeInTheDocument()
80
+ expect(screen.getByText('proj-b')).toBeInTheDocument()
81
+ const radios = screen.getAllByRole('radio')
82
+ expect(radios).toHaveLength(2)
83
+ expect(radios[1]).toHaveAttribute('aria-checked', 'true')
84
+ expect(radios[0]).toHaveAttribute('aria-checked', 'false')
85
+ })
86
+
87
+ it('点选其他目录 → onSelect(该路径)', async () => {
88
+ state.bridge = makeBridge(['/Users/x/proj-a', '/Users/x/proj-b'])
89
+ const { onSelect } = renderCard({ selected: '/Users/x/proj-a' })
90
+ await screen.findByText('proj-b')
91
+ fireEvent.click(screen.getAllByRole('radio')[1]!)
92
+ await waitFor(() => expect(onSelect).toHaveBeenCalledWith('/Users/x/proj-b'))
93
+ })
94
+
95
+ it('添加目录:pickDirectory 成功 → 清单重取(invalidate)且选中新目录', async () => {
96
+ const bridge = makeBridge(['/Users/x/proj-a'])
97
+ bridge.pickDirectory.mockImplementation(async () => {
98
+ bridge.authorize('/Users/x/new-proj')
99
+ return '/Users/x/new-proj'
100
+ })
101
+ state.bridge = bridge
102
+ const { onSelect } = renderCard({ selected: '/Users/x/proj-a' })
103
+ await screen.findByText('proj-a')
104
+ expect(bridge.listWorkspaces).toHaveBeenCalledTimes(1)
105
+ fireEvent.click(screen.getByRole('button', { name: m.agent_desktop_workspaceAdd() }))
106
+ expect(await screen.findByText('new-proj')).toBeInTheDocument()
107
+ expect(bridge.pickDirectory).toHaveBeenCalledTimes(1)
108
+ expect(bridge.listWorkspaces).toHaveBeenCalledTimes(2)
109
+ expect(onSelect).toHaveBeenCalledWith('/Users/x/new-proj')
110
+ })
111
+
112
+ it('取消目录对话框(pickDirectory → null):不重取、不改选', async () => {
113
+ const bridge = makeBridge(['/Users/x/proj-a'])
114
+ state.bridge = bridge
115
+ const { onSelect } = renderCard({ selected: '/Users/x/proj-a' })
116
+ await screen.findByText('proj-a')
117
+ fireEvent.click(screen.getByRole('button', { name: m.agent_desktop_workspaceAdd() }))
118
+ await waitFor(() => expect(bridge.pickDirectory).toHaveBeenCalledTimes(1))
119
+ await waitFor(() =>
120
+ expect(screen.getByRole('button', { name: m.agent_desktop_workspaceAdd() })).not.toBeDisabled(),
121
+ )
122
+ expect(bridge.listWorkspaces).toHaveBeenCalledTimes(1)
123
+ expect(onSelect).not.toHaveBeenCalled()
124
+ })
125
+
126
+ it('撤销授权:removeWorkspace(路径) → 清单重取后该目录消失', async () => {
127
+ const bridge = makeBridge(['/Users/x/proj-a', '/Users/x/proj-b'])
128
+ state.bridge = bridge
129
+ renderCard({ selected: '/Users/x/proj-a' })
130
+ await screen.findByText('proj-b')
131
+ fireEvent.click(screen.getAllByRole('button', { name: m.agent_desktop_workspaceRemove() })[1]!)
132
+ await waitFor(() => expect(screen.queryByText('proj-b')).not.toBeInTheDocument())
133
+ expect(bridge.removeWorkspace).toHaveBeenCalledWith('/Users/x/proj-b')
134
+ expect(screen.getByText('proj-a')).toBeInTheDocument()
135
+ })
136
+
137
+ it('空清单:空态提示(引导先添加目录)', async () => {
138
+ state.bridge = makeBridge([])
139
+ renderCard()
140
+ expect(await screen.findByText(m.agent_desktop_workspaceEmpty())).toBeInTheDocument()
141
+ expect(screen.queryAllByRole('radio')).toHaveLength(0)
142
+ })
143
+
144
+ it('旧壳(无 pickDirectory/listWorkspaces/removeWorkspace):升级提示,无添加按钮', () => {
145
+ state.bridge = { appInfo: vi.fn() }
146
+ renderCard()
147
+ expect(screen.getByText(m.agent_desktop_workspaceUnsupported())).toBeInTheDocument()
148
+ expect(screen.queryByRole('button', { name: m.agent_desktop_workspaceAdd() })).toBeNull()
149
+ })
150
+
151
+ it('agent 终态不可用(presence absent):即使三方法齐备也降级为升级提示,且不发起清单查询', () => {
152
+ state.presence = 'absent'
153
+ const bridge = makeBridge(['/Users/x/proj-a'])
154
+ state.bridge = bridge
155
+ renderCard()
156
+ expect(screen.getByText(m.agent_desktop_workspaceUnsupported())).toBeInTheDocument()
157
+ expect(screen.queryByRole('button', { name: m.agent_desktop_workspaceAdd() })).toBeNull()
158
+ expect(bridge.listWorkspaces).not.toHaveBeenCalled()
159
+ })
160
+
161
+ it('门控探测 / 冷启动重试中(presence pending):按加载态渲染,不提前报升级、不查清单', () => {
162
+ state.presence = 'pending'
163
+ const bridge = makeBridge(['/Users/x/proj-a'])
164
+ state.bridge = bridge
165
+ const { container } = renderCard()
166
+ expect(screen.queryByText(m.agent_desktop_workspaceUnsupported())).toBeNull()
167
+ expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0)
168
+ expect(bridge.listWorkspaces).not.toHaveBeenCalled()
169
+ })
170
+
171
+ it('添加失败(IPC 抛错)→ toast 错误提示', async () => {
172
+ const bridge = makeBridge(['/Users/x/proj-a'])
173
+ bridge.pickDirectory.mockRejectedValue(new Error('dialog failed'))
174
+ state.bridge = bridge
175
+ renderCard({ selected: '/Users/x/proj-a' })
176
+ await screen.findByText('proj-a')
177
+ fireEvent.click(screen.getByRole('button', { name: m.agent_desktop_workspaceAdd() }))
178
+ await waitFor(() => expect(state.toastError).toHaveBeenCalledWith(m.agent_desktop_workspaceAddFailed()))
179
+ })
180
+
181
+ it('运行中锁定(disabled):单选与撤销不可操作', async () => {
182
+ state.bridge = makeBridge(['/Users/x/proj-a', '/Users/x/proj-b'])
183
+ renderCard({ selected: '/Users/x/proj-a', disabled: true })
184
+ await screen.findByText('proj-b')
185
+ for (const btn of screen.getAllByRole('button', { name: m.agent_desktop_workspaceRemove() })) {
186
+ expect(btn).toBeDisabled()
187
+ }
188
+ })
189
+ })