feat: expand secure runtime and workspace UX
This commit is contained in:
@@ -13,6 +13,8 @@ import App from './App'
|
||||
|
||||
let agentListener: ((event: AgentEvent) => void) | undefined
|
||||
let newConversationListener: (() => void) | undefined
|
||||
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
|
||||
const removeMaximizedChangedListener = vi.fn()
|
||||
const run = vi.fn<DesktopApi['agent']['run']>()
|
||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
const projectId = '00000000-0000-4000-8000-000000000101'
|
||||
@@ -38,6 +40,14 @@ const api: DesktopApi = {
|
||||
})),
|
||||
show: vi.fn(async () => {}),
|
||||
hide: vi.fn(async () => {}),
|
||||
minimize: vi.fn(async () => {}),
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
onMaximizedChanged: vi.fn((listener) => {
|
||||
maximizedChangedListener = listener
|
||||
return removeMaximizedChangedListener
|
||||
}),
|
||||
clearLocalData: vi.fn(async () => {}),
|
||||
onNewConversation: vi.fn((listener) => {
|
||||
newConversationListener = listener
|
||||
@@ -52,7 +62,7 @@ const api: DesktopApi = {
|
||||
id: 'model' as const,
|
||||
label: 'sonnet-5',
|
||||
available: true,
|
||||
supportsToolExecution: false,
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
})),
|
||||
run,
|
||||
@@ -175,7 +185,7 @@ const api: DesktopApi = {
|
||||
id: 'model',
|
||||
label: 'sonnet-5',
|
||||
available: true,
|
||||
supportsToolExecution: false,
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
})
|
||||
)
|
||||
@@ -204,7 +214,20 @@ const api: DesktopApi = {
|
||||
available: true,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false
|
||||
})),
|
||||
listDirectory: vi.fn(async (path: string) => ({
|
||||
path,
|
||||
entries: [],
|
||||
truncated: false
|
||||
})),
|
||||
readFile: vi.fn(async (path: string) => ({
|
||||
path,
|
||||
name: path.split('/').at(-1) ?? path,
|
||||
content: '',
|
||||
mimeType: 'text/plain' as const,
|
||||
size: 0
|
||||
}))
|
||||
},
|
||||
tasks: {
|
||||
@@ -390,11 +413,12 @@ describe('App', () => {
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.clearAllMocks()
|
||||
newConversationListener = undefined
|
||||
maximizedChangedListener = undefined
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'model',
|
||||
label: 'sonnet-5',
|
||||
available: true,
|
||||
supportsToolExecution: false,
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
})
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
@@ -407,6 +431,106 @@ describe('App', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('provides custom minimize, maximize, and close controls', async () => {
|
||||
const { unmount } = render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('最小化窗口'))
|
||||
fireEvent.click(screen.getByLabelText('最大化窗口'))
|
||||
fireEvent.click(screen.getByLabelText('关闭窗口'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.app.minimize).toHaveBeenCalledOnce()
|
||||
expect(api.app.toggleMaximize).toHaveBeenCalledOnce()
|
||||
expect(api.app.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
act(() => maximizedChangedListener?.(true))
|
||||
expect(await screen.findByLabelText('还原窗口')).toBeInTheDocument()
|
||||
act(() => maximizedChangedListener?.(false))
|
||||
expect(screen.getByLabelText('最大化窗口')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
expect(removeMaximizedChangedListener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps conversation actions in the conversation list', async () => {
|
||||
const { container } = render(<App />)
|
||||
const topbar = container.querySelector<HTMLElement>('.topbar')
|
||||
const conversationList =
|
||||
container.querySelector<HTMLElement>('.conversation-list')
|
||||
expect(topbar).not.toBeNull()
|
||||
expect(conversationList).not.toBeNull()
|
||||
if (!topbar || !conversationList) {
|
||||
return
|
||||
}
|
||||
|
||||
expect(within(topbar).queryByLabelText('专家角色')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('专家角色').closest('.composer')).not.toBeNull()
|
||||
|
||||
const appMenuTrigger = within(topbar).getByLabelText('应用菜单')
|
||||
fireEvent.click(appMenuTrigger)
|
||||
expect(
|
||||
screen.queryByRole('menuitem', { name: '重命名会话' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '安全与 Runtime 设置' })
|
||||
).toBeVisible()
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '安全与 Runtime 设置' })
|
||||
).toHaveFocus()
|
||||
)
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
expect(screen.getByRole('menuitem', { name: '使用帮助' })).toHaveFocus()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(appMenuTrigger).toHaveFocus()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
|
||||
const conversationMenuTrigger = within(
|
||||
conversationList
|
||||
).getByLabelText('更多会话操作 新对话')
|
||||
fireEvent.click(conversationMenuTrigger)
|
||||
const renameButton = within(conversationList).getByRole('button', {
|
||||
name: '重命名会话'
|
||||
})
|
||||
expect(renameButton).toBeVisible()
|
||||
expect(
|
||||
within(conversationList).getByRole('button', {
|
||||
name: '复制完整会话'
|
||||
})
|
||||
).toBeVisible()
|
||||
expect(
|
||||
within(conversationList).getByRole('button', {
|
||||
name: '导出 Markdown'
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
fireEvent.click(renameButton)
|
||||
const renameInput = within(conversationList).getByLabelText(
|
||||
'重命名会话 新对话'
|
||||
)
|
||||
fireEvent.change(renameInput, {
|
||||
target: { value: '重命名后的会话' }
|
||||
})
|
||||
fireEvent.submit(renameInput.closest('form')!)
|
||||
expect(
|
||||
within(conversationList).getByText('重命名后的会话')
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() => expect(conversationMenuTrigger).toHaveFocus())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
|
||||
fireEvent.click(
|
||||
within(conversationList).getByLabelText(
|
||||
'更多会话操作 重命名后的会话'
|
||||
)
|
||||
)
|
||||
fireEvent.click(
|
||||
within(conversationList).getByRole('button', {
|
||||
name: '复制完整会话'
|
||||
})
|
||||
)
|
||||
expect(await screen.findByRole('status')).toBeVisible()
|
||||
})
|
||||
|
||||
it('sends a prompt and renders streamed agent content', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -515,6 +639,193 @@ describe('App', () => {
|
||||
await waitFor(() => expect(composer).toHaveFocus())
|
||||
})
|
||||
|
||||
it('reuses the active empty conversation and preserves its draft', async () => {
|
||||
render(<App />)
|
||||
|
||||
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
|
||||
fireEvent.change(composer, {
|
||||
target: { value: '尚未发送的草稿' }
|
||||
})
|
||||
const newConversation = screen.getByRole('button', {
|
||||
name: /新建对话/u
|
||||
})
|
||||
fireEvent.click(newConversation)
|
||||
fireEvent.click(newConversation)
|
||||
|
||||
expect(composer).toHaveValue('尚未发送的草稿')
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: '删除对话 新对话' })
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces batched new-conversation requests after a used conversation', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '已有内容' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const requestId = run.mock.calls[0]?.[0].requestId
|
||||
act(() => {
|
||||
if (requestId) {
|
||||
agentListener?.({ requestId, type: 'done' })
|
||||
}
|
||||
newConversationListener?.()
|
||||
newConversationListener?.()
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /^删除对话/u })
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens a workspace Markdown file in the right-side preview', async () => {
|
||||
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
|
||||
path: '',
|
||||
entries: [
|
||||
{
|
||||
name: 'README.md',
|
||||
path: 'README.md',
|
||||
type: 'file'
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
})
|
||||
vi.mocked(api.workspace.readFile).mockResolvedValue({
|
||||
path: 'README.md',
|
||||
name: 'README.md',
|
||||
content: '# 工作区说明',
|
||||
mimeType: 'text/markdown',
|
||||
size: 19
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '更改' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /README\.md/u })
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '工作区说明' })
|
||||
).toBeInTheDocument()
|
||||
expect(api.workspace.readFile).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
'README.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes generated workspace files when a run completes', async () => {
|
||||
vi.mocked(api.workspace.getChanges)
|
||||
.mockResolvedValueOnce({
|
||||
rootPath: project.rootPath,
|
||||
available: true,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rootPath: project.rootPath,
|
||||
available: true,
|
||||
status: '?? generated.md',
|
||||
patch: '',
|
||||
files: [{ path: 'generated.md', status: '??' }],
|
||||
truncated: false
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '更改' }))
|
||||
await waitFor(() =>
|
||||
expect(api.workspace.getChanges).toHaveBeenCalledOnce()
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '生成文件' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const requestId = run.mock.calls[0]?.[0].requestId
|
||||
act(() => {
|
||||
if (requestId) {
|
||||
agentListener?.({ requestId, type: 'done' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(await screen.findByText('generated.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores stale Git changes after switching projects', async () => {
|
||||
const secondProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000102',
|
||||
name: '第二项目',
|
||||
rootPath: 'C:\\Second'
|
||||
}
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
secondProject
|
||||
])
|
||||
let resolveFirst:
|
||||
| ((value: Awaited<ReturnType<DesktopApi['workspace']['getChanges']>>) => void)
|
||||
| undefined
|
||||
let resolveSecond:
|
||||
| ((value: Awaited<ReturnType<DesktopApi['workspace']['getChanges']>>) => void)
|
||||
| undefined
|
||||
vi.mocked(api.workspace.getChanges)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '更改' }))
|
||||
await waitFor(() =>
|
||||
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('当前项目'), {
|
||||
target: { value: secondProject.id }
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(api.workspace.getChanges).toHaveBeenCalledWith(
|
||||
secondProject.id
|
||||
)
|
||||
)
|
||||
|
||||
resolveSecond?.({
|
||||
rootPath: secondProject.rootPath,
|
||||
available: true,
|
||||
status: '?? second.md',
|
||||
patch: '',
|
||||
files: [{ path: 'second.md', status: '??' }],
|
||||
truncated: false
|
||||
})
|
||||
expect(await screen.findByText('second.md')).toBeInTheDocument()
|
||||
resolveFirst?.({
|
||||
rootPath: project.rootPath,
|
||||
available: true,
|
||||
status: '?? stale.md',
|
||||
patch: '',
|
||||
files: [{ path: 'stale.md', status: '??' }],
|
||||
truncated: false
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('stale.md')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(screen.getByText('second.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies and persists a dark appearance from Settings', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -627,10 +938,15 @@ describe('App', () => {
|
||||
expect(within(stats).getByText('345')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows and changes the work mode in the composer', async () => {
|
||||
it.each([
|
||||
['opencode', 'OpenCode'],
|
||||
['continue', 'Continue CLI']
|
||||
] as const)(
|
||||
'locks %s to Execute and submits without a mode choice',
|
||||
async (runtimeId, label) => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
id: runtimeId,
|
||||
label,
|
||||
available: true,
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
@@ -638,13 +954,15 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
expect(mode).toHaveValue('ask')
|
||||
expect(mode).toHaveValue('execute')
|
||||
expect(mode).toBeDisabled()
|
||||
expect(mode.closest('.composer')).not.toBeNull()
|
||||
expect(
|
||||
await screen.findByText(/Ask 模式:只读问答,不会调用工具/)
|
||||
await screen.findByText(
|
||||
new RegExp(`${label} 固定为 Execute.*不会弹出 GoodBuddy 审批`)
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(mode, { target: { value: 'execute' } })
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '执行任务' }
|
||||
})
|
||||
@@ -658,9 +976,50 @@ describe('App', () => {
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('restores the direct-model mode after leaving an Agent Runtime', async () => {
|
||||
vi.mocked(api.agent.getStatus)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
available: true,
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'model',
|
||||
label: 'sonnet-5',
|
||||
available: true,
|
||||
supportsToolExecution: false,
|
||||
detail: 'Ready'
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
expect(mode).toHaveValue('execute')
|
||||
expect(mode).toBeDisabled()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitemradio', { name: /默认模型/u })
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mode).toHaveValue('ask')
|
||||
expect(mode).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('disables Execute for a runtime without tool support', async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'model',
|
||||
label: 'legacy-model',
|
||||
available: true,
|
||||
supportsToolExecution: false,
|
||||
detail: 'Ready'
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
@@ -672,6 +1031,34 @@ describe('App', () => {
|
||||
expect(mode).toHaveValue('ask')
|
||||
})
|
||||
|
||||
it('allows a direct model to submit Execute with GoodBuddy approvals', async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'model',
|
||||
label: 'sonnet-5',
|
||||
available: true,
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
fireEvent.change(mode, { target: { value: 'execute' } })
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '读取项目文件' }
|
||||
})
|
||||
fireEvent.click(await screen.findByLabelText('发送'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '读取项目文件',
|
||||
workMode: 'execute'
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(mode).toBeEnabled()
|
||||
})
|
||||
|
||||
it('terminalizes tools and activity when a request is cancelled', async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'opencode',
|
||||
|
||||
+553
-181
@@ -11,11 +11,15 @@ import {
|
||||
HeartPulse,
|
||||
History,
|
||||
Library,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
Mic,
|
||||
MicOff,
|
||||
Minimize2,
|
||||
Minus,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
PanelLeft,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
@@ -27,7 +31,8 @@ import {
|
||||
Square,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
UserRound
|
||||
UserRound,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
@@ -91,6 +96,12 @@ import {
|
||||
type AppearanceTheme
|
||||
} from './theme'
|
||||
|
||||
function isAgentRuntime(
|
||||
runtime: AgentRuntimeStatus | undefined
|
||||
): boolean {
|
||||
return runtime?.id === 'opencode' || runtime?.id === 'continue'
|
||||
}
|
||||
|
||||
type ToolActivity = {
|
||||
callId?: string
|
||||
name: string
|
||||
@@ -136,6 +147,7 @@ type Conversation = {
|
||||
type ActiveRun = {
|
||||
conversationId: string
|
||||
messageId: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
type WorkspaceView =
|
||||
@@ -206,6 +218,14 @@ function createConversation(projectId?: string): Conversation {
|
||||
}
|
||||
}
|
||||
|
||||
function isUnusedConversation(conversation: Conversation): boolean {
|
||||
return (
|
||||
conversation.title === '新对话' &&
|
||||
conversation.messages.length === 1 &&
|
||||
conversation.messages[0]?.role === 'assistant'
|
||||
)
|
||||
}
|
||||
|
||||
function loadConversations(): Conversation[] {
|
||||
try {
|
||||
const value = localStorage.getItem(storageKey)
|
||||
@@ -412,6 +432,80 @@ function buildMemoryContext(memories: AssistantMemory[]): string {
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
function WindowControls({
|
||||
onError
|
||||
}: {
|
||||
onError: (message: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.goodbuddy.app
|
||||
.isMaximized()
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setMaximized(value)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
onError('窗口状态读取失败')
|
||||
}
|
||||
})
|
||||
const removeListener =
|
||||
window.goodbuddy.app.onMaximizedChanged(setMaximized)
|
||||
return () => {
|
||||
active = false
|
||||
removeListener()
|
||||
}
|
||||
}, [onError])
|
||||
|
||||
return (
|
||||
<div className="window-controls">
|
||||
<button
|
||||
aria-label="最小化窗口"
|
||||
className="window-control"
|
||||
onClick={() =>
|
||||
void window.goodbuddy.app
|
||||
.minimize()
|
||||
.catch(() => onError('窗口最小化失败'))
|
||||
}
|
||||
title="最小化"
|
||||
type="button"
|
||||
>
|
||||
<Minus size={17} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={maximized ? '还原窗口' : '最大化窗口'}
|
||||
className="window-control"
|
||||
onClick={() =>
|
||||
void window.goodbuddy.app
|
||||
.toggleMaximize()
|
||||
.catch(() => onError('窗口大小切换失败'))
|
||||
}
|
||||
title={maximized ? '还原' : '最大化'}
|
||||
type="button"
|
||||
>
|
||||
{maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
<button
|
||||
aria-label="关闭窗口"
|
||||
className="window-control window-control--close"
|
||||
onClick={() =>
|
||||
void window.goodbuddy.app
|
||||
.close()
|
||||
.catch(() => onError('窗口关闭失败'))
|
||||
}
|
||||
title="关闭"
|
||||
type="button"
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const [conversations, setConversations] = useState(loadConversations)
|
||||
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
|
||||
@@ -455,6 +549,7 @@ function App(): React.JSX.Element {
|
||||
const [selectedExpertId, setSelectedExpertId] = useState('')
|
||||
const [activeProjectId, setActiveProjectId] = useState('')
|
||||
const activeProjectIdRef = useRef(activeProjectId)
|
||||
const workspaceChangesRequestRef = useRef(0)
|
||||
const viewRef = useRef<WorkspaceView>('chat')
|
||||
const heartbeatLoadRequestRef = useRef(0)
|
||||
const [workMode, setWorkMode] = useState<WorkMode>('ask')
|
||||
@@ -463,6 +558,7 @@ function App(): React.JSX.Element {
|
||||
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
||||
const [topbarMenuOpen, setTopbarMenuOpen] = useState(false)
|
||||
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
|
||||
const [appearanceTheme, setAppearanceTheme] =
|
||||
useState<AppearanceTheme>(loadAppearanceTheme)
|
||||
@@ -475,8 +571,11 @@ function App(): React.JSX.Element {
|
||||
appearanceTheme,
|
||||
systemPrefersDark
|
||||
)
|
||||
const effectiveWorkMode =
|
||||
workMode === 'execute' && runtime?.supportsToolExecution === false
|
||||
const agentRuntimeSelected = isAgentRuntime(runtime)
|
||||
const effectiveWorkMode = agentRuntimeSelected
|
||||
? 'execute'
|
||||
: workMode === 'execute' &&
|
||||
runtime?.supportsToolExecution === false
|
||||
? 'ask'
|
||||
: workMode
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>()
|
||||
@@ -488,8 +587,8 @@ function App(): React.JSX.Element {
|
||||
useState<AssistantSidebarTab>('tasks')
|
||||
const [view, setView] = useState<WorkspaceView>('chat')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const [conversationActionsId, setConversationActionsId] = useState('')
|
||||
const [renamingConversationId, setRenamingConversationId] = useState('')
|
||||
const [notice, setNotice] = useState<string>()
|
||||
const [attachments, setAttachments] = useState<ContextAttachment[]>([])
|
||||
const [contextError, setContextError] = useState<string>()
|
||||
@@ -515,21 +614,71 @@ function App(): React.JSX.Element {
|
||||
const knowledgeScopeInitialized = useRef(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const topbarMenuRef = useRef<HTMLDivElement>(null)
|
||||
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const conversationActionTriggerRefs = useRef(
|
||||
new Map<string, HTMLButtonElement>()
|
||||
)
|
||||
|
||||
const startNewConversation = useCallback((projectId?: string): void => {
|
||||
const conversation = createConversation(projectId)
|
||||
setConversations((current) => [conversation, ...current])
|
||||
setActiveId(conversation.id)
|
||||
setView('chat')
|
||||
setInput('')
|
||||
setAttachments((current) => {
|
||||
for (const attachment of current) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
return []
|
||||
useEffect(() => {
|
||||
if (!topbarMenuOpen) {
|
||||
return
|
||||
}
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
topbarMenuRef.current
|
||||
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
|
||||
?.focus()
|
||||
})
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}, [])
|
||||
const closeOnOutsidePointer = (event: PointerEvent): void => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!topbarMenuRef.current?.contains(event.target)
|
||||
) {
|
||||
setTopbarMenuOpen(false)
|
||||
}
|
||||
}
|
||||
const handleMenuKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setTopbarMenuOpen(false)
|
||||
topbarMenuTriggerRef.current?.focus()
|
||||
return
|
||||
}
|
||||
const menuItems = Array.from(
|
||||
topbarMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitem"]'
|
||||
) ?? []
|
||||
)
|
||||
if (menuItems.length === 0) {
|
||||
return
|
||||
}
|
||||
const currentIndex = menuItems.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
const targetIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? menuItems.length - 1
|
||||
: event.key === 'ArrowDown'
|
||||
? (currentIndex + 1) % menuItems.length
|
||||
: event.key === 'ArrowUp'
|
||||
? (currentIndex - 1 + menuItems.length) %
|
||||
menuItems.length
|
||||
: -1
|
||||
if (targetIndex >= 0) {
|
||||
event.preventDefault()
|
||||
menuItems[targetIndex]?.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
document.addEventListener('keydown', handleMenuKeyDown)
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame)
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
document.removeEventListener('keydown', handleMenuKeyDown)
|
||||
}
|
||||
}, [topbarMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
saveAppearanceTheme(appearanceTheme)
|
||||
@@ -581,6 +730,56 @@ function App(): React.JSX.Element {
|
||||
() => conversations.find((conversation) => conversation.id === activeId),
|
||||
[activeId, conversations]
|
||||
)
|
||||
const conversationNavigationRef = useRef({
|
||||
activeId,
|
||||
conversations
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
conversationNavigationRef.current = {
|
||||
activeId,
|
||||
conversations
|
||||
}
|
||||
}, [activeId, conversations])
|
||||
|
||||
const startNewConversation = useCallback(
|
||||
(projectId?: string): void => {
|
||||
const navigation = conversationNavigationRef.current
|
||||
const currentConversation = navigation.conversations.find(
|
||||
(conversation) => conversation.id === navigation.activeId
|
||||
)
|
||||
if (
|
||||
currentConversation &&
|
||||
currentConversation.projectId === projectId &&
|
||||
isUnusedConversation(currentConversation)
|
||||
) {
|
||||
setView('chat')
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
return
|
||||
}
|
||||
const conversation = createConversation(projectId)
|
||||
const nextConversations = [
|
||||
conversation,
|
||||
...navigation.conversations
|
||||
]
|
||||
conversationNavigationRef.current = {
|
||||
activeId: conversation.id,
|
||||
conversations: nextConversations
|
||||
}
|
||||
setConversations(nextConversations)
|
||||
setActiveId(conversation.id)
|
||||
setView('chat')
|
||||
setInput('')
|
||||
setAttachments((current) => {
|
||||
for (const attachment of current) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
return []
|
||||
})
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
},
|
||||
[]
|
||||
)
|
||||
const activeProject = useMemo(
|
||||
() => projects.find((project) => project.id === activeProjectId),
|
||||
[activeProjectId, projects]
|
||||
@@ -811,6 +1010,21 @@ function App(): React.JSX.Element {
|
||||
setTokenUsage(await window.goodbuddy.usage.getTokenSummary())
|
||||
}, [])
|
||||
|
||||
const loadWorkspaceChanges = useCallback(
|
||||
async (projectId: string): Promise<void> => {
|
||||
const requestId = workspaceChangesRequestRef.current + 1
|
||||
workspaceChangesRequestRef.current = requestId
|
||||
const changes = await window.goodbuddy.workspace.getChanges(projectId)
|
||||
if (
|
||||
workspaceChangesRequestRef.current === requestId &&
|
||||
activeProjectIdRef.current === projectId
|
||||
) {
|
||||
setWorkspaceChanges(changes)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAgentEvent = useCallback(
|
||||
(event: AgentEvent): void => {
|
||||
const run = activeRuns.current.get(event.requestId)
|
||||
@@ -842,6 +1056,14 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
)
|
||||
if (event.type === 'done') {
|
||||
if (
|
||||
run.projectId &&
|
||||
activeProjectIdRef.current === run.projectId
|
||||
) {
|
||||
void loadWorkspaceChanges(run.projectId).catch(() =>
|
||||
setNotice('工作区文件更改读取失败')
|
||||
)
|
||||
}
|
||||
if (viewRef.current === 'activity') {
|
||||
void refreshTokenUsage().catch(() =>
|
||||
setNotice('Token 用量读取失败')
|
||||
@@ -1018,6 +1240,7 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
[
|
||||
recordActivity,
|
||||
loadWorkspaceChanges,
|
||||
refreshTokenUsage,
|
||||
updateMessage,
|
||||
updateRequestActivity
|
||||
@@ -1116,14 +1339,32 @@ function App(): React.JSX.Element {
|
||||
|
||||
const refreshWorkspaceChanges = useCallback(async (): Promise<void> => {
|
||||
if (!activeProjectId) {
|
||||
workspaceChangesRequestRef.current += 1
|
||||
setWorkspaceChanges(undefined)
|
||||
return
|
||||
}
|
||||
const changes = await window.goodbuddy.workspace.getChanges(
|
||||
activeProjectId
|
||||
)
|
||||
setWorkspaceChanges(changes)
|
||||
}, [activeProjectId])
|
||||
await loadWorkspaceChanges(activeProjectId)
|
||||
}, [activeProjectId, loadWorkspaceChanges])
|
||||
|
||||
const listWorkspaceDirectory = useCallback(
|
||||
async (path: string) => {
|
||||
if (!activeProjectId) {
|
||||
throw new Error('请先选择项目')
|
||||
}
|
||||
return window.goodbuddy.workspace.listDirectory(activeProjectId, path)
|
||||
},
|
||||
[activeProjectId]
|
||||
)
|
||||
|
||||
const loadWorkspaceFile = useCallback(
|
||||
async (path: string) => {
|
||||
if (!activeProjectId) {
|
||||
throw new Error('请先选择项目')
|
||||
}
|
||||
return window.goodbuddy.workspace.readFile(activeProjectId, path)
|
||||
},
|
||||
[activeProjectId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (assistantSidebarTab !== 'changes') {
|
||||
@@ -1416,20 +1657,21 @@ function App(): React.JSX.Element {
|
||||
.catch(() => setNotice('应用信息读取失败'))
|
||||
const removeAgentListener =
|
||||
window.goodbuddy.agent.onEvent(handleAgentEvent)
|
||||
const removeNewConversationListener =
|
||||
window.goodbuddy.app.onNewConversation(() => {
|
||||
startNewConversation(
|
||||
activeProjectIdRef.current || undefined
|
||||
)
|
||||
})
|
||||
const removeOpenSettingsListener =
|
||||
window.goodbuddy.app.onOpenSettings(() => setView('settings'))
|
||||
return () => {
|
||||
removeAgentListener()
|
||||
removeNewConversationListener()
|
||||
removeOpenSettingsListener()
|
||||
}
|
||||
}, [handleAgentEvent, startNewConversation])
|
||||
}, [handleAgentEvent])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
window.goodbuddy.app.onNewConversation(() => {
|
||||
startNewConversation(activeProjectIdRef.current || undefined)
|
||||
}),
|
||||
[startNewConversation]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
@@ -1536,6 +1778,12 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const deleteConversation = (conversationId: string): void => {
|
||||
if (conversationActionsId === conversationId) {
|
||||
setConversationActionsId('')
|
||||
}
|
||||
if (renamingConversationId === conversationId) {
|
||||
setRenamingConversationId('')
|
||||
}
|
||||
const activeRequest = [...activeRuns.current.entries()].find(
|
||||
([, run]) => run.conversationId === conversationId
|
||||
)?.[0]
|
||||
@@ -1560,26 +1808,35 @@ function App(): React.JSX.Element {
|
||||
setActiveId(replacement.id)
|
||||
}
|
||||
|
||||
const saveTitle = (): void => {
|
||||
const title = titleDraft.trim().slice(0, 80)
|
||||
if (!activeConversation || !title) {
|
||||
const focusConversationActions = (conversationId: string): void => {
|
||||
requestAnimationFrame(() =>
|
||||
conversationActionTriggerRefs.current.get(conversationId)?.focus()
|
||||
)
|
||||
}
|
||||
|
||||
const saveTitle = (
|
||||
conversationId: string,
|
||||
titleInput: string
|
||||
): void => {
|
||||
const title = titleInput.trim().slice(0, 80)
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === activeConversation.id
|
||||
conversation.id === conversationId
|
||||
? { ...conversation, title, updatedAt: Date.now() }
|
||||
: conversation
|
||||
)
|
||||
)
|
||||
setRenaming(false)
|
||||
setRenamingConversationId('')
|
||||
focusConversationActions(conversationId)
|
||||
}
|
||||
|
||||
const copyConversation = async (): Promise<void> => {
|
||||
if (!activeConversation) {
|
||||
return
|
||||
}
|
||||
const transcript = activeConversation.messages
|
||||
const copyConversation = async (
|
||||
conversation: ConversationSnapshot
|
||||
): Promise<void> => {
|
||||
const transcript = conversation.messages
|
||||
.map(
|
||||
(message) =>
|
||||
`${message.role === 'user' ? '你' : 'GoodBuddy'}:\n${message.content}`
|
||||
@@ -1593,14 +1850,13 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const exportConversation = (): void => {
|
||||
if (!activeConversation) {
|
||||
return
|
||||
}
|
||||
const exportConversation = (
|
||||
conversation: ConversationSnapshot
|
||||
): void => {
|
||||
const markdown = [
|
||||
`# ${activeConversation.title}`,
|
||||
`# ${conversation.title}`,
|
||||
'',
|
||||
...activeConversation.messages.flatMap((message) => [
|
||||
...conversation.messages.flatMap((message) => [
|
||||
`## ${message.role === 'user' ? '你' : 'GoodBuddy'}`,
|
||||
'',
|
||||
message.content,
|
||||
@@ -1613,7 +1869,7 @@ function App(): React.JSX.Element {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `${activeConversation.title.replace(/[\\/:*?"<>|]/g, '_') || 'GoodBuddy 对话'}.md`
|
||||
anchor.download = `${conversation.title.replace(/[\\/:*?"<>|]/g, '_') || 'GoodBuddy 对话'}.md`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setNotice('对话已导出')
|
||||
@@ -1707,7 +1963,8 @@ function App(): React.JSX.Element {
|
||||
|
||||
activeRuns.current.set(requestId, {
|
||||
conversationId,
|
||||
messageId: assistantMessage.id
|
||||
messageId: assistantMessage.id,
|
||||
projectId: projectIdSnapshot
|
||||
})
|
||||
preparingConversations.current.delete(conversationId)
|
||||
const startedAt = new Date().toISOString()
|
||||
@@ -2134,30 +2391,154 @@ function App(): React.JSX.Element {
|
||||
<div className="conversation-list">
|
||||
<p className="section-label">最近会话</p>
|
||||
{filteredConversations.map((conversation) => (
|
||||
<div className="conversation-row" key={conversation.id}>
|
||||
<button
|
||||
<div className="conversation-entry" key={conversation.id}>
|
||||
<div
|
||||
className={
|
||||
conversation.id === activeId
|
||||
? 'conversation-item conversation-item--active'
|
||||
: 'conversation-item'
|
||||
? 'conversation-row conversation-row--active'
|
||||
: 'conversation-row'
|
||||
}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(conversation.id)
|
||||
setView('chat')
|
||||
}}
|
||||
>
|
||||
<span>{conversation.title}</span>
|
||||
<small>{formatTime(conversation.updatedAt)}</small>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`删除对话 ${conversation.title}`}
|
||||
className="conversation-delete"
|
||||
onClick={() => deleteConversation(conversation.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
conversation.id === activeId
|
||||
? 'conversation-item conversation-item--active'
|
||||
: 'conversation-item'
|
||||
}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
setActiveId(conversation.id)
|
||||
setView('chat')
|
||||
}}
|
||||
>
|
||||
<span>{conversation.title}</span>
|
||||
<small>{formatTime(conversation.updatedAt)}</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls={`conversation-actions-${conversation.id}`}
|
||||
aria-expanded={
|
||||
conversationActionsId === conversation.id
|
||||
}
|
||||
aria-label={`更多会话操作 ${conversation.title}`}
|
||||
className="conversation-more"
|
||||
onClick={() => {
|
||||
setRenamingConversationId('')
|
||||
setConversationActionsId((current) =>
|
||||
current === conversation.id ? '' : conversation.id
|
||||
)
|
||||
}}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
conversationActionTriggerRefs.current.set(
|
||||
conversation.id,
|
||||
element
|
||||
)
|
||||
} else {
|
||||
conversationActionTriggerRefs.current.delete(
|
||||
conversation.id
|
||||
)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`删除对话 ${conversation.title}`}
|
||||
className="conversation-delete"
|
||||
onClick={() => deleteConversation(conversation.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{conversationActionsId === conversation.id && (
|
||||
<div
|
||||
aria-label={`${conversation.title} 的会话操作`}
|
||||
className="conversation-actions"
|
||||
id={`conversation-actions-${conversation.id}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
setRenamingConversationId(conversation.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
重命名会话
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
void copyConversation(conversation).finally(() =>
|
||||
focusConversationActions(conversation.id)
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Copy size={14} />
|
||||
复制完整会话
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setConversationActionsId('')
|
||||
exportConversation(conversation)
|
||||
focusConversationActions(conversation.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Download size={14} />
|
||||
导出 Markdown
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{renamingConversationId === conversation.id && (
|
||||
<form
|
||||
className="conversation-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const input =
|
||||
event.currentTarget.elements.namedItem('title')
|
||||
if (input instanceof HTMLInputElement) {
|
||||
saveTitle(conversation.id, input.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={`重命名会话 ${conversation.title}`}
|
||||
autoFocus
|
||||
defaultValue={conversation.title}
|
||||
maxLength={80}
|
||||
name="title"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setRenamingConversationId('')
|
||||
focusConversationActions(conversation.id)
|
||||
}
|
||||
}}
|
||||
pattern=".*\S.*"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
aria-label="保存会话名称"
|
||||
type="submit"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="取消重命名"
|
||||
onClick={() => {
|
||||
setRenamingConversationId('')
|
||||
focusConversationActions(conversation.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredConversations.length === 0 && (
|
||||
@@ -2189,58 +2570,24 @@ function App(): React.JSX.Element {
|
||||
aria-label="切换侧栏"
|
||||
onClick={() => setSidebarOpen((open) => !open)}
|
||||
>
|
||||
<MoreHorizontal size={19} />
|
||||
<PanelLeft size={18} />
|
||||
</button>
|
||||
{view === 'chat' && renaming ? (
|
||||
<div className="title-editor">
|
||||
<input
|
||||
aria-label="对话标题"
|
||||
autoFocus
|
||||
maxLength={80}
|
||||
onChange={(event) => setTitleDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
saveTitle()
|
||||
} else if (event.key === 'Escape') {
|
||||
setRenaming(false)
|
||||
}
|
||||
}}
|
||||
value={titleDraft}
|
||||
/>
|
||||
<button
|
||||
aria-label="保存标题"
|
||||
className="icon-button"
|
||||
onClick={saveTitle}
|
||||
type="button"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="conversation-title"
|
||||
onClick={() => {
|
||||
if (view === 'chat' && activeConversation) {
|
||||
setTitleDraft(activeConversation.title)
|
||||
setRenaming(true)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{view === 'knowledge'
|
||||
? '知识库'
|
||||
: view === 'heartbeat'
|
||||
? '智能心跳'
|
||||
<div
|
||||
className="conversation-title"
|
||||
title={activeConversation?.title}
|
||||
>
|
||||
<span>
|
||||
{view === 'knowledge'
|
||||
? '知识库'
|
||||
: view === 'heartbeat'
|
||||
? '智能心跳'
|
||||
: view === 'activity'
|
||||
? '任务与活动'
|
||||
: view === 'settings'
|
||||
? '设置中心'
|
||||
: activeConversation?.title ?? '新对话'}
|
||||
</span>
|
||||
{view === 'chat' && <Edit3 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
: activeConversation?.title ?? '新对话'}
|
||||
</span>
|
||||
</div>
|
||||
{view === 'chat' && (
|
||||
<ScopeBadge
|
||||
scope={
|
||||
@@ -2257,45 +2604,6 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
)}
|
||||
<div className="topbar__actions">
|
||||
{view === 'chat' && (
|
||||
<>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => void copyConversation()}
|
||||
title="复制对话"
|
||||
type="button"
|
||||
>
|
||||
<Copy size={17} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={exportConversation}
|
||||
title="导出 Markdown"
|
||||
type="button"
|
||||
>
|
||||
<Download size={17} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'chat' && (
|
||||
<select
|
||||
aria-label="专家角色"
|
||||
className="topbar__expert"
|
||||
disabled={runtime?.capability === 'image-generation'}
|
||||
onChange={(event) =>
|
||||
setSelectedExpertId(event.target.value)
|
||||
}
|
||||
value={selectedExpertId}
|
||||
>
|
||||
<option value="">通用助手</option>
|
||||
<option value="team">专家团队(并行)</option>
|
||||
{assistantExperts.map((expert) => (
|
||||
<option key={expert.id} value={expert.id}>
|
||||
{expert.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
runtime?.available
|
||||
@@ -2329,37 +2637,61 @@ function App(): React.JSX.Element {
|
||||
<PanelRightOpen size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={
|
||||
view === 'settings'
|
||||
? 'icon-button icon-button--active'
|
||||
: 'icon-button'
|
||||
}
|
||||
type="button"
|
||||
aria-label="安全与 Runtime 设置"
|
||||
onClick={() => setView('settings')}
|
||||
>
|
||||
<ShieldCheck size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="帮助"
|
||||
onClick={() =>
|
||||
setNotice(
|
||||
'输入问题后按 Enter 发送,Shift+Enter 换行。附件只会在你明确选择后发送。'
|
||||
)
|
||||
}
|
||||
>
|
||||
<CircleHelp size={18} />
|
||||
</button>
|
||||
<div className="topbar-menu" ref={topbarMenuRef}>
|
||||
<button
|
||||
aria-expanded={topbarMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="应用菜单"
|
||||
className="icon-button"
|
||||
onClick={() =>
|
||||
setTopbarMenuOpen((current) => !current)
|
||||
}
|
||||
ref={topbarMenuTriggerRef}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
{topbarMenuOpen && (
|
||||
<div
|
||||
aria-label="应用操作"
|
||||
className="topbar-menu__popover"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTopbarMenuOpen(false)
|
||||
setView('settings')
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
安全与 Runtime 设置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTopbarMenuOpen(false)
|
||||
setNotice(
|
||||
'输入问题后按 Enter 发送,Shift+Enter 换行。附件只会在你明确选择后发送。'
|
||||
)
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<CircleHelp size={16} />
|
||||
使用帮助
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<WindowControls onError={setNotice} />
|
||||
</header>
|
||||
|
||||
{view === 'chat' ? (
|
||||
<PageShell variant="reading">
|
||||
<section className="chat" ref={scrollRef}>
|
||||
{activeConversation?.messages.length === 1 && (
|
||||
{activeConversation && isUnusedConversation(activeConversation) && (
|
||||
<div className="welcome">
|
||||
<div className="welcome__badge">
|
||||
<Sparkles size={18} />
|
||||
@@ -2769,12 +3101,33 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
<span className="divider" />
|
||||
<label className="composer__expert">
|
||||
<Bot size={15} />
|
||||
<select
|
||||
aria-label="专家角色"
|
||||
disabled={runtime?.capability === 'image-generation'}
|
||||
onChange={(event) =>
|
||||
setSelectedExpertId(event.target.value)
|
||||
}
|
||||
value={selectedExpertId}
|
||||
>
|
||||
<option value="">通用助手</option>
|
||||
<option value="team">专家团队(并行)</option>
|
||||
{assistantExperts.map((expert) => (
|
||||
<option key={expert.id} value={expert.id}>
|
||||
{expert.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
className={`composer__mode composer__mode--${effectiveWorkMode}`}
|
||||
>
|
||||
<span>模式</span>
|
||||
<select
|
||||
aria-describedby="work-mode-hint"
|
||||
aria-label="工作模式"
|
||||
disabled={agentRuntimeSelected}
|
||||
onChange={(event) =>
|
||||
setWorkMode(event.target.value as WorkMode)
|
||||
}
|
||||
@@ -2925,13 +3278,15 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="composer-hint">
|
||||
<p className="composer-hint" id="work-mode-hint">
|
||||
{notice ??
|
||||
contextError ??
|
||||
(!runtime?.available
|
||||
? '请先配置可用的模型或 Agent Runtime。'
|
||||
: runtime.capability === 'image-generation'
|
||||
? '图像生成模型:输入画面描述后,生成结果会直接显示并保存到成果。'
|
||||
: agentRuntimeSelected
|
||||
? `${runtime.label} 固定为 Execute,工具调用不会弹出 GoodBuddy 审批,并会记录到活动。`
|
||||
: effectiveWorkMode === 'ask'
|
||||
? 'Ask 模式:只读问答,不会调用工具或修改文件。'
|
||||
: effectiveWorkMode === 'plan'
|
||||
@@ -3137,6 +3492,20 @@ function App(): React.JSX.Element {
|
||||
</PageShell>
|
||||
)}
|
||||
</main>
|
||||
{view !== 'chat' && notice && (
|
||||
<div className="app-notice">
|
||||
<span aria-live="polite" role="status">
|
||||
{notice}
|
||||
</span>
|
||||
<button
|
||||
aria-label="关闭通知"
|
||||
onClick={() => setNotice(undefined)}
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<RightAssistantSidebar
|
||||
activities={activityRecords}
|
||||
approvals={pendingSidebarApprovals}
|
||||
@@ -3210,6 +3579,8 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
onRunHeartbeat={runHeartbeat}
|
||||
onSetHeartbeatPaused={setHeartbeatPaused}
|
||||
onListWorkspaceDirectory={listWorkspaceDirectory}
|
||||
onLoadWorkspaceFile={loadWorkspaceFile}
|
||||
onRefreshChanges={refreshWorkspaceChanges}
|
||||
onRespondApproval={(approval, decision) => {
|
||||
void respondToApproval(
|
||||
@@ -3227,6 +3598,7 @@ function App(): React.JSX.Element {
|
||||
tab={assistantSidebarTab}
|
||||
tasks={assistantTasks}
|
||||
workspaceChanges={workspaceChanges}
|
||||
workspaceProjectId={activeProjectId || undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -54,4 +54,21 @@ const ready = true
|
||||
).not.toMatch(/^javascript:/u)
|
||||
expect(container.querySelector('script')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a whole Markdown fence as formatted content', () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer>{`\`\`\`markdown
|
||||
# 方案标题
|
||||
|
||||
- 第一步
|
||||
- 第二步
|
||||
\`\`\``}</MarkdownRenderer>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '方案标题', level: 1 })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('第一步')).toBeInTheDocument()
|
||||
expect(container.querySelector('pre')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import type { Components } from 'react-markdown'
|
||||
@@ -30,7 +31,15 @@ type MarkdownRendererProps = {
|
||||
children: string
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
const wholeMarkdownFence =
|
||||
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
|
||||
|
||||
function unwrapMarkdownFence(content: string): string {
|
||||
const fencedMarkdown = wholeMarkdownFence.exec(content.trim())
|
||||
return fencedMarkdown?.[1] ?? content
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
children
|
||||
}: MarkdownRendererProps): React.JSX.Element {
|
||||
return (
|
||||
@@ -39,7 +48,7 @@ export function MarkdownRenderer({
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml
|
||||
>
|
||||
{children}
|
||||
{unwrapMarkdownFence(children)}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ const runtimeLabels: Record<RuntimeTarget, string> = {
|
||||
opencode: 'OpenCode',
|
||||
continue: 'Continue'
|
||||
}
|
||||
const configurableMcpTargets: RuntimeTarget[] = ['opencode']
|
||||
const configurableMcpTargets: RuntimeTarget[] = ['model']
|
||||
|
||||
type McpEditor = {
|
||||
id?: string
|
||||
@@ -42,7 +42,7 @@ const emptyEditor: McpEditor = {
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['opencode'],
|
||||
assignments: ['model'],
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: '',
|
||||
@@ -57,8 +57,8 @@ function editorFromServer(server: McpServerSummary): McpEditor {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
enabled: server.enabled,
|
||||
assignments: server.assignments.includes('opencode')
|
||||
? ['opencode']
|
||||
assignments: server.assignments.includes('model')
|
||||
? ['model']
|
||||
: [],
|
||||
transport: server.transport,
|
||||
command: server.transport === 'stdio' ? server.command : '',
|
||||
@@ -199,7 +199,7 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
|
||||
<p className="settings-notice">
|
||||
MCP Server 及其工具具有当前用户权限。请仅添加可信服务;远程访问令牌将由系统安全存储加密。
|
||||
当前版本仅由 OpenCode Runtime 加载 MCP 工具。
|
||||
当前版本仅由直连模型在 Execute 模式加载 MCP 工具,并在每次调用前请求 GoodBuddy 审批。
|
||||
</p>
|
||||
{error && <p className="settings-warning">{error}</p>}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
X,
|
||||
XCircle
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import type {
|
||||
AssistantMemory,
|
||||
AssistantSchedule,
|
||||
@@ -22,7 +22,9 @@ import type {
|
||||
HeartbeatCreateInput,
|
||||
ScheduleCreateInput,
|
||||
AssistantTask,
|
||||
WorkspaceChanges
|
||||
WorkspaceChanges,
|
||||
WorkspaceDirectoryListing,
|
||||
WorkspaceFilePreview
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import type {
|
||||
@@ -32,6 +34,7 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
import type { ActivityRecord } from './activity-store'
|
||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
|
||||
export type AssistantSidebarTab =
|
||||
| 'tasks'
|
||||
@@ -71,6 +74,7 @@ type RightAssistantSidebarProps = {
|
||||
heartbeats: AssistantHeartbeatConfig[]
|
||||
heartbeatEntries: AssistantHeartbeatEntry[]
|
||||
workspaceChanges?: WorkspaceChanges
|
||||
workspaceProjectId?: string
|
||||
onClose: () => void
|
||||
onOpenHeartbeat: () => void
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
@@ -89,6 +93,10 @@ type RightAssistantSidebarProps = {
|
||||
onRemoveSchedule: (scheduleId: string) => Promise<void>
|
||||
onRunSchedule: (scheduleId: string) => Promise<void>
|
||||
onRefreshChanges: () => Promise<void>
|
||||
onListWorkspaceDirectory: (
|
||||
path: string
|
||||
) => Promise<WorkspaceDirectoryListing>
|
||||
onLoadWorkspaceFile: (path: string) => Promise<WorkspaceFilePreview>
|
||||
onRemoveMemory: (memoryId: string) => Promise<void>
|
||||
onSetMemoryStatus: (
|
||||
memoryId: string,
|
||||
@@ -111,6 +119,7 @@ const tabs: Array<{
|
||||
{ id: 'changes', label: '更改' },
|
||||
{ id: 'preview', label: '预览' }
|
||||
]
|
||||
const emptyChangedFiles: WorkspaceChanges['files'] = []
|
||||
|
||||
function formatTime(timestamp: number | string): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
@@ -133,6 +142,7 @@ export function RightAssistantSidebar({
|
||||
heartbeats,
|
||||
heartbeatEntries,
|
||||
workspaceChanges,
|
||||
workspaceProjectId,
|
||||
onClose,
|
||||
onOpenHeartbeat,
|
||||
onOpenConversation,
|
||||
@@ -148,13 +158,40 @@ export function RightAssistantSidebar({
|
||||
onRemoveSchedule,
|
||||
onRunSchedule,
|
||||
onRefreshChanges,
|
||||
onListWorkspaceDirectory,
|
||||
onLoadWorkspaceFile,
|
||||
onRemoveMemory,
|
||||
onSetMemoryStatus,
|
||||
onRespondApproval,
|
||||
onTabChange
|
||||
}: RightAssistantSidebarProps): React.JSX.Element {
|
||||
const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
|
||||
const [workspacePreview, setWorkspacePreview] = useState<
|
||||
| {
|
||||
projectId?: string
|
||||
path: string
|
||||
state: 'loading'
|
||||
error?: undefined
|
||||
file?: undefined
|
||||
}
|
||||
| {
|
||||
projectId?: string
|
||||
path: string
|
||||
state: 'error'
|
||||
error: string
|
||||
file?: undefined
|
||||
}
|
||||
| {
|
||||
projectId?: string
|
||||
path: string
|
||||
state: 'ready'
|
||||
error?: undefined
|
||||
file: WorkspaceFilePreview
|
||||
}
|
||||
>()
|
||||
const workspacePreviewRequest = useRef(0)
|
||||
const [memoryDraft, setMemoryDraft] = useState('')
|
||||
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
|
||||
const [scheduleTitle, setScheduleTitle] = useState('')
|
||||
const [schedulePrompt, setSchedulePrompt] = useState('')
|
||||
const [scheduleTime, setScheduleTime] = useState('')
|
||||
@@ -167,9 +204,75 @@ export function RightAssistantSidebar({
|
||||
const changes = activities
|
||||
.filter((activity) => activity.kind === 'tool')
|
||||
.slice(0, 30)
|
||||
const preview =
|
||||
const artifactPreview =
|
||||
artifacts.find((artifact) => artifact.id === selectedArtifactId) ??
|
||||
artifacts[0]
|
||||
const currentWorkspacePreview =
|
||||
workspacePreview?.projectId === workspaceProjectId
|
||||
? workspacePreview
|
||||
: undefined
|
||||
|
||||
const openWorkspaceFile = (path: string): void => {
|
||||
const requestId = workspacePreviewRequest.current + 1
|
||||
workspacePreviewRequest.current = requestId
|
||||
const projectId = workspaceProjectId
|
||||
setWorkspacePreview({ projectId, path, state: 'loading' })
|
||||
onTabChange('preview')
|
||||
void onLoadWorkspaceFile(path)
|
||||
.then((file) => {
|
||||
if (workspacePreviewRequest.current === requestId) {
|
||||
setWorkspacePreview({
|
||||
projectId,
|
||||
path,
|
||||
state: 'ready',
|
||||
file
|
||||
})
|
||||
setSelectedArtifactId(undefined)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (workspacePreviewRequest.current === requestId) {
|
||||
setWorkspacePreview({
|
||||
path,
|
||||
projectId,
|
||||
state: 'error',
|
||||
error:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '工作区文件预览失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const moveTabFocus = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
tabId: AssistantSidebarTab
|
||||
): void => {
|
||||
const index = tabs.findIndex((item) => item.id === tabId)
|
||||
const targetIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? tabs.length - 1
|
||||
: event.key === 'ArrowLeft'
|
||||
? (index - 1 + tabs.length) % tabs.length
|
||||
: event.key === 'ArrowRight'
|
||||
? (index + 1) % tabs.length
|
||||
: -1
|
||||
if (targetIndex < 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const target = tabs[targetIndex]
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
onTabChange(target.id)
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -194,18 +297,26 @@ export function RightAssistantSidebar({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav aria-label="工作栏分类" className="assistant-sidebar__tabs">
|
||||
<nav
|
||||
aria-label="工作栏分类"
|
||||
className="assistant-sidebar__tabs"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
aria-controls="assistant-sidebar-panel"
|
||||
aria-selected={tab === item.id}
|
||||
className={
|
||||
tab === item.id
|
||||
? 'assistant-sidebar__tab assistant-sidebar__tab--active'
|
||||
: 'assistant-sidebar__tab'
|
||||
}
|
||||
id={`assistant-sidebar-tab-${item.id}`}
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
onKeyDown={(event) => moveTabFocus(event, item.id)}
|
||||
role="tab"
|
||||
tabIndex={tab === item.id ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
@@ -218,7 +329,12 @@ export function RightAssistantSidebar({
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="assistant-sidebar__body">
|
||||
<div
|
||||
aria-labelledby={`assistant-sidebar-tab-${tab}`}
|
||||
className="assistant-sidebar__body"
|
||||
id="assistant-sidebar-panel"
|
||||
role="tabpanel"
|
||||
>
|
||||
{tab === 'tasks' && (
|
||||
<section className="assistant-sidebar__section">
|
||||
{approvals.length > 0 && (
|
||||
@@ -593,6 +709,8 @@ export function RightAssistantSidebar({
|
||||
className="assistant-sidebar__row"
|
||||
key={artifact.id}
|
||||
onClick={() => {
|
||||
workspacePreviewRequest.current += 1
|
||||
setWorkspacePreview(undefined)
|
||||
setSelectedArtifactId(artifact.id)
|
||||
onTabChange('preview')
|
||||
void onLoadArtifact(artifact.id)
|
||||
@@ -615,36 +733,43 @@ export function RightAssistantSidebar({
|
||||
<>
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>
|
||||
<FileDiff size={15} />
|
||||
Git 工作区
|
||||
<FolderTree size={15} />
|
||||
项目工作区
|
||||
<button
|
||||
aria-label="刷新文件更改"
|
||||
aria-label="刷新工作区文件"
|
||||
className="icon-button"
|
||||
onClick={() => void onRefreshChanges()}
|
||||
onClick={() => {
|
||||
setWorkspaceRefreshVersion((current) => current + 1)
|
||||
void onRefreshChanges()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</h3>
|
||||
{!workspaceChanges?.available ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{workspaceChanges?.error ?? '正在读取工作区更改…'}
|
||||
</p>
|
||||
) : workspaceChanges.status ||
|
||||
workspaceChanges.patch ? (
|
||||
<pre className="assistant-sidebar__diff">
|
||||
{[workspaceChanges.status, workspaceChanges.patch]
|
||||
.filter(Boolean)
|
||||
.join('\n')}
|
||||
{workspaceChanges.truncated
|
||||
? '\n\n[输出超过安全限制,已截断]'
|
||||
: ''}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="assistant-sidebar__empty">
|
||||
工作区没有未提交更改。
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
|
||||
key={`${workspaceProjectId ?? 'none'}:${workspaceRefreshVersion}`}
|
||||
onListDirectory={onListWorkspaceDirectory}
|
||||
onOpenFile={openWorkspaceFile}
|
||||
projectId={workspaceProjectId}
|
||||
/>
|
||||
{workspaceChanges?.error && (
|
||||
<p className="workspace-files__status">
|
||||
Git 状态不可用:{workspaceChanges.error}
|
||||
</p>
|
||||
)}
|
||||
{workspaceChanges?.patch && (
|
||||
<details className="assistant-sidebar__diff-details">
|
||||
<summary>查看完整 Git diff</summary>
|
||||
<pre className="assistant-sidebar__diff">
|
||||
{workspaceChanges.patch}
|
||||
{workspaceChanges.truncated
|
||||
? '\n\n[输出超过安全限制,已截断]'
|
||||
: ''}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>工具活动</h3>
|
||||
@@ -677,37 +802,68 @@ export function RightAssistantSidebar({
|
||||
|
||||
{tab === 'preview' && (
|
||||
<section className="assistant-sidebar__preview">
|
||||
{preview ? (
|
||||
{currentWorkspacePreview ? (
|
||||
<>
|
||||
<header>
|
||||
<strong>{preview.title}</strong>
|
||||
<small>{formatTime(preview.createdAt)}</small>
|
||||
<strong>{currentWorkspacePreview.path}</strong>
|
||||
<small>
|
||||
{currentWorkspacePreview.state === 'ready'
|
||||
? `${currentWorkspacePreview.file.size.toLocaleString('zh-CN')} 字节`
|
||||
: '项目工作区文件'}
|
||||
</small>
|
||||
</header>
|
||||
{currentWorkspacePreview.state === 'loading' ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
正在读取文件…
|
||||
</p>
|
||||
) : currentWorkspacePreview.state === 'error' ? (
|
||||
<p className="assistant-sidebar__empty" role="alert">
|
||||
{currentWorkspacePreview.error}
|
||||
</p>
|
||||
) : (
|
||||
<div className="markdown-body markdown-content">
|
||||
{currentWorkspacePreview.file.mimeType ===
|
||||
'text/markdown' ? (
|
||||
<MarkdownRenderer>
|
||||
{currentWorkspacePreview.file.content}
|
||||
</MarkdownRenderer>
|
||||
) : (
|
||||
<pre>{currentWorkspacePreview.file.content}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : artifactPreview ? (
|
||||
<>
|
||||
<header>
|
||||
<strong>{artifactPreview.title}</strong>
|
||||
<small>{formatTime(artifactPreview.createdAt)}</small>
|
||||
</header>
|
||||
<div className="markdown-body markdown-content">
|
||||
{preview.mimeType.startsWith('image/') ? (
|
||||
preview.content ? (
|
||||
{artifactPreview.mimeType.startsWith('image/') ? (
|
||||
artifactPreview.content ? (
|
||||
<img
|
||||
alt={preview.title}
|
||||
alt={artifactPreview.title}
|
||||
className="assistant-sidebar__image-preview"
|
||||
src={preview.content}
|
||||
src={artifactPreview.content}
|
||||
/>
|
||||
) : (
|
||||
<p className="assistant-sidebar__empty">
|
||||
正在加载图片…
|
||||
</p>
|
||||
)
|
||||
) : preview.mimeType === 'text/html' ? (
|
||||
) : artifactPreview.mimeType === 'text/html' ? (
|
||||
<iframe
|
||||
className="assistant-sidebar__web-preview"
|
||||
sandbox=""
|
||||
srcDoc={preview.content}
|
||||
title={preview.title}
|
||||
srcDoc={artifactPreview.content}
|
||||
title={artifactPreview.title}
|
||||
/>
|
||||
) : preview.mimeType === 'application/json' ? (
|
||||
<pre>{preview.content}</pre>
|
||||
) : artifactPreview.mimeType === 'application/json' ? (
|
||||
<pre>{artifactPreview.content}</pre>
|
||||
) : (
|
||||
<MarkdownRenderer>
|
||||
{preview.content}
|
||||
{artifactPreview.content}
|
||||
</MarkdownRenderer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -250,7 +250,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByText(/自定义 Continue 可执行文件将以当前用户权限运行/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/仅在实际请求高风险工具时暂停/)
|
||||
screen.getByText(/Continue 固定以 Execute 运行/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/不会匿名加载远程默认模型/)
|
||||
@@ -375,6 +375,32 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Responses for the official OpenAI preset', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
fireEvent.change(await screen.findByLabelText('模型预设'), {
|
||||
target: { value: 'openai' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '从预设添加' })
|
||||
)
|
||||
|
||||
const protocol = screen.getByLabelText('接口协议 OpenAI')
|
||||
expect(protocol).toHaveValue('openai-responses')
|
||||
expect(
|
||||
within(protocol).getByRole('option', { name: 'OpenAI Responses' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the BigToken gpt-image-2 preset as image generation', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -589,5 +615,10 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /添加 Server/ })
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /添加 Server/ }))
|
||||
expect(screen.getByLabelText('模型')).toBeChecked()
|
||||
expect(
|
||||
screen.queryByLabelText('OpenCode')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -466,7 +466,9 @@ export function SettingsPanel({
|
||||
|
||||
const isContinueCompatible = (
|
||||
profile: ModelProfileDraft
|
||||
): boolean => profile.protocol !== 'openai-images-generations'
|
||||
): boolean =>
|
||||
profile.protocol === 'anthropic-messages' ||
|
||||
profile.protocol === 'openai-chat-completions'
|
||||
|
||||
const detectionSummary = (
|
||||
value: AgentRuntimeDetection['opencode'] | undefined
|
||||
@@ -766,6 +768,9 @@ export function SettingsPanel({
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<div className="runtime-note">
|
||||
OpenCode 固定以 Execute 运行,不弹出 GoodBuddy 工具审批;工具调用仍记录到活动。
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Server 地址</span>
|
||||
<input
|
||||
@@ -872,7 +877,7 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
Continue 仅在实际请求高风险工具时暂停,并提供仅此次、此会话或永久允许。
|
||||
Continue 固定以 Execute 运行,不弹出 GoodBuddy 工具审批;工具调用仍记录到活动。
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>模型连接</span>
|
||||
@@ -903,8 +908,8 @@ export function SettingsPanel({
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
Continue 支持 Anthropic Messages、OpenAI Chat
|
||||
Completions 和无认证本机模型。未选择独立连接时,必须在下方指定配置文件。
|
||||
Continue 独立连接支持 Anthropic Messages、OpenAI
|
||||
兼容 Chat Completions 和无认证本机模型。未选择独立连接时,必须在下方指定配置文件。
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
@@ -998,8 +1003,9 @@ export function SettingsPanel({
|
||||
<div>
|
||||
<strong>模型连接</strong>
|
||||
<small>
|
||||
可配置文本对话或 OpenAI Images Generations
|
||||
图像生成接口
|
||||
直连文本支持 OpenAI Responses、Anthropic Messages 和
|
||||
OpenAI 兼容 Chat Completions;另可配置 OpenAI Images
|
||||
Generations 图像生成接口
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
@@ -1136,7 +1142,8 @@ export function SettingsPanel({
|
||||
setOpencodeModelSource({ kind: 'platform' })
|
||||
}
|
||||
if (
|
||||
protocol === 'openai-images-generations' &&
|
||||
protocol !== 'anthropic-messages' &&
|
||||
protocol !== 'openai-chat-completions' &&
|
||||
continueModelSource.kind === 'profile' &&
|
||||
continueModelSource.profileId === profile.id
|
||||
) {
|
||||
@@ -1149,8 +1156,11 @@ export function SettingsPanel({
|
||||
<option value="anthropic-messages">
|
||||
Anthropic Messages
|
||||
</option>
|
||||
<option value="openai-responses">
|
||||
OpenAI Responses
|
||||
</option>
|
||||
<option value="openai-chat-completions">
|
||||
OpenAI Chat Completions
|
||||
OpenAI 兼容 Chat Completions
|
||||
</option>
|
||||
<option value="openai-images-generations">
|
||||
OpenAI Images Generations(图像生成)
|
||||
@@ -1287,7 +1297,7 @@ export function SettingsPanel({
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Agent 工具安全策略</span>
|
||||
<span>直连模型工具安全策略</span>
|
||||
<select
|
||||
value={toolApproval}
|
||||
onChange={(event) =>
|
||||
@@ -1300,7 +1310,9 @@ export function SettingsPanel({
|
||||
<option value="policy">禁止所有工具执行</option>
|
||||
</select>
|
||||
<small>
|
||||
Continue 会在具体高风险工具调用时提供仅此次、此会话和永久允许。
|
||||
直连模型的 Execute 模式可使用内置工作区工具及已分配的
|
||||
MCP 工具,每次调用均受此策略控制。OpenCode 与 Continue
|
||||
继续使用各自的工具系统。
|
||||
</small>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('WorkspaceFilesPanel', () => {
|
||||
it('lists the project tree, expands directories, and opens files', async () => {
|
||||
const onListDirectory = vi.fn(async (path: string) =>
|
||||
path
|
||||
? {
|
||||
path,
|
||||
entries: [
|
||||
{
|
||||
name: 'guide.md',
|
||||
path: 'docs/guide.md',
|
||||
type: 'file' as const
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
}
|
||||
: {
|
||||
path,
|
||||
entries: [
|
||||
{
|
||||
name: 'docs',
|
||||
path: 'docs',
|
||||
type: 'directory' as const
|
||||
},
|
||||
{
|
||||
name: 'notes.txt',
|
||||
path: 'notes.txt',
|
||||
type: 'file' as const
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
}
|
||||
)
|
||||
const onOpenFile = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenFile={onOpenFile}
|
||||
projectId="00000000-0000-4000-8000-000000000101"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('当前工作区')).toBeInTheDocument()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /docs/u }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /guide\.md/u })
|
||||
)
|
||||
|
||||
expect(onListDirectory).toHaveBeenCalledWith('')
|
||||
expect(onListDirectory).toHaveBeenCalledWith('docs')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('docs/guide.md')
|
||||
expect(screen.getAllByText('修改')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores stale directory results after the active project changes', async () => {
|
||||
let resolveFirst:
|
||||
| ((value: {
|
||||
path: string
|
||||
entries: []
|
||||
truncated: false
|
||||
}) => void)
|
||||
| undefined
|
||||
const onListDirectory = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValue({
|
||||
path: '',
|
||||
entries: [],
|
||||
truncated: false
|
||||
})
|
||||
const { rerender } = render(
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenFile={vi.fn()}
|
||||
projectId="00000000-0000-4000-8000-000000000101"
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(onListDirectory).toHaveBeenCalledOnce())
|
||||
rerender(
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenFile={vi.fn()}
|
||||
projectId="00000000-0000-4000-8000-000000000102"
|
||||
/>
|
||||
)
|
||||
resolveFirst?.({ path: '', entries: [], truncated: false })
|
||||
|
||||
await waitFor(() => expect(onListDirectory).toHaveBeenCalledTimes(2))
|
||||
expect(await screen.findByText('工作区为空。')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceDirectoryEntry,
|
||||
WorkspaceDirectoryListing
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
type WorkspaceFilesPanelProps = {
|
||||
projectId?: string
|
||||
changedFiles: WorkspaceChangedFile[]
|
||||
onListDirectory: (path: string) => Promise<WorkspaceDirectoryListing>
|
||||
onOpenFile: (path: string) => void
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const value = status.trim()
|
||||
if (value === '??') {
|
||||
return '新增'
|
||||
}
|
||||
if (value.includes('D')) {
|
||||
return '删除'
|
||||
}
|
||||
if (value.includes('R')) {
|
||||
return '重命名'
|
||||
}
|
||||
if (value.includes('A')) {
|
||||
return '新增'
|
||||
}
|
||||
return '修改'
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({
|
||||
projectId,
|
||||
changedFiles,
|
||||
onListDirectory,
|
||||
onOpenFile
|
||||
}: WorkspaceFilesPanelProps): React.JSX.Element {
|
||||
const [listingState, setListingState] = useState<{
|
||||
projectId?: string
|
||||
value: Record<string, WorkspaceDirectoryListing>
|
||||
}>({ value: {} })
|
||||
const [expandedState, setExpandedState] = useState<{
|
||||
projectId?: string
|
||||
value: Set<string>
|
||||
}>({ value: new Set() })
|
||||
const [loadingState, setLoadingState] = useState<{
|
||||
projectId?: string
|
||||
value: Set<string>
|
||||
}>({ value: new Set() })
|
||||
const [errorState, setErrorState] = useState<{
|
||||
projectId?: string
|
||||
value?: string
|
||||
}>({})
|
||||
const requestGeneration = useRef(0)
|
||||
const inFlightPaths = useRef(new Set<string>())
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (path: string, generation: number): Promise<void> => {
|
||||
if (!projectId || inFlightPaths.current.has(path)) {
|
||||
return
|
||||
}
|
||||
inFlightPaths.current.add(path)
|
||||
setLoadingState((current) => {
|
||||
const next = new Set(
|
||||
current.projectId === projectId ? current.value : []
|
||||
)
|
||||
next.add(path)
|
||||
return { projectId, value: next }
|
||||
})
|
||||
try {
|
||||
const listing = await onListDirectory(path)
|
||||
if (requestGeneration.current !== generation) {
|
||||
return
|
||||
}
|
||||
setListingState((current) => ({
|
||||
projectId,
|
||||
value: {
|
||||
...(current.projectId === projectId ? current.value : {}),
|
||||
[path]: listing
|
||||
}
|
||||
}))
|
||||
setErrorState({ projectId })
|
||||
} catch (reason) {
|
||||
if (requestGeneration.current === generation) {
|
||||
setErrorState({
|
||||
projectId,
|
||||
value:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '工作区文件读取失败'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
inFlightPaths.current.delete(path)
|
||||
if (requestGeneration.current === generation) {
|
||||
setLoadingState((current) => {
|
||||
const next = new Set(
|
||||
current.projectId === projectId ? current.value : []
|
||||
)
|
||||
next.delete(path)
|
||||
return { projectId, value: next }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[onListDirectory, projectId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
requestGeneration.current += 1
|
||||
const generation = requestGeneration.current
|
||||
inFlightPaths.current.clear()
|
||||
if (!projectId) {
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
void loadDirectory('', generation)
|
||||
}, 0)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [loadDirectory, projectId])
|
||||
|
||||
const changedByPath = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
changedFiles.map((file) => [
|
||||
file.path.replaceAll('\\', '/'),
|
||||
file
|
||||
])
|
||||
),
|
||||
[changedFiles]
|
||||
)
|
||||
const emptyPaths = useMemo(() => new Set<string>(), [])
|
||||
const listings =
|
||||
listingState.projectId === projectId ? listingState.value : {}
|
||||
const expandedPaths =
|
||||
expandedState.projectId === projectId
|
||||
? expandedState.value
|
||||
: emptyPaths
|
||||
const loadingPaths =
|
||||
loadingState.projectId === projectId
|
||||
? loadingState.value
|
||||
: emptyPaths
|
||||
const error =
|
||||
errorState.projectId === projectId ? errorState.value : undefined
|
||||
|
||||
const toggleDirectory = (path: string): void => {
|
||||
const expanding = !expandedPaths.has(path)
|
||||
setExpandedState((current) => {
|
||||
const next = new Set(
|
||||
current.projectId === projectId ? current.value : []
|
||||
)
|
||||
if (expanding) {
|
||||
next.add(path)
|
||||
} else {
|
||||
next.delete(path)
|
||||
}
|
||||
return { projectId, value: next }
|
||||
})
|
||||
if (expanding && !listings[path]) {
|
||||
void loadDirectory(path, requestGeneration.current)
|
||||
}
|
||||
}
|
||||
|
||||
const renderEntry = (
|
||||
entry: WorkspaceDirectoryEntry
|
||||
): React.JSX.Element => {
|
||||
const expanded = expandedPaths.has(entry.path)
|
||||
const listing = listings[entry.path]
|
||||
const changed = changedByPath.get(entry.path)
|
||||
if (entry.type === 'directory') {
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="workspace-files__row"
|
||||
onClick={() => toggleDirectory(entry.path)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={13} />
|
||||
) : (
|
||||
<ChevronRight size={13} />
|
||||
)}
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="workspace-files__children">
|
||||
{listing?.entries.map((child) =>
|
||||
renderEntry(child)
|
||||
)}
|
||||
{loadingPaths.has(entry.path) && (
|
||||
<p className="workspace-files__status">正在读取…</p>
|
||||
)}
|
||||
{listing?.truncated && (
|
||||
<p className="workspace-files__status">
|
||||
目录项目超过 500 项,仅显示前 500 项。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="workspace-files__row"
|
||||
key={entry.path}
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
title={entry.path}
|
||||
type="button"
|
||||
>
|
||||
<span className="workspace-files__indent" />
|
||||
<FileText size={15} />
|
||||
<span>{entry.name}</span>
|
||||
{changed && (
|
||||
<small className="workspace-files__change">
|
||||
{statusLabel(changed.status)}
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<p className="assistant-sidebar__empty">
|
||||
选择项目后可浏览项目工作区。
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const root = listings['']
|
||||
return (
|
||||
<div className="workspace-files">
|
||||
{changedFiles.length > 0 && (
|
||||
<div className="workspace-files__changed">
|
||||
<strong>未提交更改</strong>
|
||||
{changedFiles.slice(0, 50).map((file) => {
|
||||
const deleted = file.status.includes('D')
|
||||
return (
|
||||
<button
|
||||
className="workspace-files__changed-row"
|
||||
disabled={deleted}
|
||||
key={`${file.status}:${file.path}`}
|
||||
onClick={() => onOpenFile(file.path)}
|
||||
title={file.path}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{file.path}</span>
|
||||
<small>{statusLabel(file.status)}</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{changedFiles.length > 50 && (
|
||||
<p className="workspace-files__status">
|
||||
仅显示前 50 个未提交更改。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<strong className="workspace-files__heading">当前工作区</strong>
|
||||
{loadingPaths.has('') && !root ? (
|
||||
<p className="assistant-sidebar__empty">正在读取工作区…</p>
|
||||
) : error && !root ? (
|
||||
<p className="assistant-sidebar__empty">{error}</p>
|
||||
) : root?.entries.length ? (
|
||||
<>
|
||||
<div className="workspace-files__tree">
|
||||
{root.entries.map((entry) => renderEntry(entry))}
|
||||
</div>
|
||||
{root.truncated && (
|
||||
<p className="workspace-files__status">
|
||||
根目录项目超过 500 项,仅显示前 500 项。
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="assistant-sidebar__empty">工作区为空。</p>
|
||||
)}
|
||||
{error && root && (
|
||||
<p className="workspace-files__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+363
-52
@@ -118,6 +118,8 @@ textarea:focus-visible {
|
||||
min-width: 248px;
|
||||
padding: 0 8px 20px;
|
||||
gap: 11px;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
@@ -492,10 +494,55 @@ textarea:focus-visible {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: 0 21px;
|
||||
padding: 0 0 0 21px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
gap: 3px;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.topbar :is(button, input, select),
|
||||
.topbar__actions,
|
||||
.window-controls {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.window-control {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
min-width: 46px;
|
||||
height: 100%;
|
||||
place-items: center;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--motion-fast, 120ms) ease-out,
|
||||
color var(--motion-fast, 120ms) ease-out;
|
||||
}
|
||||
|
||||
.window-control:hover {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.window-control--close:hover {
|
||||
background: var(--danger-solid);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.window-control:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@@ -519,18 +566,6 @@ textarea:focus-visible {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.topbar__expert {
|
||||
width: clamp(86px, 11vw, 130px);
|
||||
min-width: 0;
|
||||
max-width: 130px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
color: #595959;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.assistant-sidebar {
|
||||
display: flex;
|
||||
width: 0;
|
||||
@@ -651,6 +686,113 @@ textarea:focus-visible {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant-sidebar__diff-details {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.assistant-sidebar__diff-details summary {
|
||||
padding: var(--space-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-files {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-files__heading,
|
||||
.workspace-files__changed > strong {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workspace-files__changed {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-files__changed-row,
|
||||
.workspace-files__row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-files__changed-row {
|
||||
padding: var(--space-2);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.workspace-files__row {
|
||||
padding: var(--space-2);
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.workspace-files__changed-row:hover:not(:disabled),
|
||||
.workspace-files__row:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-files__changed-row:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.workspace-files__changed-row span,
|
||||
.workspace-files__row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-files__changed-row small,
|
||||
.workspace-files__change {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workspace-files__tree {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-files__children {
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
|
||||
.workspace-files__indent {
|
||||
width: 13px;
|
||||
}
|
||||
|
||||
.workspace-files__status,
|
||||
.workspace-files__error {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workspace-files__error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.assistant-sidebar__row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -1101,6 +1243,7 @@ textarea:focus-visible {
|
||||
|
||||
.conversation-title {
|
||||
display: flex;
|
||||
max-width: min(360px, 36vw);
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: 7px 9px;
|
||||
@@ -1108,7 +1251,7 @@ textarea:focus-visible {
|
||||
margin-left: 4px;
|
||||
background: transparent;
|
||||
color: #1f1f1f;
|
||||
cursor: pointer;
|
||||
flex: 0 1 auto;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
@@ -1120,15 +1263,6 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-title svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.conversation-title:hover {
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.topbar__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1138,6 +1272,44 @@ textarea:focus-visible {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.topbar-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar-menu__popover {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: calc(100% + 7px);
|
||||
right: 0;
|
||||
display: grid;
|
||||
width: 210px;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.topbar-menu__popover button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topbar-menu__popover button:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1736,13 +1908,54 @@ textarea:focus-visible {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.composer__expert,
|
||||
.composer__mode {
|
||||
display: flex;
|
||||
height: 29px;
|
||||
align-items: center;
|
||||
padding: 0 4px 0 8px;
|
||||
border: 1px solid #91caff;
|
||||
border: 1px solid;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.composer__expert {
|
||||
max-width: 150px;
|
||||
padding: 0 5px 0 8px;
|
||||
border-color: var(--border-default);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.composer__expert svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.composer__expert select,
|
||||
.composer__mode select {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.composer__expert select {
|
||||
min-width: 0;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.composer__expert:focus-within,
|
||||
.composer__mode:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.composer__mode {
|
||||
padding: 0 4px 0 8px;
|
||||
border-color: #91caff;
|
||||
background: #e6f4ff;
|
||||
color: #0958d9;
|
||||
font-size: 10px;
|
||||
@@ -1752,11 +1965,6 @@ textarea:focus-visible {
|
||||
|
||||
.composer__mode select {
|
||||
max-width: 138px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -2407,12 +2615,12 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.conversation-row .conversation-item {
|
||||
padding-right: 32px;
|
||||
padding-right: 58px;
|
||||
}
|
||||
|
||||
.conversation-more,
|
||||
.conversation-delete {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
@@ -2424,14 +2632,134 @@ textarea:focus-visible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.conversation-more {
|
||||
right: 31px;
|
||||
}
|
||||
|
||||
.conversation-delete {
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.conversation-row:hover .conversation-more,
|
||||
.conversation-row:hover .conversation-delete,
|
||||
.conversation-row--active .conversation-more,
|
||||
.conversation-more:focus-visible,
|
||||
.conversation-delete:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conversation-more[aria-expanded='true'] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conversation-more:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conversation-delete:hover {
|
||||
background: #fafafa;
|
||||
color: #ff4d4f;
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.conversation-actions {
|
||||
display: grid;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
margin: 2px 5px var(--space-2);
|
||||
background: var(--surface-subtle);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.conversation-actions button {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.conversation-actions button:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conversation-rename {
|
||||
display: grid;
|
||||
padding: var(--space-1);
|
||||
margin: 2px 5px var(--space-2);
|
||||
gap: var(--space-1);
|
||||
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
||||
}
|
||||
|
||||
.conversation-rename input {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.conversation-rename button {
|
||||
display: grid;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-notice {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
bottom: var(--space-6);
|
||||
left: 50%;
|
||||
display: flex;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-body);
|
||||
gap: var(--space-3);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.app-notice span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.app-notice button {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-notice button:hover {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conversation-empty {
|
||||
@@ -2441,23 +2769,6 @@ textarea:focus-visible {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.title-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.title-editor input {
|
||||
width: min(360px, 36vw);
|
||||
height: 32px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #1677ff;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-sources {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4652,7 +4963,7 @@ textarea:focus-visible {
|
||||
.assistant-sidebar {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 0;
|
||||
top: 58px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
box-shadow: -12px 0 30px rgb(0 0 0 / 10%);
|
||||
|
||||
Reference in New Issue
Block a user