chore: prepare GoodBuddy 0.8.6
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled

This commit is contained in:
lofyer
2026-08-07 13:11:45 +08:00
parent 32aba176c8
commit 2c715e5e81
40 changed files with 3079 additions and 358 deletions
+171
View File
@@ -0,0 +1,171 @@
import { CircleHelp } from 'lucide-react'
import { useMemo, useState } from 'react'
import type {
AgentEvent,
AgentQuestionAnswer
} from '../../shared/contracts'
type AgentQuestion = Extract<AgentEvent, { type: 'question' }>
type AgentQuestionCardProps = {
value: AgentQuestion
onReject: () => Promise<void>
onSubmit: (answers: AgentQuestionAnswer[]) => Promise<void>
}
export function AgentQuestionCard({
value,
onReject,
onSubmit
}: AgentQuestionCardProps): React.JSX.Element {
const [selected, setSelected] = useState<string[][]>(
value.questions.map(() => [])
)
const [custom, setCustom] = useState<string[]>(
value.questions.map(() => '')
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const answers = useMemo(
() =>
value.questions.map((question, index) => {
const ownAnswer = custom[index]?.trim()
const choices = selected[index] ?? []
return [
...choices,
...(ownAnswer && (question.multiple || choices.length === 0)
? [ownAnswer]
: [])
]
}),
[custom, selected, value.questions]
)
const complete = answers.every((answer) => answer.length > 0)
const run = async (action: () => Promise<void>): Promise<void> => {
setSubmitting(true)
setError('')
try {
await action()
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '回答提交失败,请重试'
)
setSubmitting(false)
}
}
return (
<form
className="agent-question-card"
onSubmit={(event) => {
event.preventDefault()
if (complete) {
void run(() => onSubmit(answers))
}
}}
>
<header>
<CircleHelp aria-hidden="true" size={18} />
<strong>OpenCode </strong>
</header>
{value.questions.map((question, questionIndex) => (
<fieldset key={`${question.header}:${questionIndex}`}>
<legend>
<span>{question.header}</span>
{question.question}
</legend>
{question.options.map((option) => {
const checked =
selected[questionIndex]?.includes(option.label) ?? false
return (
<label key={option.label}>
<input
checked={checked}
disabled={submitting}
name={`agent-question-${value.questionId}-${questionIndex}`}
onChange={() => {
setSelected((current) =>
current.map((answer, index) =>
index !== questionIndex
? answer
: question.multiple
? checked
? answer.filter(
(label) => label !== option.label
)
: [...answer, option.label]
: [option.label]
)
)
if (!question.multiple) {
setCustom((current) =>
current.map((answer, index) =>
index === questionIndex ? '' : answer
)
)
}
}}
type={question.multiple ? 'checkbox' : 'radio'}
/>
<span>
<strong>{option.label}</strong>
{option.description && <small>{option.description}</small>}
</span>
</label>
)
})}
{(question.custom || question.options.length === 0) && (
<label className="agent-question-card__custom">
<span></span>
<input
disabled={submitting}
maxLength={2_000}
onChange={(event) => {
const answer = event.target.value
setCustom((current) =>
current.map((item, index) =>
index === questionIndex ? answer : item
)
)
if (!question.multiple && answer.trim()) {
setSelected((current) =>
current.map((item, index) =>
index === questionIndex ? [] : item
)
)
}
}}
placeholder="输入你的回答"
type="text"
value={custom[questionIndex] ?? ''}
/>
</label>
)}
</fieldset>
))}
{error && (
<p className="agent-question-card__error" role="alert">
{error}
</p>
)}
<footer>
<button
className="secondary-button"
disabled={submitting}
onClick={() => void run(onReject)}
type="button"
>
</button>
<button
className="primary-button"
disabled={submitting || !complete}
type="submit"
>
{submitting ? '提交中…' : '提交回答'}
</button>
</footer>
</form>
)
}
+233 -6
View File
@@ -85,6 +85,7 @@ const api: DesktopApi = {
run,
cancel: vi.fn(async () => {}),
respondApproval: vi.fn(async () => {}),
respondQuestion: vi.fn(async () => {}),
onEvent: vi.fn((listener) => {
agentListener = listener
return () => {
@@ -265,7 +266,8 @@ const api: DesktopApi = {
...input,
id: _projectId
})),
setArchived: vi.fn(async () => {})
setArchived: vi.fn(async () => {}),
delete: vi.fn(async () => {})
},
conversations: {
list: vi.fn(async () => []),
@@ -291,7 +293,8 @@ const api: DesktopApi = {
content: '',
mimeType: 'text/plain' as const,
size: 0
}))
})),
openPath: vi.fn(async () => {})
},
tasks: {
list: vi.fn(async () => []),
@@ -765,7 +768,7 @@ describe('App', () => {
expect(await screen.findByRole('status')).toBeVisible()
})
it('sends a prompt and renders streamed agent content', async () => {
it('renders streamed reasoning, text, and tools in event order', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
@@ -815,6 +818,55 @@ describe('App', () => {
expect(streamingReasoning).toHaveAttribute('open')
expect(screen.getByText('先检查项目结构')).toBeInTheDocument()
act(() => {
if (!request) {
throw new Error('Missing request')
}
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'read',
state: 'running',
summary: 'OpenCode 工具:read'
})
agentListener?.({
requestId: request.requestId,
type: 'reasoning',
delta: '再检查关键文件'
})
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '最终结论'
})
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'read',
state: 'completed',
summary: 'OpenCode 工具:read'
})
})
const assistantArticle = screen
.getByText('最终结论')
.closest('article')
const orderedBlocks = [
...assistantArticle!.querySelectorAll('.message-blocks > *')
].map((element) => element.textContent)
expect(orderedBlocks).toEqual([
expect.stringContaining('这是回答内容'),
expect.stringContaining('先检查项目结构'),
expect.stringContaining('OpenCode 工具:read'),
expect.stringContaining('再检查关键文件'),
expect.stringContaining('最终结论')
])
expect(
screen.getAllByText('OpenCode 工具:read')
).toHaveLength(1)
act(() => {
if (!request) {
throw new Error('Missing request')
@@ -825,8 +877,11 @@ describe('App', () => {
})
})
const completedReasoning = await screen.findByText('推理过程')
expect(completedReasoning.closest('details')).not.toHaveAttribute('open')
const completedReasoning = await screen.findAllByText('推理过程')
expect(completedReasoning).toHaveLength(2)
for (const reasoning of completedReasoning) {
expect(reasoning.closest('details')).not.toHaveAttribute('open')
}
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
@@ -1274,7 +1329,7 @@ describe('App', () => {
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
fireEvent.click(
await screen.findByRole('button', { name: /README\.md/u })
await screen.findByRole('button', { name: 'README.md' })
)
expect(
@@ -1286,6 +1341,43 @@ describe('App', () => {
)
})
it('opens workspace entries from their row actions', async () => {
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
path: '',
entries: [
{ name: 'docs', path: 'docs', type: 'directory' },
{ name: 'README.md', path: 'README.md', type: 'file' }
],
truncated: false
})
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
fireEvent.click(
await screen.findByRole('button', {
name: '在系统资源管理器中打开文件夹 docs'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '使用默认应用打开文件 README.md'
})
)
await waitFor(() =>
expect(api.workspace.openPath).toHaveBeenCalledWith(
projectId,
'docs',
'directory'
)
)
expect(api.workspace.openPath).toHaveBeenCalledWith(
projectId,
'README.md',
'file'
)
})
it('refreshes generated workspace files when a run completes', async () => {
vi.mocked(api.workspace.getChanges)
.mockResolvedValueOnce({
@@ -2399,6 +2491,87 @@ describe('App', () => {
).toBeInTheDocument()
})
it('edits and safely deletes the current project from project settings', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
render(<App />)
fireEvent.click(await screen.findByLabelText('项目设置'))
let dialog = screen.getByRole('dialog', { name: '项目设置' })
expect(within(dialog).getByLabelText('名称')).toHaveValue(
project.name
)
expect(within(dialog).getByLabelText('根目录')).toHaveValue(
project.rootPath
)
fireEvent.change(within(dialog).getByLabelText('说明'), {
target: { value: '更新后的说明' }
})
fireEvent.click(
within(dialog).getByRole('button', { name: '保存项目' })
)
await waitFor(() =>
expect(api.projects.update).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
description: '更新后的说明',
rootPath: project.rootPath
})
)
)
fireEvent.click(screen.getByLabelText('项目设置'))
dialog = screen.getByRole('dialog', { name: '项目设置' })
expect(dialog).toHaveTextContent('不会删除磁盘上的项目目录或文件')
fireEvent.click(
within(dialog).getByRole('button', { name: '删除项目' })
)
const confirmation = within(dialog).getByLabelText(
`输入“${project.name}”确认删除`
)
const deleteButton = within(dialog).getByRole('button', {
name: '永久删除项目'
})
expect(deleteButton).toBeDisabled()
fireEvent.change(confirmation, {
target: { value: project.name }
})
expect(deleteButton).toBeEnabled()
fireEvent.click(deleteButton)
await waitFor(() =>
expect(api.projects.delete).toHaveBeenCalledWith(
project.id,
project.name
)
)
expect(screen.getByLabelText('当前项目')).toHaveValue(
secondProject.id
)
})
it('uses a message icon for conversation navigation', async () => {
render(<App />)
const conversationNavigation = await screen.findByRole('button', {
name: '对话'
})
expect(
conversationNavigation.querySelector('.lucide-message-square')
).not.toBeNull()
expect(
conversationNavigation.querySelector('.lucide-history')
).toBeNull()
})
it('marks an image model and renders its generated artifact', async () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
@@ -2711,6 +2884,60 @@ describe('App', () => {
)
})
it('renders and answers an OpenCode question request', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '需要确认的任务' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'question',
questionId: 'question-1',
questions: [
{
header: '实现方式',
question: '请选择实现方式',
options: [
{
label: '直接修改',
description: '立即更新现有实现'
},
{
label: '先写测试',
description: '先增加回归测试'
}
],
multiple: false,
custom: true
}
]
})
})
expect(
await screen.findByText('OpenCode 需要补充信息')
).toBeInTheDocument()
fireEvent.click(screen.getByLabelText(//u))
fireEvent.click(screen.getByRole('button', { name: '提交回答' }))
await waitFor(() =>
expect(api.agent.respondQuestion).toHaveBeenCalledWith(
'question-1',
[['先写测试']]
)
)
expect(
screen.queryByText('OpenCode 需要补充信息')
).not.toBeInTheDocument()
})
it('configures a runtime without reading an existing API key', async () => {
render(<App />)
+409 -83
View File
@@ -11,11 +11,11 @@ import {
Edit3,
FileText,
HeartPulse,
History,
Info,
Library,
Maximize2,
MessageSquarePlus,
MessageSquare,
Mic,
MicOff,
Minimize2,
@@ -48,6 +48,7 @@ import {
import type {
ApprovalDecision,
AgentEvent,
AgentQuestionAnswer,
AgentRuntimeStatus,
AppInfo,
BrowserLiveState,
@@ -77,16 +78,20 @@ import type {
TokenUsageSummary,
ConversationSnapshot,
ConversationAttachment,
ConversationMessageBlock,
ConversationToolActivity,
ProjectCreateInput,
InteractiveWorkMode,
WorkspaceChanges
} from '../../shared/assistant-contracts'
import {
conversationAttachmentSchema,
conversationMessageBlocksSchema,
interactiveWorkModes,
normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import { AgentQuestionCard } from './AgentQuestionCard'
import {
loadActivityRecords,
reconcileActivityRecords,
@@ -268,20 +273,7 @@ function supportsSubagentSmartRouting(
return workMode === 'ask' || ['plan'].includes(workMode)
}
type ToolActivity = {
callId?: string
name: string
state:
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'recoverable'
| 'cancelled'
| 'interrupted'
summary: string
error?: string
}
type ToolActivity = ConversationToolActivity
type SubagentActivity = {
childTaskId: string
@@ -298,6 +290,7 @@ type Message = {
role: 'user' | 'assistant'
content: string
reasoning?: string
blocks?: ConversationMessageBlock[]
createdAt: number
state: 'streaming' | 'complete' | 'error'
status?: string
@@ -311,6 +304,7 @@ type Message = {
argumentSummary?: string
allowPermanent?: boolean
}
question?: Extract<AgentEvent, { type: 'question' }>
sources?: string[]
sourceReferences?: KnowledgeSearchReference[]
artifactIds?: string[]
@@ -394,6 +388,89 @@ const subagentStateLabels: Record<SubagentActivity['state'], string> = {
cancelled: '已取消'
}
const maxMessageContentLength = 1_000_000
const maxMessageBlocks = 500
function appendMessageContentBlock(
blocks: ConversationMessageBlock[] | undefined,
type: 'text' | 'reasoning',
delta: string
): ConversationMessageBlock[] | undefined {
if (!blocks || !delta) {
return blocks
}
const current = [...blocks]
const previous = current.at(-1)
if (previous?.type === type) {
previous.content = `${previous.content}${delta}`.slice(
0,
maxMessageContentLength
)
return current
}
if (current.length >= maxMessageBlocks) {
return current
}
current.push({
id: crypto.randomUUID(),
type,
content: delta.slice(0, maxMessageContentLength)
})
return current
}
function upsertMessageToolBlock(
blocks: ConversationMessageBlock[] | undefined,
tool: ToolActivity
): ConversationMessageBlock[] | undefined {
if (!blocks) {
return blocks
}
const callId = tool.callId
const index = callId
? blocks.findIndex(
(block) =>
block.type === 'tool' && block.tool.callId === callId
)
: -1
if (index >= 0) {
return blocks.map((block, blockIndex) =>
blockIndex === index && block.type === 'tool'
? { ...block, tool }
: block
)
}
if (blocks.length >= maxMessageBlocks) {
return blocks
}
return [
...blocks,
{
id: crypto.randomUUID(),
type: 'tool',
tool
}
]
}
function terminalizeMessageToolBlocks(
blocks: ConversationMessageBlock[] | undefined,
state: 'failed' | 'cancelled'
): ConversationMessageBlock[] | undefined {
return blocks?.map((block) =>
block.type === 'tool' &&
(block.tool.state === 'pending' || block.tool.state === 'running')
? {
...block,
tool: {
...block.tool,
state
}
}
: block
)
}
function createConversation(
projectId?: string,
runtimeSelection?: AgentRuntimeSelection
@@ -492,6 +569,9 @@ function isConversation(value: unknown): value is Conversation {
entry.content.length <= 1_000_000 &&
(entry.reasoning === undefined ||
typeof entry.reasoning === 'string') &&
(entry.blocks === undefined ||
conversationMessageBlocksSchema.safeParse(entry.blocks)
.success) &&
typeof entry.createdAt === 'number' &&
(entry.state === 'streaming' ||
entry.state === 'complete' ||
@@ -525,6 +605,7 @@ function toConversationSnapshots(
role: message.role,
content: message.content,
reasoning: message.reasoning,
blocks: message.blocks,
createdAt: message.createdAt,
state: message.state,
status: message.status,
@@ -1608,7 +1689,7 @@ function App(): React.JSX.Element {
? {
...task,
status:
event.type === 'approval'
event.type === 'approval' || event.type === 'question'
? 'waiting_approval'
: event.type === 'done'
? 'completed'
@@ -1673,19 +1754,46 @@ function App(): React.JSX.Element {
}
if (event.type === 'text') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
content: `${message.content}${event.delta}`.slice(0, 1_000_000),
status:
message.content.length + event.delta.length > 1_000_000
? '回答过长,已在本地截断显示'
: undefined
}))
updateMessage(run.conversationId, run.messageId, (message) => {
const remaining = Math.max(
0,
maxMessageContentLength - message.content.length
)
const acceptedDelta = event.delta.slice(0, remaining)
return {
...message,
content: `${message.content}${acceptedDelta}`,
blocks: appendMessageContentBlock(
message.blocks,
'text',
acceptedDelta
),
status:
event.delta.length > remaining
? '回答过长,已在本地截断显示'
: undefined
}
})
} else if (event.type === 'reasoning') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
reasoning: `${message.reasoning ?? ''}${event.delta}`
}))
updateMessage(run.conversationId, run.messageId, (message) => {
const currentReasoning = message.reasoning ?? ''
const acceptedDelta = event.delta.slice(
0,
Math.max(
0,
maxMessageContentLength - currentReasoning.length
)
)
return {
...message,
reasoning: `${currentReasoning}${acceptedDelta}`,
blocks: appendMessageContentBlock(
message.blocks,
'reasoning',
acceptedDelta
)
}
})
} else if (event.type === 'status') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
@@ -1728,7 +1836,11 @@ function App(): React.JSX.Element {
} else {
tools.push(tool)
}
return { ...message, tools }
return {
...message,
tools,
blocks: upsertMessageToolBlock(message.blocks, tool)
}
})
} else if (event.type === 'subagent') {
const childStatus = event.state
@@ -1831,6 +1943,12 @@ function App(): React.JSX.Element {
allowPermanent: event.allowPermanent
}
}))
} else if (event.type === 'question') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
status: undefined,
question: event
}))
} else if (event.type === 'artifact') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
@@ -1924,30 +2042,47 @@ function App(): React.JSX.Element {
: 'Agent Runtime 已完成响应',
status: terminalStatus
})
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
state: event.type === 'error' ? 'error' : 'complete',
status: event.type === 'error' ? event.message : undefined,
approval: undefined,
tools:
updateMessage(run.conversationId, run.messageId, (message) => {
const toolTerminalState =
event.type === 'error'
? event.status === 'cancelled'
? ('cancelled' as const)
: ('failed' as const)
: undefined
const fallbackError =
event.type === 'error' && !message.content
? event.message.slice(0, maxMessageContentLength)
: ''
return {
...message,
state: event.type === 'error' ? 'error' : 'complete',
status: event.type === 'error' ? event.message : undefined,
approval: undefined,
question: undefined,
tools: toolTerminalState
? message.tools?.map((tool) =>
tool.state === 'pending' || tool.state === 'running'
? {
...tool,
state:
event.status === 'cancelled'
? ('cancelled' as const)
: ('failed' as const)
}
? { ...tool, state: toolTerminalState }
: tool
)
: message.tools,
content:
event.type === 'error' && !message.content
? event.message
: message.content
}))
blocks: toolTerminalState
? terminalizeMessageToolBlocks(
appendMessageContentBlock(
message.blocks,
'text',
fallbackError
),
toolTerminalState
)
: appendMessageContentBlock(
message.blocks,
'text',
fallbackError
),
content: fallbackError || message.content
}
})
activeRuns.current.delete(event.requestId)
}
},
@@ -2088,6 +2223,22 @@ function App(): React.JSX.Element {
},
[activeProjectId]
)
const openWorkspaceEntry = useCallback(
async (
path: string,
type: 'file' | 'directory'
): Promise<void> => {
if (!activeProjectId) {
throw new Error('请先选择项目')
}
await window.goodbuddy.workspace.openPath(
activeProjectId,
path,
type
)
},
[activeProjectId]
)
useEffect(() => {
if (assistantSidebarTab !== 'changes') {
@@ -2510,6 +2661,27 @@ function App(): React.JSX.Element {
return project
}
const updateProject = async (
projectId: string,
input: ProjectCreateInput
): Promise<AssistantProject> => {
const project = await window.goodbuddy.projects.update(
projectId,
input
)
setProjects((current) =>
current.map((candidate) =>
candidate.id === project.id ? project : candidate
)
)
if (project.id === activeProjectId) {
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
}
return project
}
const archiveProject = async (projectId: string): Promise<void> => {
await window.goodbuddy.projects.setArchived(projectId, true)
const remaining = projects.filter((project) => project.id !== projectId)
@@ -2520,6 +2692,65 @@ function App(): React.JSX.Element {
}
}
const deleteProject = async (
projectId: string,
confirmation: string
): Promise<void> => {
await window.goodbuddy.projects.delete(projectId, confirmation)
const remainingProjects = projects.filter(
(project) => project.id !== projectId
)
const remainingConversations = conversations.filter(
(conversation) => conversation.projectId !== projectId
)
setProjects(remainingProjects)
setConversations(remainingConversations)
setAssistantTasks((current) =>
current.filter((task) => task.projectId !== projectId)
)
setAssistantArtifacts((current) =>
current.filter((artifact) => artifact.projectId !== projectId)
)
setAssistantMemories((current) =>
current.filter(
(memory) =>
!(
memory.scope === 'project' &&
memory.scopeId === projectId
)
)
)
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.projectId !== projectId)
)
setAssistantHeartbeats((current) =>
current.filter((heartbeat) => heartbeat.projectId !== projectId)
)
const next = remainingProjects[0]
if (next) {
setActiveProjectId(next.id)
setWorkMode(
normalizeInteractiveWorkMode(next.defaultWorkMode)
)
const nextConversation = remainingConversations.find(
(conversation) => conversation.projectId === next.id
)
if (nextConversation) {
setActiveId(nextConversation.id)
} else {
const created = createConversation(
next.id,
runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined
)
setConversations((current) => [created, ...current])
setActiveId(created.id)
}
}
setView('chat')
}
const newConversation = (): void => {
startNewConversation(activeProjectId || undefined)
}
@@ -2818,6 +3049,7 @@ function App(): React.JSX.Element {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
blocks: [],
createdAt: Date.now(),
state: 'streaming',
status: '正在连接 Agent Runtime'
@@ -2982,6 +3214,20 @@ function App(): React.JSX.Element {
}
}
const respondToQuestion = async (
conversationId: string,
messageId: string,
questionId: string,
answers?: AgentQuestionAnswer[]
): Promise<void> => {
await window.goodbuddy.agent.respondQuestion(questionId, answers)
updateMessage(conversationId, messageId, (message) => ({
...message,
question: undefined,
status: answers ? '回答已提交,OpenCode 正在继续执行' : '已跳过问题'
}))
}
const addContext = async (
action: () => Promise<ContextAttachment | ContextAttachment[]>
): Promise<void> => {
@@ -3383,10 +3629,12 @@ function App(): React.JSX.Element {
activeProjectId={activeProjectId}
onArchive={archiveProject}
onCreate={createProject}
onDelete={deleteProject}
onSelect={selectProject}
onSelectRoot={() =>
window.goodbuddy.settings.selectWorkspace()
}
onUpdate={updateProject}
projects={projects}
/>
@@ -3414,7 +3662,7 @@ function App(): React.JSX.Element {
onClick={() => setView('chat')}
type="button"
>
<History size={17} />
<MessageSquare size={17} />
<span></span>
</button>
<button
@@ -3901,30 +4149,85 @@ function App(): React.JSX.Element {
})}
</div>
)}
{message.reasoning && (
<details
className="message-reasoning"
key={`${message.id}-${message.state}`}
open={message.state === 'streaming'}
>
<summary>
{message.state === 'streaming'
? '正在推理'
: '推理过程'}
</summary>
<div className="markdown-content message-reasoning__content">
<MarkdownRenderer>
{message.reasoning}
</MarkdownRenderer>
</div>
</details>
)}
{message.content && (
<div className="markdown-content message__content">
<MarkdownRenderer>
{message.content}
</MarkdownRenderer>
{message.blocks && message.blocks.length > 0 ? (
<div className="message-blocks">
{message.blocks.map((block) =>
block.type === 'reasoning' ? (
<details
className="message-reasoning"
key={block.id}
open={
message.state === 'streaming' &&
message.blocks?.at(-1)?.id === block.id
}
>
<summary>
{message.state === 'streaming'
? '正在推理'
: '推理过程'}
</summary>
<div className="markdown-content message-reasoning__content">
<MarkdownRenderer>
{block.content}
</MarkdownRenderer>
</div>
</details>
) : block.type === 'text' ? (
<div
className="markdown-content message__content"
key={block.id}
>
<MarkdownRenderer>
{block.content}
</MarkdownRenderer>
</div>
) : (
<div
className="tool-activity"
key={block.id}
>
<TerminalSquare size={15} />
<div className="tool-activity__content">
<span>{block.tool.summary}</span>
{block.tool.error && (
<code>{block.tool.error}</code>
)}
</div>
<small>
{toolStateLabels[block.tool.state]}
</small>
</div>
)
)}
</div>
) : (
<>
{message.reasoning && (
<details
className="message-reasoning"
key={`${message.id}-${message.state}`}
open={message.state === 'streaming'}
>
<summary>
{message.state === 'streaming'
? '正在推理'
: '推理过程'}
</summary>
<div className="markdown-content message-reasoning__content">
<MarkdownRenderer>
{message.reasoning}
</MarkdownRenderer>
</div>
</details>
)}
{message.content && (
<div className="markdown-content message__content">
<MarkdownRenderer>
{message.content}
</MarkdownRenderer>
</div>
)}
</>
)}
{message.artifactIds?.map((artifactId) => {
const candidate =
@@ -4043,19 +4346,20 @@ function App(): React.JSX.Element {
</ol>
</details>
)}
{message.tools?.map((tool) => (
<div
className="tool-activity"
key={tool.callId ?? tool.name}
>
<TerminalSquare size={15} />
<div className="tool-activity__content">
<span>{tool.summary}</span>
{tool.error && <code>{tool.error}</code>}
{(!message.blocks || message.blocks.length === 0) &&
message.tools?.map((tool) => (
<div
className="tool-activity"
key={tool.callId ?? tool.name}
>
<TerminalSquare size={15} />
<div className="tool-activity__content">
<span>{tool.summary}</span>
{tool.error && <code>{tool.error}</code>}
</div>
<small>{toolStateLabels[tool.state]}</small>
</div>
<small>{toolStateLabels[tool.state]}</small>
</div>
))}
))}
{message.subagents && message.subagents.length > 0 && (
<section
aria-label="子专家状态"
@@ -4159,6 +4463,27 @@ function App(): React.JSX.Element {
)}
</div>
)}
{message.question && (
<AgentQuestionCard
key={message.question.questionId}
onReject={() =>
respondToQuestion(
activeConversation.id,
message.id,
message.question!.questionId
)
}
onSubmit={(answers) =>
respondToQuestion(
activeConversation.id,
message.id,
message.question!.questionId,
answers
)
}
value={message.question}
/>
)}
{message.status && (
<div
className={
@@ -5064,6 +5389,7 @@ function App(): React.JSX.Element {
onSetHeartbeatPaused={setHeartbeatPaused}
onListWorkspaceDirectory={listWorkspaceDirectory}
onLoadWorkspaceFile={loadWorkspaceFile}
onOpenWorkspaceEntry={openWorkspaceEntry}
onRefreshChanges={refreshWorkspaceChanges}
onRespondApproval={(approval, decision) => {
void respondToApproval(
+241 -57
View File
@@ -1,4 +1,11 @@
import { Archive, FolderOpen, Plus, X } from 'lucide-react'
import {
Archive,
FolderOpen,
Plus,
Settings,
Trash2,
X
} from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type {
AssistantProject,
@@ -6,7 +13,10 @@ import type {
ProjectCreateInput,
WorkMode
} from '../../shared/assistant-contracts'
import { interactiveWorkModes } from '../../shared/assistant-contracts'
import {
interactiveWorkModes,
normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts'
import { trapTabFocus } from './dialog-focus'
type ProjectSwitcherProps = {
@@ -14,8 +24,13 @@ type ProjectSwitcherProps = {
activeProjectId: string
onArchive: (projectId: string) => Promise<void>
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
onDelete: (projectId: string, confirmation: string) => Promise<void>
onSelect: (projectId: string) => void
onSelectRoot: () => Promise<string | undefined>
onUpdate: (
projectId: string,
input: ProjectCreateInput
) => Promise<AssistantProject>
}
export const workModeLabels: Record<InteractiveWorkMode, string> = {
@@ -28,59 +43,86 @@ export function ProjectSwitcher({
activeProjectId,
onArchive,
onCreate,
onDelete,
onSelect,
onSelectRoot
onSelectRoot,
onUpdate
}: ProjectSwitcherProps): React.JSX.Element {
const [creating, setCreating] = useState(false)
const [dialogMode, setDialogMode] = useState<
'create' | 'settings'
>()
const [saving, setSaving] = useState(false)
const [archiving, setArchiving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteConfirmation, setDeleteConfirmation] = useState('')
const [error, setError] = useState<string>()
const createButtonRef = useRef<HTMLButtonElement>(null)
const settingsButtonRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const restoreCreateButtonFocus = useRef(false)
const restoreFocusTarget = useRef<
'create' | 'settings' | undefined
>(undefined)
const [draft, setDraft] = useState<ProjectCreateInput>({
name: '',
description: '',
rootPath: '',
defaultWorkMode: 'ask'
})
const activeProject = projects.find(
(project) => project.id === activeProjectId
)
const busy = saving || archiving || deleting
useEffect(() => {
if (!creating) {
if (restoreCreateButtonFocus.current) {
if (!dialogMode) {
if (restoreFocusTarget.current === 'create') {
createButtonRef.current?.focus()
restoreCreateButtonFocus.current = false
} else if (restoreFocusTarget.current === 'settings') {
settingsButtonRef.current?.focus()
}
restoreFocusTarget.current = undefined
return
}
restoreCreateButtonFocus.current = true
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !saving && !archiving) {
if (event.key === 'Escape' && !busy) {
setError(undefined)
setCreating(false)
setConfirmingDelete(false)
setDeleteConfirmation('')
setDialogMode(undefined)
return
}
trapTabFocus(event, dialogRef.current)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [archiving, creating, saving])
}, [busy, dialogMode])
const create = async (): Promise<void> => {
const closeDialog = (): void => {
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
setDialogMode(undefined)
}
const save = async (): Promise<void> => {
setSaving(true)
setError(undefined)
try {
const project = await onCreate(draft)
onSelect(project.id)
setDraft({
name: '',
description: '',
rootPath: '',
defaultWorkMode: 'ask'
})
setCreating(false)
if (dialogMode === 'settings' && activeProject) {
await onUpdate(activeProject.id, draft)
} else {
await onCreate(draft)
}
closeDialog()
} catch (reason) {
setError(reason instanceof Error ? reason.message : '创建项目失败')
setError(
reason instanceof Error
? reason.message
: dialogMode === 'settings'
? '保存项目失败'
: '创建项目失败'
)
} finally {
setSaving(false)
}
@@ -110,7 +152,7 @@ export function ProjectSwitcher({
setError(undefined)
try {
await onArchive(activeProjectId)
setCreating(false)
closeDialog()
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '归档项目失败'
@@ -120,6 +162,24 @@ export function ProjectSwitcher({
}
}
const deleteProject = async (): Promise<void> => {
if (!activeProject) {
return
}
setDeleting(true)
setError(undefined)
try {
await onDelete(activeProject.id, deleteConfirmation)
closeDialog()
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '删除项目失败'
)
} finally {
setDeleting(false)
}
}
return (
<div className="project-switcher">
<div className="project-switcher__row">
@@ -139,45 +199,79 @@ export function ProjectSwitcher({
className="icon-button"
onClick={() => {
setError(undefined)
setCreating(true)
setConfirmingDelete(false)
setDeleteConfirmation('')
setDraft({
name: '',
description: '',
rootPath: '',
defaultWorkMode: 'ask'
})
restoreFocusTarget.current = 'create'
setDialogMode('create')
}}
ref={createButtonRef}
type="button"
>
<Plus size={15} />
</button>
<button
aria-label="项目设置"
className="icon-button"
disabled={!activeProject}
onClick={() => {
if (!activeProject) {
return
}
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
setDraft({
name: activeProject.name,
description: activeProject.description,
rootPath: activeProject.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode(
activeProject.defaultWorkMode
)
})
restoreFocusTarget.current = 'settings'
setDialogMode('settings')
}}
ref={settingsButtonRef}
type="button"
>
<Settings size={15} />
</button>
</div>
{creating && (
{dialogMode && (
<div
className="project-create-backdrop"
onMouseDown={(event) => {
if (
event.currentTarget === event.target &&
!saving &&
!archiving
) {
setError(undefined)
setCreating(false)
if (event.currentTarget === event.target && !busy) {
closeDialog()
}
}}
>
<div
aria-labelledby="project-create-title"
aria-labelledby="project-dialog-title"
aria-modal="true"
className="project-create-card"
ref={dialogRef}
role="dialog"
>
<header>
<strong id="project-create-title"></strong>
<strong id="project-dialog-title">
{dialogMode === 'create' ? '新建项目' : '项目设置'}
</strong>
<button
aria-label="关闭新建项目"
aria-label={
dialogMode === 'create'
? '关闭新建项目'
: '关闭项目设置'
}
className="icon-button"
disabled={saving || archiving}
onClick={() => {
setError(undefined)
setCreating(false)
}}
disabled={busy}
onClick={closeDialog}
type="button"
>
<X size={14} />
@@ -186,7 +280,7 @@ export function ProjectSwitcher({
<label>
<span></span>
<input
autoFocus
autoFocus={!confirmingDelete}
maxLength={120}
onChange={(event) =>
setDraft((current) => ({
@@ -218,7 +312,7 @@ export function ProjectSwitcher({
<button
aria-label="选择项目根目录"
className="secondary-button"
disabled={saving || archiving}
disabled={busy}
onClick={() => void selectRoot()}
type="button"
>
@@ -249,27 +343,117 @@ export function ProjectSwitcher({
{error}
</p>
)}
{dialogMode === 'settings' && (
<section
aria-labelledby="project-danger-title"
className="project-danger-zone"
>
<div>
<strong id="project-danger-title"></strong>
<p>
GoodBuddy
</p>
</div>
{!confirmingDelete ? (
<button
className="danger-button danger-button--quiet"
disabled={busy || projects.length <= 1}
onClick={() => {
setError(undefined)
setDeleteConfirmation('')
setConfirmingDelete(true)
}}
type="button"
>
<Trash2 size={13} />
</button>
) : (
<div className="project-delete-confirmation">
<label>
<span>
{activeProject?.name}
</span>
<input
autoFocus
disabled={busy}
onChange={(event) =>
setDeleteConfirmation(event.target.value)
}
value={deleteConfirmation}
/>
</label>
<div>
<button
className="secondary-button"
disabled={busy}
onClick={() => {
setError(undefined)
setDeleteConfirmation('')
setConfirmingDelete(false)
}}
type="button"
>
</button>
<button
className="danger-button"
disabled={
busy ||
deleteConfirmation !== activeProject?.name
}
onClick={() => void deleteProject()}
type="button"
>
<Trash2 size={13} />
{deleting ? '删除中' : '永久删除项目'}
</button>
</div>
</div>
)}
{projects.length <= 1 && (
<small></small>
)}
</section>
)}
<div className="project-create-card__actions">
{projects.length > 1 && activeProjectId && (
<button
className="secondary-button"
disabled={saving || archiving}
onClick={() => void archive()}
type="button"
>
<Archive size={13} />
{archiving ? '归档中' : '归档当前'}
</button>
)}
{dialogMode === 'settings' &&
projects.length > 1 &&
activeProjectId && (
<button
className="secondary-button"
disabled={busy}
onClick={() => void archive()}
type="button"
>
<Archive size={13} />
{archiving ? '归档中' : '归档项目'}
</button>
)}
<button
className="secondary-button"
disabled={busy}
onClick={closeDialog}
type="button"
>
</button>
<button
className="primary-button"
disabled={
saving || archiving || !draft.name.trim()
busy || !draft.name.trim() || confirmingDelete
}
onClick={() => void create()}
onClick={() => void save()}
type="button"
>
{saving ? '创建中' : '创建'}
{saving
? dialogMode === 'create'
? '创建中'
: '保存中'
: dialogMode === 'create'
? '创建'
: '保存项目'}
</button>
</div>
</div>
@@ -49,6 +49,7 @@ function renderSidebar({
}))}
onLoadArtifact={vi.fn(async () => undefined)}
onLoadWorkspaceFile={vi.fn()}
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
onOpenConversation={vi.fn()}
onOpenHeartbeat={vi.fn()}
onRefreshChanges={vi.fn(async () => undefined)}
+11 -3
View File
@@ -104,6 +104,10 @@ type RightAssistantSidebarProps = {
path: string
) => Promise<WorkspaceDirectoryListing>
onLoadWorkspaceFile: (path: string) => Promise<WorkspaceFilePreview>
onOpenWorkspaceEntry: (
path: string,
type: 'file' | 'directory'
) => Promise<void>
onRemoveMemory: (memoryId: string) => Promise<void>
onSetMemoryStatus: (
memoryId: string,
@@ -139,7 +143,7 @@ const tabs: Array<{
{
id: 'changes',
label: '工作区',
description: '浏览项目文件、Git 变更与工具活动'
description: '浏览项目文件与工具活动'
},
{
id: 'browser',
@@ -262,6 +266,7 @@ export function RightAssistantSidebar({
onRefreshChanges,
onListWorkspaceDirectory,
onLoadWorkspaceFile,
onOpenWorkspaceEntry,
onRemoveMemory,
onSetMemoryStatus,
onRespondApproval,
@@ -1079,8 +1084,8 @@ export function RightAssistantSidebar({
<>
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
Git Agent
Agent Git
</p>
<h3>
<FolderTree size={15} />
@@ -1088,6 +1093,7 @@ export function RightAssistantSidebar({
<button
aria-label="刷新工作区文件"
className="icon-button"
disabled={!workspaceProjectId}
onClick={() => {
setWorkspaceRefreshVersion((current) => current + 1)
runAction(
@@ -1095,6 +1101,7 @@ export function RightAssistantSidebar({
'刷新工作区文件失败'
)
}}
title="刷新"
type="button"
>
<RefreshCw size={14} />
@@ -1104,6 +1111,7 @@ export function RightAssistantSidebar({
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
key={`${workspaceProjectId ?? 'none'}:${workspaceRefreshVersion}`}
onListDirectory={onListWorkspaceDirectory}
onOpenEntry={onOpenWorkspaceEntry}
onOpenFile={openWorkspaceFile}
projectId={workspaceProjectId}
/>
+14 -1
View File
@@ -193,6 +193,9 @@ const capabilitySnapshot = {
}
} satisfies CapabilitySnapshot
const getCapabilitySnapshot = vi.fn(async () => capabilitySnapshot)
const importSkill = vi.fn<DesktopApi['capabilities']['importSkill']>(
async () => capabilitySnapshot
)
const saveMcpServer = vi.fn(async () => capabilitySnapshot)
const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
...capabilitySnapshot,
@@ -342,7 +345,7 @@ describe('SettingsPanel runtime files', () => {
},
capabilities: {
getSnapshot: getCapabilitySnapshot,
importSkill: vi.fn(async () => capabilitySnapshot),
importSkill,
removeSkill: vi.fn(async () => capabilitySnapshot),
setSkillEnabled,
setSkillAssignments: vi.fn(async () => capabilitySnapshot),
@@ -1654,6 +1657,16 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
expect(await screen.findByText('文档写作')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '导入 Skill 目录' })
)
await waitFor(() =>
expect(importSkill).toHaveBeenCalledWith('directory')
)
fireEvent.click(
screen.getByRole('button', { name: '导入 Skill ZIP' })
)
await waitFor(() => expect(importSkill).toHaveBeenCalledWith('zip'))
fireEvent.click(screen.getByLabelText('启用 文档写作'))
await waitFor(() =>
expect(setSkillEnabled).toHaveBeenCalledWith(
+6 -4
View File
@@ -1194,15 +1194,17 @@ export function SettingsPanel({
<div className="settings-section__title">
<FolderOpen size={17} />
<div>
<strong></strong>
<small>Agent </small>
<strong></strong>
<small>
Agent 使
</small>
</div>
</div>
<label className="field">
<span></span>
<span></span>
<div className="workspace-picker">
<input
aria-label="工作区目录"
aria-label="默认工作区目录"
onChange={(event) => setWorkspacePath(event.target.value)}
value={workspacePath}
/>
+15 -2
View File
@@ -68,13 +68,26 @@ export function SkillsSettingsSection(): React.JSX.Element {
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill()
window.goodbuddy.capabilities.importSkill('directory')
)
}
type="button"
>
<Download size={14} />
SKILL.md
Skill
</button>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('zip')
)
}
type="button"
>
<Download size={14} />
Skill ZIP
</button>
</div>
+18 -2
View File
@@ -37,25 +37,39 @@ describe('WorkspaceFilesPanel', () => {
}
)
const onOpenFile = vi.fn()
const onOpenEntry = vi.fn(async () => undefined)
render(
<WorkspaceFilesPanel
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
onListDirectory={onListDirectory}
onOpenEntry={onOpenEntry}
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: 'docs' }))
fireEvent.click(
await screen.findByRole('button', { name: /guide\.md/u })
await screen.findByRole('button', { name: 'guide.md' })
)
expect(onListDirectory).toHaveBeenCalledWith('')
expect(onListDirectory).toHaveBeenCalledWith('docs')
expect(onOpenFile).toHaveBeenCalledWith('docs/guide.md')
fireEvent.click(
screen.getByRole('button', {
name: '使用默认应用打开文件 guide.md'
})
)
expect(onOpenEntry).toHaveBeenCalledWith('docs/guide.md', 'file')
fireEvent.click(
screen.getByRole('button', {
name: '在系统资源管理器中打开文件夹 docs'
})
)
expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory')
expect(screen.getAllByText('修改')).not.toHaveLength(0)
})
@@ -84,6 +98,7 @@ describe('WorkspaceFilesPanel', () => {
<WorkspaceFilesPanel
changedFiles={[]}
onListDirectory={onListDirectory}
onOpenEntry={vi.fn(async () => undefined)}
onOpenFile={vi.fn()}
projectId="00000000-0000-4000-8000-000000000101"
/>
@@ -94,6 +109,7 @@ describe('WorkspaceFilesPanel', () => {
<WorkspaceFilesPanel
changedFiles={[]}
onListDirectory={onListDirectory}
onOpenEntry={vi.fn(async () => undefined)}
onOpenFile={vi.fn()}
projectId="00000000-0000-4000-8000-000000000102"
/>
+73 -31
View File
@@ -1,6 +1,7 @@
import {
ChevronDown,
ChevronRight,
FileSearch,
FileText,
Folder,
FolderOpen
@@ -23,6 +24,10 @@ type WorkspaceFilesPanelProps = {
changedFiles: WorkspaceChangedFile[]
onListDirectory: (path: string) => Promise<WorkspaceDirectoryListing>
onOpenFile: (path: string) => void
onOpenEntry: (
path: string,
type: WorkspaceDirectoryEntry['type']
) => Promise<void>
}
function statusLabel(status: string): string {
@@ -46,7 +51,8 @@ export function WorkspaceFilesPanel({
projectId,
changedFiles,
onListDirectory,
onOpenFile
onOpenFile,
onOpenEntry
}: WorkspaceFilesPanelProps): React.JSX.Element {
const [listingState, setListingState] = useState<{
projectId?: string
@@ -174,6 +180,21 @@ export function WorkspaceFilesPanel({
}
}
const openEntry = (entry: WorkspaceDirectoryEntry): void => {
setErrorState({ projectId })
void onOpenEntry(entry.path, entry.type).catch((reason: unknown) => {
setErrorState({
projectId,
value:
reason instanceof Error
? reason.message
: entry.type === 'directory'
? '打开文件夹失败'
: '打开文件失败'
})
})
}
const renderEntry = (
entry: WorkspaceDirectoryEntry
): React.JSX.Element => {
@@ -183,20 +204,31 @@ export function WorkspaceFilesPanel({
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>
<div className="workspace-files__entry">
<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>
<button
aria-label={`在系统资源管理器中打开文件夹 ${entry.name}`}
className="workspace-files__open-entry"
onClick={() => openEntry(entry)}
title="打开文件夹"
type="button"
>
<FolderOpen size={14} />
</button>
</div>
{expanded && (
<div className="workspace-files__children">
{listing?.entries.map((child) =>
@@ -216,22 +248,32 @@ export function WorkspaceFilesPanel({
)
}
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>
<div className="workspace-files__entry" key={entry.path}>
<button
className="workspace-files__row"
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>
<button
aria-label={`使用默认应用打开文件 ${entry.name}`}
className="workspace-files__open-entry"
onClick={() => openEntry(entry)}
title="打开文件"
type="button"
>
<FileSearch size={14} />
</button>
</div>
)
}
+197 -1
View File
@@ -171,7 +171,7 @@ textarea:focus-visible {
display: grid;
align-items: center;
gap: 5px;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
}
.project-switcher select {
@@ -272,6 +272,56 @@ textarea:focus-visible {
font-size: 9px;
}
.project-danger-zone {
display: flex;
padding: var(--space-3);
border: 1px solid var(--danger-border);
border-radius: var(--radius-control);
background: var(--danger-subtle);
flex-direction: column;
gap: var(--space-3);
}
.project-danger-zone > div:first-child {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.project-danger-zone strong {
color: var(--danger);
font-size: var(--font-body);
}
.project-danger-zone p,
.project-danger-zone small {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.5;
}
.project-danger-zone > .danger-button {
align-self: flex-start;
}
.project-delete-confirmation {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.project-delete-confirmation > div {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.project-delete-confirmation .danger-button,
.project-delete-confirmation .secondary-button {
align-self: auto;
}
.new-chat {
display: flex;
min-width: 248px;
@@ -798,9 +848,49 @@ textarea:focus-visible {
.workspace-files__row {
padding: var(--space-2);
padding-right: 38px;
grid-template-columns: auto auto minmax(0, 1fr) auto;
}
.workspace-files__entry {
position: relative;
}
.workspace-files__open-entry {
position: absolute;
top: 50%;
right: var(--space-1);
display: grid;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-secondary);
cursor: pointer;
opacity: 0;
pointer-events: none;
transform: translateY(-50%);
transition:
opacity var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
color var(--motion-fast) ease-out;
}
.workspace-files__entry:hover .workspace-files__open-entry,
.workspace-files__entry:focus-within .workspace-files__open-entry {
opacity: 1;
pointer-events: auto;
}
.workspace-files__open-entry:hover {
background: var(--accent-selected);
color: var(--accent);
}
.workspace-files__changed-row:hover:not(:disabled),
.workspace-files__row:hover {
background: var(--accent-subtle);
@@ -1853,6 +1943,16 @@ textarea:focus-visible {
border-bottom: 1px solid var(--border-subtle);
}
.message-blocks {
display: grid;
gap: var(--space-3);
}
.message-blocks .message-reasoning,
.message-blocks .tool-activity {
margin: 0;
}
.markdown-content > :first-child {
margin-top: 0;
}
@@ -2152,6 +2252,102 @@ textarea:focus-visible {
color: #fff;
}
.agent-question-card {
display: flex;
padding: var(--space-4);
border: 1px solid var(--border-control);
border-radius: var(--radius-card);
margin-top: var(--space-3);
background: var(--surface-raised);
color: var(--text-primary);
flex-direction: column;
gap: var(--space-3);
}
.agent-question-card > header,
.agent-question-card > footer {
display: flex;
align-items: center;
gap: var(--space-2);
}
.agent-question-card > header {
color: var(--accent);
}
.agent-question-card > footer {
justify-content: flex-end;
}
.agent-question-card fieldset {
display: flex;
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
margin: 0;
flex-direction: column;
gap: var(--space-2);
}
.agent-question-card legend {
padding: 0 var(--space-1);
color: var(--text-primary);
font-size: var(--font-body);
line-height: 1.5;
}
.agent-question-card legend span {
display: block;
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 650;
}
.agent-question-card fieldset > label {
display: flex;
align-items: flex-start;
color: var(--text-secondary);
cursor: pointer;
gap: var(--space-2);
}
.agent-question-card fieldset > label > span {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.agent-question-card fieldset > label strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.agent-question-card fieldset > label small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.agent-question-card__custom {
flex-direction: column;
}
.agent-question-card__custom input {
width: 100%;
min-height: 34px;
padding: 0 var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
}
.agent-question-card__error {
margin: 0;
color: var(--danger);
font-size: var(--font-caption);
}
.composer-wrap {
padding:
8px