@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
@@ -0,0 +1,221 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手 · 对话区(桌面本地优先 P3-B5):useChat 直连 Pro desktop 域
6
+ // AgentService 的 `POST /prompt`(AG-UI SSE)。
7
+ // - connection:lib/desktop-agent/connection 包装——每次 connect 懒取 appInfo 的
8
+ // agentPort/agentToken(backend 重启换端口/令牌后下一问即用新值),引用稳定。
9
+ // - threadId:mount 级稳定值(useChat 1.0 起必填,变化即重建 ChatClient)。B4 端点
10
+ // 每次 POST 新建 pi 会话,threadId 仅作 run 归属回显——一期不保留多轮上下文,
11
+ // 输入区常驻提示告知。
12
+ // - cwd:经 forwardedProps 进 RunAgentInput.forwardedProps(端点 extractCwd 取值位,
13
+ // 服务端再按 workspaces.json 复核授权);父级以 key={cwd} 让每个目录一份会话。
14
+ // - 消息:user 气泡 / assistant Markdown + 工具调用折叠卡(同 toolCallId 的
15
+ // tool-result 已由 processor 挂到 tool-call part.output,不重复渲染);pi 对
16
+ // user / toolResult 消息也发 message_start/end,processor 会建空 assistant 消息,
17
+ // 渲染层跳过(B3B4 报告疑虑 3)。
18
+ // - 输入:Enter 发送 / Shift+Enter 换行 / 输入法组合中的 Enter 不发送。
19
+ // - 中止:运行中发送按钮换成「中止」+ 应用热键 agent-abort(Mod+.,T23 声明即绑定,
20
+ // 仅运行中 enabled)。
21
+ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type UIEvent } from 'react'
22
+ import { useChat } from '@tanstack/ai-react'
23
+ import type { UIMessage } from '@tanstack/ai-client'
24
+ import { useAppHotkeys, useHotkey } from '@tanstack/react-hotkeys'
25
+ import { ArrowUp, CircleAlert, FolderSearch, Loader2, MessageSquareText, Square } from 'lucide-react'
26
+ import { AgentToolCard, type AgentToolResultPart } from '@/components/desktop-agent/agent-tool-card'
27
+ import { MarkdownContent } from '@/components/harness/ai-message/components/markdown-content'
28
+ import { ShortcutBadge } from '@/components/hotkeys/shortcut-badge'
29
+ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
30
+ import {
31
+ InputGroup,
32
+ InputGroupAddon,
33
+ InputGroupButton,
34
+ InputGroupTextarea,
35
+ } from '@/components/ui/input-group'
36
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
37
+ import { createDesktopAgentConnection } from '@/lib/desktop-agent/connection'
38
+ import { describeDesktopAgentError } from '@/lib/desktop-agent/errors'
39
+ import { formatAppHotkeyForBadge } from '@/lib/hotkeys/registry'
40
+ import { m } from '@/paraglide/messages.js'
41
+
42
+ /** 距底部小于该值视为「贴底」:新内容到达时自动滚到底;用户上翻阅读时不打断 */
43
+ const STICK_TO_BOTTOM_PX = 80
44
+
45
+ /** 有可见内容:user 有正文;assistant 有非空正文或工具调用(空消息对跳过) */
46
+ function hasVisibleContent(message: UIMessage): boolean {
47
+ return message.parts.some((part) =>
48
+ part.type === 'text' ? part.content.trim() !== '' : part.type === 'tool-call',
49
+ )
50
+ }
51
+
52
+ function AgentMessage({ message }: { message: UIMessage }) {
53
+ if (message.role === 'user') {
54
+ const text = message.parts.map((part) => (part.type === 'text' ? part.content : '')).join('')
55
+ return (
56
+ <div className="flex justify-end" data-testid="agent-message" data-role="user">
57
+ <div className="max-w-xl rounded-2xl rounded-br-sm bg-primary px-3.5 py-2 text-sm whitespace-pre-wrap text-primary-foreground">
58
+ {text}
59
+ </div>
60
+ </div>
61
+ )
62
+ }
63
+ const results = new Map<string, AgentToolResultPart>()
64
+ for (const part of message.parts) {
65
+ if (part.type === 'tool-result') results.set(part.toolCallId, part)
66
+ }
67
+ return (
68
+ <div className="flex flex-col gap-2 text-sm" data-testid="agent-message" data-role="assistant">
69
+ {message.parts.map((part, index) => {
70
+ if (part.type === 'text') {
71
+ // parts 流式只追加不重排,index key 稳定
72
+ return part.content.trim() === '' ? null : (
73
+ <MarkdownContent key={index} content={part.content} skipCodeHighlighting />
74
+ )
75
+ }
76
+ if (part.type === 'tool-call') {
77
+ return <AgentToolCard key={part.id || index} part={part} result={results.get(part.id)} />
78
+ }
79
+ return null
80
+ })}
81
+ </div>
82
+ )
83
+ }
84
+
85
+ export interface AgentChatProps {
86
+ /** 当前工作目录(授权清单选中项);null = 尚未授权/选择,输入禁用 */
87
+ cwd: string | null
88
+ /** 运行态变化通知父级(运行中锁定目录切换);须传稳定引用(如 setState) */
89
+ onRunningChange?: (running: boolean) => void
90
+ }
91
+
92
+ export function AgentChat({ cwd, onRunningChange }: AgentChatProps) {
93
+ const [connection] = useState(() => createDesktopAgentConnection())
94
+ const [threadId] = useState(() => crypto.randomUUID())
95
+ const forwardedProps = useMemo(() => ({ cwd: cwd ?? '' }), [cwd])
96
+ const { messages, sendMessage, stop, isLoading, error } = useChat({ connection, threadId, forwardedProps })
97
+
98
+ const [input, setInput] = useState('')
99
+ const scrollRef = useRef<HTMLDivElement>(null)
100
+ const stickToBottomRef = useRef(true)
101
+
102
+ // 声明即绑定:agent-abort 单一来源 lib/hotkeys/registry.ts
103
+ const hk = useAppHotkeys()
104
+ useHotkey(hk['agent-abort'], () => stop(), { enabled: isLoading })
105
+
106
+ useEffect(() => {
107
+ onRunningChange?.(isLoading)
108
+ }, [isLoading, onRunningChange])
109
+ // 运行中被卸载(离开页面):解除父级锁定
110
+ useEffect(() => () => onRunningChange?.(false), [onRunningChange])
111
+
112
+ const visible = useMemo(() => messages.filter(hasVisibleContent), [messages])
113
+
114
+ useEffect(() => {
115
+ const el = scrollRef.current
116
+ if (el && stickToBottomRef.current) el.scrollTop = el.scrollHeight
117
+ }, [visible, isLoading])
118
+
119
+ const onScroll = (event: UIEvent<HTMLDivElement>) => {
120
+ const el = event.currentTarget
121
+ stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_TO_BOTTOM_PX
122
+ }
123
+
124
+ const canSend = cwd !== null && input.trim() !== '' && !isLoading
125
+
126
+ const submit = () => {
127
+ if (!canSend) return
128
+ const text = input.trim()
129
+ setInput('')
130
+ stickToBottomRef.current = true
131
+ // 失败由 useChat error 态渲染(describeDesktopAgentError),这里只吞 unhandledrejection
132
+ Promise.resolve(sendMessage(text)).catch(() => {})
133
+ }
134
+
135
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
136
+ if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
137
+ event.preventDefault()
138
+ submit()
139
+ }
140
+
141
+ return (
142
+ <section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card">
143
+ <div className="flex items-center gap-2 border-b px-4 py-2.5 text-xs text-muted-foreground">
144
+ <FolderSearch className="size-3.5 shrink-0" />
145
+ <span className="min-w-0 truncate" data-testid="agent-chat-cwd">
146
+ {cwd ? m.agent_desktop_chatCwd({ path: cwd }) : m.agent_desktop_chatNoWorkspace()}
147
+ </span>
148
+ </div>
149
+
150
+ <div ref={scrollRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
151
+ {visible.length === 0 && !isLoading ? (
152
+ <Empty className="h-full">
153
+ <EmptyHeader>
154
+ <EmptyMedia variant="icon">
155
+ <MessageSquareText />
156
+ </EmptyMedia>
157
+ <EmptyTitle>{m.agent_desktop_chatEmptyTitle()}</EmptyTitle>
158
+ <EmptyDescription>{m.agent_desktop_chatEmptyHint()}</EmptyDescription>
159
+ </EmptyHeader>
160
+ </Empty>
161
+ ) : (
162
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
163
+ {visible.map((message) => (
164
+ <AgentMessage key={message.id} message={message} />
165
+ ))}
166
+ {isLoading && (
167
+ <div className="flex items-center gap-2 text-sm text-muted-foreground" data-testid="agent-chat-thinking">
168
+ <Loader2 className="size-4 animate-spin" />
169
+ {m.agent_desktop_chatThinking()}
170
+ </div>
171
+ )}
172
+ </div>
173
+ )}
174
+ </div>
175
+
176
+ {error && (
177
+ <div
178
+ role="alert"
179
+ className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive"
180
+ >
181
+ <CircleAlert className="mt-0.5 size-3.5 shrink-0" />
182
+ <span className="min-w-0 wrap-break-word">{describeDesktopAgentError(error)}</span>
183
+ </div>
184
+ )}
185
+
186
+ <div className="border-t p-3">
187
+ <InputGroup>
188
+ <InputGroupTextarea
189
+ value={input}
190
+ onChange={(event) => setInput(event.target.value)}
191
+ onKeyDown={onKeyDown}
192
+ disabled={cwd === null}
193
+ placeholder={cwd ? m.agent_desktop_chatPlaceholder() : m.agent_desktop_chatNoWorkspace()}
194
+ className="max-h-40"
195
+ />
196
+ <InputGroupAddon align="block-end">
197
+ <span className="min-w-0 truncate text-xs font-normal">{m.agent_desktop_chatIndependentHint()}</span>
198
+ {isLoading ? (
199
+ <Tooltip>
200
+ <TooltipTrigger
201
+ render={<InputGroupButton variant="outline" size="sm" className="ml-auto" onClick={() => stop()} />}
202
+ >
203
+ <Square className="fill-current" />
204
+ {m.agent_desktop_chatStop()}
205
+ </TooltipTrigger>
206
+ <TooltipContent>
207
+ <ShortcutBadge keys={formatAppHotkeyForBadge(hk['agent-abort'].hotkey)} />
208
+ </TooltipContent>
209
+ </Tooltip>
210
+ ) : (
211
+ <InputGroupButton variant="default" size="sm" className="ml-auto" disabled={!canSend} onClick={submit}>
212
+ <ArrowUp />
213
+ {m.agent_desktop_chatSend()}
214
+ </InputGroupButton>
215
+ )}
216
+ </InputGroupAddon>
217
+ </InputGroup>
218
+ </div>
219
+ </section>
220
+ )
221
+ }
@@ -0,0 +1,185 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手 · 工具调用折叠卡(桌面本地优先 P3-B5):pi 只读四件套的每次调用
6
+ // 渲染为一张默认折叠的卡——头部「工具 + 目标 + 计数」,展开后列出文件名(ls /
7
+ // find)、命中行(grep:文件:行号 + 行内容)或文件内容(read)。风格对齐 hextaui
8
+ // AI 分组 / 站内 components/ai/tool-cards(rounded-lg border bg-muted/40 text-xs +
9
+ // 折叠头),折叠用 shadcn Collapsible(Base UI)。解析逻辑在 lib/desktop-agent/tool-result。
10
+ import { useState } from 'react'
11
+ import type { UIMessage } from '@tanstack/ai-client'
12
+ import {
13
+ ChevronRight,
14
+ FileSearch,
15
+ FileText,
16
+ Folder,
17
+ FolderTree,
18
+ Loader2,
19
+ Search,
20
+ Wrench,
21
+ type LucideIcon,
22
+ } from 'lucide-react'
23
+ import { Badge } from '@/components/ui/badge'
24
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
25
+ import {
26
+ extractAgentToolText,
27
+ parseAgentToolArgs,
28
+ summarizeAgentTool,
29
+ type AgentToolKind,
30
+ type AgentToolSummary,
31
+ } from '@/lib/desktop-agent/tool-result'
32
+ import { cn } from '@/lib/utils'
33
+ import { m } from '@/paraglide/messages.js'
34
+
35
+ type MessagePart = UIMessage['parts'][number]
36
+ export type AgentToolCallPart = Extract<MessagePart, { type: 'tool-call' }>
37
+ export type AgentToolResultPart = Extract<MessagePart, { type: 'tool-result' }>
38
+
39
+ const KIND_ICON: Record<AgentToolKind, LucideIcon> = {
40
+ read: FileText,
41
+ grep: Search,
42
+ find: FileSearch,
43
+ ls: FolderTree,
44
+ other: Wrench,
45
+ }
46
+
47
+ // 文案在渲染时解析(paraglide 按当前 locale);非导出函数保 Fast Refresh 纯组件边界
48
+ function titleOf(summary: AgentToolSummary, toolName: string): string {
49
+ const target = summary.target ?? '…'
50
+ switch (summary.kind) {
51
+ case 'read':
52
+ return m.agent_desktop_toolRead({ target })
53
+ case 'grep':
54
+ return m.agent_desktop_toolGrep({ target })
55
+ case 'find':
56
+ return m.agent_desktop_toolFind({ target })
57
+ case 'ls':
58
+ return m.agent_desktop_toolLs({ target })
59
+ default:
60
+ return m.agent_desktop_toolOther({ name: toolName })
61
+ }
62
+ }
63
+
64
+ function countOf(summary: AgentToolSummary): string | null {
65
+ if (summary.empty) return summary.kind === 'ls' ? m.agent_desktop_toolEmptyDir() : m.agent_desktop_toolNoMatches()
66
+ if (summary.total === 0) return null
67
+ switch (summary.kind) {
68
+ case 'grep':
69
+ return m.agent_desktop_toolCountHits({ count: summary.total })
70
+ case 'ls':
71
+ case 'find':
72
+ return m.agent_desktop_toolCountFiles({ count: summary.total })
73
+ default:
74
+ return m.agent_desktop_toolCountLines({ count: summary.total })
75
+ }
76
+ }
77
+
78
+ function ToolEntries({ summary }: { summary: AgentToolSummary }) {
79
+ const hidden = summary.total - summary.entries.length
80
+ return (
81
+ <div className="flex flex-col gap-1.5">
82
+ {summary.kind === 'read' || summary.kind === 'other' ? (
83
+ <pre className="max-h-60 overflow-auto rounded bg-background p-2 font-mono whitespace-pre-wrap break-all text-foreground">
84
+ {summary.entries.map((entry) => (entry.kind === 'text' ? entry.text : '')).join('\n')}
85
+ </pre>
86
+ ) : (
87
+ <ul className="flex max-h-60 flex-col gap-0.5 overflow-auto font-mono">
88
+ {summary.entries.map((entry, index) =>
89
+ entry.kind === 'hit' ? (
90
+ <li key={index} className="flex min-w-0 gap-2 rounded px-1.5 py-0.5 hover:bg-background">
91
+ <span className="shrink-0 text-muted-foreground">{`${entry.path}:${entry.line}`}</span>
92
+ <span className="min-w-0 truncate">{entry.text}</span>
93
+ </li>
94
+ ) : entry.kind === 'file' ? (
95
+ <li key={index} className="flex min-w-0 items-center gap-1.5 rounded px-1.5 py-0.5 hover:bg-background">
96
+ {entry.path.endsWith('/') ? (
97
+ <Folder className="size-3 shrink-0 text-muted-foreground" />
98
+ ) : (
99
+ <FileText className="size-3 shrink-0 text-muted-foreground" />
100
+ )}
101
+ <span className="min-w-0 truncate">{entry.path}</span>
102
+ </li>
103
+ ) : null,
104
+ )}
105
+ </ul>
106
+ )}
107
+ {hidden > 0 && <p className="text-muted-foreground">{m.agent_desktop_toolMore({ count: hidden })}</p>}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ export interface AgentToolCardProps {
113
+ part: AgentToolCallPart
114
+ /** 同 toolCallId 的 tool-result part(output 缺席时的内容回落 + 错误原文) */
115
+ result?: AgentToolResultPart
116
+ }
117
+
118
+ export function AgentToolCard({ part, result }: AgentToolCardProps) {
119
+ const [open, setOpen] = useState(false)
120
+ const isError = part.state === 'error' || result?.state === 'error'
121
+ const settled = isError || part.output !== undefined || (result !== undefined && result.state !== 'streaming')
122
+ const text = settled ? extractAgentToolText(part.output, result?.content) : null
123
+ const summary = summarizeAgentTool(part.name, parseAgentToolArgs(part), isError ? null : text)
124
+ const errorText = isError ? (result?.error ?? text) : null
125
+ const expandable = isError ? Boolean(errorText) : summary.entries.length > 0
126
+ const count = settled && !isError ? countOf(summary) : null
127
+ const Icon = KIND_ICON[summary.kind]
128
+ const expanded = open && expandable
129
+
130
+ return (
131
+ <Collapsible
132
+ open={expanded}
133
+ onOpenChange={setOpen}
134
+ disabled={!expandable}
135
+ data-tool={part.name}
136
+ className={cn(
137
+ 'rounded-lg border text-xs',
138
+ isError ? 'border-destructive/40 bg-destructive/5' : 'border-border bg-muted/40',
139
+ )}
140
+ >
141
+ <CollapsibleTrigger
142
+ data-testid="agent-tool-card-trigger"
143
+ className="flex w-full items-center gap-2 px-2.5 py-2 text-left"
144
+ >
145
+ <ChevronRight
146
+ className={cn(
147
+ 'size-3.5 shrink-0 text-muted-foreground transition-transform',
148
+ expanded && 'rotate-90',
149
+ !expandable && 'opacity-0',
150
+ )}
151
+ />
152
+ <Icon className="size-3.5 shrink-0 text-muted-foreground" />
153
+ <span className="min-w-0 flex-1 truncate font-medium">{titleOf(summary, part.name)}</span>
154
+ {!settled && (
155
+ <span className="flex shrink-0 items-center gap-1 text-muted-foreground">
156
+ <Loader2 className="size-3 animate-spin" />
157
+ {m.agent_desktop_toolRunning()}
158
+ </span>
159
+ )}
160
+ {isError && (
161
+ <Badge variant="destructive" className="shrink-0">
162
+ {m.agent_desktop_toolError()}
163
+ </Badge>
164
+ )}
165
+ {summary.truncated && (
166
+ <Badge variant="outline" className="shrink-0">
167
+ {m.agent_desktop_toolTruncated()}
168
+ </Badge>
169
+ )}
170
+ {count && <span className="shrink-0 text-muted-foreground">{count}</span>}
171
+ </CollapsibleTrigger>
172
+ <CollapsibleContent>
173
+ <div className="border-t px-2.5 py-2">
174
+ {isError ? (
175
+ <pre className="max-h-60 overflow-auto font-mono whitespace-pre-wrap break-all text-destructive">
176
+ {errorText}
177
+ </pre>
178
+ ) : (
179
+ <ToolEntries summary={summary} />
180
+ )}
181
+ </div>
182
+ </CollapsibleContent>
183
+ </Collapsible>
184
+ )
185
+ }
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手 · 授权目录卡(桌面本地优先 P3-B5):agent 只能读取这里列出的目录。
6
+ // - 添加:desktopBridge.pickDirectory(main 侧原生目录对话框,选中即写入
7
+ // userData/agent/workspaces.json 并返回规范化绝对路径;取消返回 null)——
8
+ // renderer 无法注入任意路径,授权入口只有这一个(B1 授权模型)。
9
+ // - 清单:react-query 工厂 desktopAgentWorkspacesQueryOptions(listWorkspaces)。
10
+ // - 撤销:removeWorkspace;增删成功后按 DESKTOP_AGENT_QUERY_KEY 前缀 invalidate。
11
+ // - 单选当前工作目录(对话 cwd);运行中由父级锁定(disabled),避免中途换目录。
12
+ // - 门控:useDesktopAgentPresence(含 agentPort 装配判定 + 冷启动有界重试)+
13
+ // isAgentWorkspaceSupported(B1 三方法)双条件,与侧栏入口/页面同源;presence
14
+ // 为 pending(探测/重试中)时按加载态渲染,absent(终态)才提示升级。
15
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
16
+ import { FolderOpen, FolderPlus, Loader2, X } from 'lucide-react'
17
+ import { Button } from '@/components/ui/button'
18
+ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
19
+ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
20
+ import { Label } from '@/components/ui/label'
21
+ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
22
+ import { Skeleton } from '@/components/ui/skeleton'
23
+ import { toast } from '@/components/ui/toast'
24
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
25
+ import { getDesktopBridge } from '@/lib/desktop-bridge-types'
26
+ import { isAgentWorkspaceSupported } from '@/lib/desktop-agent/availability'
27
+ import { useDesktopAgentPresence } from '@/lib/desktop-agent/use-desktop-agent-presence'
28
+ import { DESKTOP_AGENT_QUERY_KEY, desktopAgentWorkspacesQueryOptions } from '@/lib/queries/desktop-agent'
29
+ import { cn } from '@/lib/utils'
30
+ import { m } from '@/paraglide/messages.js'
31
+
32
+ /** 路径末段作为目录显示名(POSIX / Windows 分隔符均可) */
33
+ function folderNameOf(path: string): string {
34
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
35
+ }
36
+
37
+ export interface AgentWorkspaceCardProps {
38
+ /** 当前选中的工作目录(对话 cwd) */
39
+ selected: string | null
40
+ onSelect: (path: string) => void
41
+ /** 运行中锁定切换与撤销 */
42
+ disabled?: boolean
43
+ }
44
+
45
+ export function AgentWorkspaceCard({ selected, onSelect, disabled = false }: AgentWorkspaceCardProps) {
46
+ const queryClient = useQueryClient()
47
+ // 门控口径与侧栏入口/页面同源(presence 已含 agentPort:agent 未装配即不可用),
48
+ // 再叠加 B1 三方法检查——旧壳(无目录授权 IPC)同样整卡降级为升级提示。
49
+ const presence = useDesktopAgentPresence()
50
+ const supported = presence === 'present' && isAgentWorkspaceSupported()
51
+ const workspacesQuery = useQuery({ ...desktopAgentWorkspacesQueryOptions(), enabled: supported })
52
+
53
+ const addMutation = useMutation({
54
+ mutationFn: async () => (await getDesktopBridge()?.pickDirectory?.()) ?? null,
55
+ onSuccess: async (picked) => {
56
+ if (!picked) return // 用户取消对话框
57
+ await queryClient.invalidateQueries({ queryKey: DESKTOP_AGENT_QUERY_KEY })
58
+ onSelect(picked)
59
+ },
60
+ onError: () => {
61
+ toast.error(m.agent_desktop_workspaceAddFailed())
62
+ },
63
+ })
64
+
65
+ const removeMutation = useMutation({
66
+ mutationFn: async (path: string) => {
67
+ await getDesktopBridge()?.removeWorkspace?.(path)
68
+ },
69
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: DESKTOP_AGENT_QUERY_KEY }),
70
+ onError: () => {
71
+ toast.error(m.agent_desktop_workspaceRemoveFailed())
72
+ },
73
+ })
74
+
75
+ const workspaces = workspacesQuery.data ?? []
76
+ const loadingBody = (
77
+ <div className="flex flex-col gap-2">
78
+ <Skeleton className="h-12 w-full" />
79
+ <Skeleton className="h-12 w-full" />
80
+ </div>
81
+ )
82
+
83
+ let body: React.ReactNode
84
+ if (presence === 'pending') {
85
+ // 门控探测 / 冷启动有界重试中:按加载态渲染,不提前报「请升级客户端」
86
+ body = loadingBody
87
+ } else if (!supported) {
88
+ body = <p className="text-sm text-muted-foreground">{m.agent_desktop_workspaceUnsupported()}</p>
89
+ } else if (workspacesQuery.isPending) {
90
+ body = loadingBody
91
+ } else if (workspacesQuery.isError) {
92
+ body = <p className="text-sm text-destructive">{m.agent_desktop_workspaceLoadFailed()}</p>
93
+ } else if (workspaces.length === 0) {
94
+ body = (
95
+ <Empty className="border p-6">
96
+ <EmptyHeader>
97
+ <EmptyMedia variant="icon">
98
+ <FolderOpen />
99
+ </EmptyMedia>
100
+ <EmptyTitle>{m.agent_desktop_workspaceEmpty()}</EmptyTitle>
101
+ <EmptyDescription>{m.agent_desktop_workspaceEmptyHint()}</EmptyDescription>
102
+ </EmptyHeader>
103
+ </Empty>
104
+ )
105
+ } else {
106
+ body = (
107
+ <RadioGroup
108
+ value={selected ?? ''}
109
+ onValueChange={(value) => {
110
+ if (typeof value === 'string' && value !== '') onSelect(value)
111
+ }}
112
+ disabled={disabled}
113
+ className="gap-2"
114
+ >
115
+ {workspaces.map((path, index) => {
116
+ const id = `desktop-agent-workspace-${index}`
117
+ const checked = path === selected
118
+ return (
119
+ <div
120
+ key={path}
121
+ className={cn(
122
+ 'flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors',
123
+ checked ? 'border-primary/60 bg-primary/5' : 'hover:border-primary/30',
124
+ )}
125
+ >
126
+ <RadioGroupItem value={path} id={id} />
127
+ <Label htmlFor={id} className="min-w-0 flex-1 cursor-pointer flex-col items-start gap-1">
128
+ <span className="w-full truncate text-sm font-medium">{folderNameOf(path)}</span>
129
+ <span className="w-full truncate text-xs font-normal text-muted-foreground">{path}</span>
130
+ </Label>
131
+ <Tooltip>
132
+ <TooltipTrigger
133
+ render={
134
+ <Button
135
+ variant="ghost"
136
+ size="icon-sm"
137
+ aria-label={m.agent_desktop_workspaceRemove()}
138
+ disabled={disabled || removeMutation.isPending}
139
+ onClick={() => removeMutation.mutate(path)}
140
+ />
141
+ }
142
+ >
143
+ <X />
144
+ </TooltipTrigger>
145
+ <TooltipContent>{m.agent_desktop_workspaceRemove()}</TooltipContent>
146
+ </Tooltip>
147
+ </div>
148
+ )
149
+ })}
150
+ </RadioGroup>
151
+ )
152
+ }
153
+
154
+ return (
155
+ <Card className="gap-4">
156
+ <CardHeader>
157
+ <CardTitle>{m.agent_desktop_workspaceTitle()}</CardTitle>
158
+ <CardDescription>{m.agent_desktop_workspaceDescription()}</CardDescription>
159
+ {supported && (
160
+ <CardAction>
161
+ <Button
162
+ variant="outline"
163
+ size="sm"
164
+ disabled={addMutation.isPending}
165
+ onClick={() => addMutation.mutate()}
166
+ >
167
+ {addMutation.isPending ? <Loader2 className="animate-spin" /> : <FolderPlus />}
168
+ {m.agent_desktop_workspaceAdd()}
169
+ </Button>
170
+ </CardAction>
171
+ )}
172
+ </CardHeader>
173
+ <CardContent>{body}</CardContent>
174
+ </Card>
175
+ )
176
+ }
@@ -19,6 +19,10 @@ vi.mock('@/components/ui/sidebar', () => ({
19
19
  }))
20
20
  vi.mock('@/components/ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
21
21
  vi.mock('@/server/serverFns/auth/session', () => ({ logoutRemoteFn: vi.fn() }))
22
+ // A9:user-menu 反向转移登出链路新增 import——拦住 serverFn 模块图(勿真触
23
+ // desktop-bootstrap/@allbluecn/database 模块级单例,照本文件 session mock 先例)
24
+ vi.mock('@/server/serverFns/sync/transfer', () => ({ reverseTransferOwnershipFn: vi.fn() }))
25
+ vi.mock('@/lib/desktop-bridge-types', () => ({ getDesktopBridge: () => null }))
22
26
  vi.mock('@/components/shared-kit', () => ({
23
27
  UserAvatar: ({ name }: { name?: string }) => <div data-testid="avatar">{name}</div>,
24
28
  }))
@@ -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 { workspaceRoutes } from "@/config/sidebar-routes";
35
+ import { desktopAgentRoute, 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,6 +40,7 @@ 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";
43
44
 
44
45
  // presence 渲染工厂(§8.2 消费者两文件之一):nav-main 按 route.id 查
45
46
  // MODULE_ROUTES 得 module key 后回调;module 级单例 map,identity 稳定。
@@ -47,10 +48,16 @@ const routes: Route[] = workspaceRoutes.map((route) => ({
47
48
  ...route,
48
49
  presence: (module: string) => <PresenceAvatarStack module={module} />,
49
50
  }));
51
+ // 桌面壳追加本地文件助手入口(P3-B5):module 级常量保 identity 稳定
52
+ const routesWithDesktopAgent: Route[] = [...routes, desktopAgentRoute];
50
53
 
51
54
  export function DashboardSidebar() {
52
55
  const { state } = useSidebar();
53
56
  const isCollapsed = state === "collapsed";
57
+ // 本地文件助手入口门控(与 /_nav/agent 路由同源判定):SSR / hydration 首帧恒
58
+ // pending(不渲染该项,首帧一致),挂载后桌面壳恢复;云 Web / OSS 恒不可见
59
+ const desktopAgentPresence = useDesktopAgentPresence();
60
+ const navRoutes = desktopAgentPresence === "present" ? routesWithDesktopAgent : routes;
54
61
  // 桌面壳(Electron hiddenInset):红绿灯悬浮在窗口左上角,顶栏重排为
55
62
  // 「第一行红绿灯让位 + 右侧按钮组(整行可拖动窗口)、第二行 logo」。
56
63
  // web/云端 SaaS 布局不变(isDesktopEdition 为构建期常量,死分支消除)。
@@ -149,7 +156,7 @@ export function DashboardSidebar() {
149
156
  )}
150
157
  </SidebarHeader>
151
158
  <SidebarContent className="gap-4 px-2 py-4">
152
- <DashboardNavigation routes={routes} />
159
+ <DashboardNavigation routes={navRoutes} />
153
160
  <NavWorkspace />
154
161
  <NavKnowledge />
155
162
  <PluginNavItems />