feat: expand secure runtime and workspace UX

This commit is contained in:
lofyer
2026-08-04 00:57:08 +08:00
parent 09e9fbf5e2
commit 2910d315f7
56 changed files with 6164 additions and 882 deletions
+396 -9
View File
@@ -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
View File
@@ -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()
})
})
+12 -3
View File
@@ -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>
)
}
})
+5 -5
View File
@@ -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>}
+195 -39
View File
@@ -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>
+32 -1
View File
@@ -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()
})
})
+22 -10
View File
@@ -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 MessagesOpenAI Chat
Completions
Continue Anthropic MessagesOpenAI
Chat Completions
</small>
</label>
<label className="field">
@@ -998,8 +1003,9 @@ export function SettingsPanel({
<div>
<strong></strong>
<small>
OpenAI Images Generations
OpenAI ResponsesAnthropic 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()
})
})
+302
View File
@@ -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
View File
@@ -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%);