fix: complete runtime and workspace workflows
Restore Ask workspace reads and output paging without granting write access, and let programming Subagents reuse their parent's browser page. Allocate browser resources only when used, keep Agent pages aligned with workbar tabs, recover released bindings, and remove unused automatic screenshot work. Preserve inherited Runtime selections during remote usage persistence and manual compression. Keep viable channel model fallbacks, display the actual scheduled-task mode and Harness model source, and avoid rediscovery delays when calling healthy MCP tools. Fix directory/history refresh, case-only renames, rename-aware commit diffs, and FastGPT probe success reporting. Add regression coverage and align feature documentation. The Agent-side connection timeout fix requires deploying an updated Agent; existing Ask read-only and Execute authorization boundaries remain unchanged. Final validation: 3839 tests passed, 66 skipped; typecheck, lint, development build, and diff checks passed. Isolated Electron UI/MCP workflows passed with a loopback model fixture. Current-source Linux x64 Host checks passed with three successful real model requests. Chromium loadURL rejection was not reproduced in the UI; released-binding recovery has automated coverage. Release note: 修复 Ask 读取项目文件、浏览器页面显示与恢复、文件管理及模型配置跟随中的不一致,并减少无用截图和 MCP 等待。定时任务显示实际工作模式,远程会话可正常保存继承 Runtime 的用量;远端连接超时修复需更新 Agent 后生效。
This commit is contained in:
@@ -125,6 +125,7 @@ import { UiLocaleProvider } from "./i18n/UiLocaleProvider";
|
||||
|
||||
let agentListener: ((event: AgentEvent) => void) | undefined;
|
||||
let browserListener: ((state: BrowserLiveState) => void) | undefined;
|
||||
const browserListeners = new Set<(state: BrowserLiveState) => void>();
|
||||
let fileSelectionProgressListener:
|
||||
Parameters<DesktopApi["context"]["onFileSelectionProgress"]>[0] | undefined;
|
||||
let newConversationListener: (() => void) | undefined;
|
||||
@@ -269,9 +270,12 @@ const api: DesktopApi = {
|
||||
setViewport: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
onState: vi.fn((listener) => {
|
||||
browserListener = listener;
|
||||
browserListeners.add(listener);
|
||||
browserListener = (state) => {
|
||||
for (const notify of browserListeners) notify(state);
|
||||
};
|
||||
return () => {
|
||||
browserListener = undefined;
|
||||
browserListeners.delete(listener);
|
||||
};
|
||||
}),
|
||||
},
|
||||
@@ -1161,6 +1165,7 @@ describe("App", () => {
|
||||
newConversationListener = undefined;
|
||||
beforeQuitListener = undefined;
|
||||
browserListener = undefined;
|
||||
browserListeners.clear();
|
||||
fileSelectionProgressListener = undefined;
|
||||
maximizedChangedListener = undefined;
|
||||
speechRecognitionMocks.startPcmRecording.mockResolvedValue({
|
||||
@@ -1690,7 +1695,7 @@ describe("App", () => {
|
||||
const taskRegion = await screen.findByRole("region", {
|
||||
name: "当前会话的任务",
|
||||
});
|
||||
expect(within(taskRegion).getByText("Execute")).toBeInTheDocument();
|
||||
expect(within(taskRegion).getByText("Ask")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes scheduled Task state on queue-only events and guards manual runs through completion", async () => {
|
||||
@@ -10564,7 +10569,6 @@ describe("App", () => {
|
||||
createBrowserState(conversationId ?? "", {
|
||||
canGoBack: false,
|
||||
url: "https://example.com/",
|
||||
frameDataUrl: "data:image/jpeg;base64,/9j/2Q==",
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6032,17 +6032,11 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
return next;
|
||||
}
|
||||
const previous = conversationStates[tabId];
|
||||
return {
|
||||
...current,
|
||||
[state.conversationId]: {
|
||||
...conversationStates,
|
||||
[tabId]: state.frameDataUrl || !previous?.frameDataUrl
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
frameDataUrl: previous.frameDataUrl,
|
||||
},
|
||||
[tabId]: state,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -8989,6 +8983,14 @@ function App(): React.JSX.Element {
|
||||
taskStrip={
|
||||
!conversation.remote ? (
|
||||
<ConversationTaskStrip
|
||||
conversationMode={
|
||||
conversation.id === activeId
|
||||
? effectiveWorkMode
|
||||
: normalizeInteractiveWorkMode(
|
||||
conversation.workMode ??
|
||||
projects.find((project) => project.id === conversation.projectId)?.defaultWorkMode
|
||||
)
|
||||
}
|
||||
locale={locale}
|
||||
onRemoveSchedule={removeAssistantSchedule}
|
||||
onRunSchedule={runAssistantSchedule}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantSchedule, AssistantTask } from '../../shared/assistant-contracts'
|
||||
import { ConversationTaskStrip } from './ConversationTaskStrip'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -23,6 +24,7 @@ describe('ConversationTaskStrip', () => {
|
||||
|
||||
render(
|
||||
<ConversationTaskStrip
|
||||
conversationMode="execute"
|
||||
locale="zh-CN"
|
||||
onRemoveSchedule={onRemoveSchedule}
|
||||
onRunSchedule={onRunSchedule}
|
||||
@@ -107,4 +109,87 @@ describe('ConversationTaskStrip', () => {
|
||||
screen.queryByRole('region', { name: '当前会话的任务' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['running', 'waiting_approval', 'completed', 'failed', 'interrupted'] as const)(
|
||||
'shows the actual %s Task mode, not the schedule or current conversation mode',
|
||||
(status) => {
|
||||
const { task, schedule, props } = modeFixtures()
|
||||
render(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="execute"
|
||||
schedules={[schedule]}
|
||||
tasks={[{ ...task, status, workMode: 'ask' }]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Ask')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('tracks current conversation mode for an unrun schedule instead of its legacy mode', () => {
|
||||
const { task, schedule, props } = modeFixtures()
|
||||
const { rerender } = render(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="ask"
|
||||
schedules={[schedule]}
|
||||
tasks={[task]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Ask')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
|
||||
rerender(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="execute"
|
||||
schedules={[{ ...schedule, workMode: 'ask' }]}
|
||||
tasks={[task]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Execute')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not invent a mode when the conversation or historical Task mode is unavailable', () => {
|
||||
const { task, schedule, props } = modeFixtures()
|
||||
const { rerender } = render(
|
||||
<ConversationTaskStrip {...props} schedules={[schedule]} tasks={[task]} />
|
||||
)
|
||||
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Ask')).not.toBeInTheDocument()
|
||||
rerender(
|
||||
<ConversationTaskStrip
|
||||
{...props}
|
||||
conversationMode="execute"
|
||||
schedules={[schedule]}
|
||||
tasks={[{ ...task, status: 'completed', workMode: undefined }]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Ask')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
function modeFixtures() {
|
||||
const task: AssistantTask = {
|
||||
id: 'task', conversationId: 'conversation', scheduleId: 'schedule',
|
||||
title: '模式回归', instructions: '', origin: 'schedule', status: 'idle',
|
||||
workMode: 'execute', createdAt: '2026-08-19T00:00:00.000Z'
|
||||
}
|
||||
const schedule: AssistantSchedule = {
|
||||
id: 'schedule', taskId: task.id, conversationId: 'conversation',
|
||||
title: task.title, prompt: '', workMode: 'execute', recurrence: 'daily',
|
||||
nextRunAt: '2026-08-20T09:00:00.000Z', enabled: true,
|
||||
createdAt: task.createdAt, updatedAt: task.createdAt
|
||||
}
|
||||
return {
|
||||
task, schedule,
|
||||
props: {
|
||||
locale: 'zh-CN', selectedTaskId: task.id,
|
||||
onRemoveSchedule: vi.fn(async () => undefined),
|
||||
onRunSchedule: vi.fn(async () => undefined),
|
||||
onSelectTask: vi.fn(),
|
||||
onSetScheduleEnabled: vi.fn(async () => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AssistantSchedule,
|
||||
AssistantTask
|
||||
AssistantTask,
|
||||
WorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
findTaskSchedule,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from './TaskScheduleActions'
|
||||
|
||||
type ConversationTaskStripProps = {
|
||||
conversationMode?: WorkMode
|
||||
locale: string
|
||||
onRemoveSchedule: (scheduleId: string) => Promise<void>
|
||||
onRunSchedule: (scheduleId: string) => Promise<void>
|
||||
@@ -30,6 +32,7 @@ type ConversationTaskStripProps = {
|
||||
}
|
||||
|
||||
export function ConversationTaskStrip({
|
||||
conversationMode,
|
||||
locale,
|
||||
onRemoveSchedule,
|
||||
onRunSchedule,
|
||||
@@ -51,6 +54,17 @@ export function ConversationTaskStrip({
|
||||
const selectedSchedule = selectedTask
|
||||
? findTaskSchedule(selectedTask, schedules)
|
||||
: undefined
|
||||
const unrunSchedule =
|
||||
selectedSchedule &&
|
||||
!selectedSchedule.lastRunAt &&
|
||||
!selectedTask?.startedAt &&
|
||||
!selectedTask?.completedAt &&
|
||||
(selectedTask?.status === 'idle' ||
|
||||
selectedTask?.status === 'queued' ||
|
||||
selectedTask?.status === 'paused')
|
||||
const selectedMode = unrunSchedule
|
||||
? conversationMode
|
||||
: selectedTask?.workMode
|
||||
const expanded =
|
||||
manuallyExpanded ||
|
||||
(Boolean(selectedTaskId) && collapsedTaskId !== selectedTaskId)
|
||||
@@ -136,11 +150,9 @@ export function ConversationTaskStrip({
|
||||
<div>
|
||||
<dt>{t('task.fields.mode')}</dt>
|
||||
<dd>
|
||||
{selectedSchedule
|
||||
? t(`task.mode.${selectedSchedule.workMode}`)
|
||||
: selectedTask.workMode
|
||||
? t(`task.mode.${selectedTask.workMode}`)
|
||||
: t('task.mode.unavailable')}
|
||||
{selectedMode
|
||||
? t(`task.mode.${selectedMode}`)
|
||||
: t('task.mode.unavailable')}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -529,7 +529,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
const close = screen.getByRole('button', {
|
||||
name: '关闭结果'
|
||||
})
|
||||
expect(close).toHaveFocus()
|
||||
await waitFor(() => expect(close).toHaveFocus())
|
||||
const backgroundSection = trigger.closest<HTMLElement>(
|
||||
'.settings-section'
|
||||
)
|
||||
@@ -543,7 +543,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(trigger).toHaveFocus()
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(backgroundSection?.inert).toBe(false)
|
||||
expect(test).toHaveBeenCalledWith('chat-attachment')
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -7,6 +8,9 @@ import {
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
import { workbarLayoutPreferencesSchema } from '../../shared/workbar-contracts'
|
||||
import { DEFAULT_WORKBAR_INSTANCES } from './WorkbarShell'
|
||||
import type {
|
||||
AssistantProject,
|
||||
AssistantTask,
|
||||
@@ -26,18 +30,39 @@ import {
|
||||
type SidebarArtifact
|
||||
} from './RightAssistantSidebar'
|
||||
|
||||
vi.mock('./TerminalPanel', () => ({
|
||||
TerminalPanel: ({ onSessionChange }: {
|
||||
onSessionChange?: (snapshot: TerminalSnapshot) => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSessionChange?.({
|
||||
sessionId: '00000000-0000-4000-8000-000000000498',
|
||||
state: 'running'
|
||||
} as TerminalSnapshot)}
|
||||
type="button"
|
||||
>
|
||||
Start test terminal
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const firstBrowserTabId =
|
||||
'00000000-0000-4000-8000-000000000401' as BrowserTabId
|
||||
const secondBrowserTabId =
|
||||
'00000000-0000-4000-8000-000000000402' as BrowserTabId
|
||||
const browserStateListeners = new Set<(state: BrowserLiveState) => void>()
|
||||
const browserApi = {
|
||||
createTab: vi.fn<(request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>>(),
|
||||
closeTab: vi.fn(async () => undefined),
|
||||
setViewport: vi.fn<
|
||||
(request: BrowserSetViewportRequest) => Promise<void>
|
||||
>(async () => undefined)
|
||||
>(async () => undefined),
|
||||
onState: (listener: (state: BrowserLiveState) => void) => {
|
||||
browserStateListeners.add(listener)
|
||||
return () => { browserStateListeners.delete(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
function browserSummary(
|
||||
@@ -59,6 +84,7 @@ function browserSummary(
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
browserStateListeners.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1400
|
||||
@@ -115,7 +141,8 @@ function sidebarElement({
|
||||
onBackBrowser,
|
||||
onNavigateBrowser,
|
||||
onReloadBrowser,
|
||||
onStopLoadingBrowser
|
||||
onStopLoadingBrowser,
|
||||
onTabChange = vi.fn()
|
||||
}: {
|
||||
tab?: AssistantSidebarTab
|
||||
approvals?: PendingSidebarApproval[]
|
||||
@@ -146,6 +173,7 @@ function sidebarElement({
|
||||
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
|
||||
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onTabChange?: (tab: AssistantSidebarTab) => void
|
||||
} = {}): React.JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
@@ -177,7 +205,7 @@ function sidebarElement({
|
||||
onSetScheduleEnabled={vi.fn(async () => undefined)}
|
||||
onStopLoadingBrowser={onStopLoadingBrowser}
|
||||
onOpenTask={vi.fn()}
|
||||
onTabChange={vi.fn()}
|
||||
onTabChange={onTabChange}
|
||||
open
|
||||
restoreFocusRef={restoreFocusRef}
|
||||
tab={tab}
|
||||
@@ -1177,4 +1205,166 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rebinds a released tab before retrying navigation and can close a released instance', async () => {
|
||||
const conversationId = 'retry-browser'
|
||||
const onNavigateBrowser = vi.fn(async () => undefined)
|
||||
renderSidebar({ tab: 'browser', activeConversationId: conversationId, onNavigateBrowser })
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||
const owner = browserApi.createTab.mock.calls[0]![0].workbarInstanceId
|
||||
const stopped: BrowserLiveState = {
|
||||
...browserSummary(conversationId, firstBrowserTabId, true),
|
||||
status: 'stopped',
|
||||
sessionActive: false
|
||||
}
|
||||
act(() => {
|
||||
for (const listener of browserStateListeners) listener(stopped)
|
||||
})
|
||||
browserApi.createTab.mockResolvedValueOnce(browserSummary(conversationId, secondBrowserTabId, true))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
|
||||
target: { value: 'https://retry.example/' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
||||
await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith(
|
||||
conversationId, secondBrowserTabId, 'https://retry.example/'
|
||||
))
|
||||
expect(browserApi.createTab).toHaveBeenLastCalledWith({ conversationId, workbarInstanceId: owner })
|
||||
act(() => {
|
||||
for (const listener of browserStateListeners) listener({ ...stopped, tabId: secondBrowserTabId })
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · retry-browser' }))
|
||||
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · retry-browser' })).not.toBeInTheDocument())
|
||||
expect(browserApi.closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the request-created Main tab without creating a second blank page', async () => {
|
||||
const conversationId = 'request-browser'
|
||||
const workbarInstanceId = '00000000-0000-4000-8000-000000000499'
|
||||
const onNavigateBrowser = vi.fn(async () => undefined)
|
||||
renderSidebar({
|
||||
tab: 'browser',
|
||||
activeConversationId: conversationId,
|
||||
onNavigateBrowser,
|
||||
browserStates: {
|
||||
[conversationId]: {
|
||||
[firstBrowserTabId]: {
|
||||
...browserSummary(conversationId, firstBrowserTabId, true),
|
||||
workbarInstanceId,
|
||||
sessionActive: true,
|
||||
url: 'https://agent.example/'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://agent.example/'))
|
||||
expect(browserApi.createTab).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
||||
await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith(
|
||||
conversationId, firstBrowserTabId, 'https://agent.example/'
|
||||
))
|
||||
expect(browserApi.createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains a request page at layout capacity and binds it after a slot is closed', async () => {
|
||||
const conversationId = 'full-workbar'
|
||||
const instances = [
|
||||
...DEFAULT_WORKBAR_INSTANCES.filter((instance) => instance.appId === 'tasks' || instance.appId === 'workspace'),
|
||||
...Array.from({ length: 30 }, (_, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
appId: 'browser' as const,
|
||||
title: `parked-${index}`,
|
||||
targetRef: { type: 'conversation' as const, conversationId }
|
||||
}))
|
||||
]
|
||||
localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({
|
||||
instances, activeInstanceId: instances[0]!.id,
|
||||
expanded: true, dock: 'right', widthRatio: 0.3,
|
||||
taskScope: 'current-project'
|
||||
}))
|
||||
const workbarInstanceId = '00000000-0000-4000-8000-000000000497'
|
||||
renderSidebar({
|
||||
tab: 'browser', activeConversationId: conversationId,
|
||||
browserStates: {
|
||||
[conversationId]: {
|
||||
[firstBrowserTabId]: {
|
||||
...browserSummary(conversationId, firstBrowserTabId, true),
|
||||
workbarInstanceId, sessionActive: true, url: 'https://retained.example/'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('请关闭一个可关闭页签')
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(32)
|
||||
expect(browserApi.createTab).not.toHaveBeenCalled()
|
||||
expect(workbarLayoutPreferencesSchema.safeParse(
|
||||
JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!)
|
||||
).success).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭parked-0' }))
|
||||
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://retained.example/'))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(32)
|
||||
expect(browserApi.createTab).not.toHaveBeenCalled()
|
||||
const restored = workbarLayoutPreferencesSchema.parse(
|
||||
JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!)
|
||||
)
|
||||
expect(restored.instances.some((instance) => instance.id === workbarInstanceId)).toBe(true)
|
||||
expect(restored.activeInstanceId).toBe(workbarInstanceId)
|
||||
})
|
||||
|
||||
it('preserves an explicit cross-conversation tab selection when the external app follows it', async () => {
|
||||
const onTabChange = vi.fn()
|
||||
const view = render(sidebarElement({
|
||||
tab: 'browser', activeConversationId: 'conversation-a', onTabChange
|
||||
}))
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||
view.rerender(sidebarElement({
|
||||
tab: 'browser', activeConversationId: 'conversation-b', onTabChange
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
|
||||
fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!)
|
||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
|
||||
view.rerender(sidebarElement({
|
||||
tab: 'tasks', activeConversationId: 'conversation-b', onTabChange
|
||||
}))
|
||||
const browserA = screen.getByRole('tab', { name: '浏览器 · conversation-a' })
|
||||
fireEvent.click(browserA)
|
||||
expect(onTabChange).toHaveBeenLastCalledWith('browser')
|
||||
view.rerender(sidebarElement({
|
||||
tab: 'browser', activeConversationId: 'conversation-b', onTabChange
|
||||
}))
|
||||
expect(browserA).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('releases the native browser viewport while confirming another terminal close', async () => {
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
|
||||
width: 400, x: 900, y: 100, toJSON: () => ({})
|
||||
})
|
||||
try {
|
||||
renderSidebar({ tab: 'browser', activeConversationId: 'modal-browser' })
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
|
||||
fireEvent.click(screen.getByText('终端', { selector: 'strong' }).closest('button')!)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Start test terminal' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · modal-browser' }))
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
const activeViewport = browserApi.setViewport.mock.calls.at(-1)![0]
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭终端 1' }))
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({
|
||||
leaseToken: activeViewport.leaseToken
|
||||
}))
|
||||
const dialog = screen.getByRole('alertdialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /取消/u }))
|
||||
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
|
||||
))
|
||||
} finally {
|
||||
bounds.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1021,6 +1021,7 @@ export function RightAssistantSidebar({
|
||||
if (instance.appId === 'browser') {
|
||||
browserMruRef.current[instance.id] = ++nextBrowserMruRef.current
|
||||
}
|
||||
lastExternalTabRef.current = instance.appId
|
||||
onTabChange(instance.appId)
|
||||
}
|
||||
},
|
||||
@@ -1160,6 +1161,71 @@ export function RightAssistantSidebar({
|
||||
[activeConversationId, conversationTitles, t]
|
||||
)
|
||||
|
||||
useEffect(() => window.goodbuddy.browser?.onState?.((state) => {
|
||||
if (state.status !== 'stopped') return
|
||||
const next = { ...browserTabIdsRef.current }
|
||||
let changed = false
|
||||
for (const [instanceId, tabId] of Object.entries(next)) {
|
||||
if (tabId === state.tabId) {
|
||||
delete next[instanceId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
browserTabIdsRef.current = next
|
||||
setBrowserTabIds(next)
|
||||
}
|
||||
}), [])
|
||||
|
||||
useEffect(() => {
|
||||
const liveTabs = Object.values(browserStates ?? {}).flatMap((states) =>
|
||||
Object.values(states).filter((state) =>
|
||||
state.workbarInstanceId &&
|
||||
(state.sessionActive || state.status === 'creating')
|
||||
)
|
||||
)
|
||||
const nextBindings = { ...browserTabIdsRef.current }
|
||||
let changed = false
|
||||
const newInstances: WorkbarTabInstance[] = []
|
||||
for (const state of liveTabs) {
|
||||
const instanceId = state.workbarInstanceId!
|
||||
if (nextBindings[instanceId] !== state.tabId) {
|
||||
nextBindings[instanceId] = state.tabId
|
||||
changed = true
|
||||
}
|
||||
if (!workbarInstances.some((instance) => instance.id === instanceId)) {
|
||||
newInstances.push({
|
||||
id: instanceId,
|
||||
appId: 'browser',
|
||||
title: `${t('sidebar.tabs.browser.label')} · ${conversationTitles.get(state.conversationId) ?? state.conversationId}`,
|
||||
targetRef: { type: 'conversation', conversationId: state.conversationId }
|
||||
})
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
browserTabIdsRef.current = nextBindings
|
||||
setBrowserTabIds(nextBindings)
|
||||
}
|
||||
const additions = newInstances.slice(
|
||||
0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - workbarInstances.length)
|
||||
)
|
||||
if (additions.length > 0) {
|
||||
setWorkbarInstances((current) => [
|
||||
...current,
|
||||
...additions
|
||||
.filter((candidate) => !current.some((instance) => instance.id === candidate.id))
|
||||
.slice(0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - current.length))
|
||||
])
|
||||
const requested = additions.find((instance) =>
|
||||
instance.targetRef?.type === 'conversation' &&
|
||||
instance.targetRef.conversationId === activeConversationId
|
||||
)
|
||||
if (requested && tab === 'browser') {
|
||||
setActiveWorkbarInstanceId(requested.id)
|
||||
}
|
||||
}
|
||||
}, [activeConversationId, browserStates, conversationTitles, tab, t, workbarInstances])
|
||||
|
||||
const removeWorkbarInstance = useCallback(
|
||||
(instanceId: string): void => {
|
||||
setWorkbarInstances((current) =>
|
||||
@@ -1567,10 +1633,20 @@ export function RightAssistantSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
const hasUnboundRequestBrowser = Object.values(
|
||||
browserStates?.[activeConversationId ?? ''] ?? {}
|
||||
).some((state) =>
|
||||
state.workbarInstanceId &&
|
||||
(state.sessionActive || state.status === 'creating') &&
|
||||
!workbarInstances.some((instance) => instance.id === state.workbarInstanceId)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeWorkbarInstance?.appId !== 'browser') {
|
||||
return
|
||||
}
|
||||
// Bind a newly materialized request tab before opening a fallback instance.
|
||||
if (hasUnboundRequestBrowser) return
|
||||
void ensureBrowserTab(activeWorkbarInstance).catch((reason: unknown) => {
|
||||
setActionError(
|
||||
reason instanceof Error
|
||||
@@ -1578,7 +1654,7 @@ export function RightAssistantSidebar({
|
||||
: t('sidebar.errors.browserControlUnavailable')
|
||||
)
|
||||
})
|
||||
}, [activeWorkbarInstance, ensureBrowserTab, open, t])
|
||||
}, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, t])
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -1676,6 +1752,12 @@ export function RightAssistantSidebar({
|
||||
<div
|
||||
className={`assistant-sidebar__body${instance.appId === 'browser' ? ' assistant-sidebar__body--browser' : ''}`}
|
||||
>
|
||||
{hasUnboundRequestBrowser &&
|
||||
workbarInstances.length >= WORKBAR_LIMITS.maximumOpenInstances ? (
|
||||
<p className="settings-error" role="alert">
|
||||
{t('sidebar.browser.workbarFull', { count: WORKBAR_LIMITS.maximumOpenInstances })}
|
||||
</p>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<p className="settings-error" role="alert">
|
||||
{actionError}
|
||||
@@ -2296,7 +2378,7 @@ export function RightAssistantSidebar({
|
||||
: undefined
|
||||
}
|
||||
tabId={browserTabIds[instance.id]}
|
||||
visible={open && instance.id === activeWorkbarInstanceId}
|
||||
visible={open && instance.id === activeWorkbarInstanceId && !terminalCloseConfirmation}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -150,6 +150,7 @@ const runtimeSettings: RuntimeSettings = {
|
||||
profileId: modelProfileId
|
||||
},
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
deepseekHarnessPlatformModel: { source: 'unavailable' },
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: 'always'
|
||||
}
|
||||
@@ -3085,7 +3086,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an environment-managed source compatible without exposing it as an option', async () => {
|
||||
it.each(['environment', 'profile'] as const)(
|
||||
'uses the Main-projected Harness %s source instead of guessing from environment credentials',
|
||||
async (sourceKind) => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
modelBaseUrl: 'https://gateway.example/openai/v1',
|
||||
@@ -3101,6 +3104,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
}
|
||||
],
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
deepseekHarnessPlatformModel: sourceKind === 'environment'
|
||||
? { source: 'environment', name: '管理员预置模型', modelName: 'actual-administrator-model' }
|
||||
: { source: 'profile', profileId: modelProfileId, name: '默认模型', modelName: 'qwen-plus' },
|
||||
configured: {
|
||||
modelProfiles: [
|
||||
{
|
||||
@@ -3138,15 +3144,26 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
)
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'自动选择:优先使用管理员预置连接,否则跟随当前兼容模型'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
if (sourceKind === 'environment') {
|
||||
expect(screen.getByText('此选项实际使用管理员预置:actual-administrator-model。')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/已保存配置中的实际回退/)).not.toBeInTheDocument()
|
||||
} else {
|
||||
expect(screen.getByText(/未配置完整的管理员预置;已保存配置中的实际回退:默认模型 · qwen-plus。/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/此选项实际使用管理员预置/)).not.toBeInTheDocument()
|
||||
}
|
||||
const source = screen.getByLabelText(
|
||||
'DeepSeek Harness OpenAI 兼容模型连接'
|
||||
)
|
||||
expect(source).toHaveValue('platform')
|
||||
expect(screen.queryByText(/当前环境生效模型/)).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(source).getByRole('option', {
|
||||
name: '管理员环境优先,回退 GoodBuddy 兼容连接'
|
||||
})
|
||||
).toHaveValue('platform')
|
||||
expect(
|
||||
within(source).queryByRole('option', { name: /自有配置/ })
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.change(source, {
|
||||
target: { value: runtimeSettings.modelProfiles[0]!.id }
|
||||
})
|
||||
@@ -3161,6 +3178,127 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', true],
|
||||
['zh-CN', false],
|
||||
['en-US', true],
|
||||
['en-US', false]
|
||||
] as const)('shows the saved Harness fallback in %s (compatible default: %s)', async (locale, compatibleDefault) => {
|
||||
await changeUiLocale(locale)
|
||||
const firstCompatible = {
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
id: '00000000-0000-4000-8000-000000000052',
|
||||
name: 'First compatible',
|
||||
modelName: 'first-model',
|
||||
protocol: 'openai-chat-completions' as const
|
||||
}
|
||||
const preferred = {
|
||||
...firstCompatible,
|
||||
id: modelProfileId,
|
||||
name: 'Preferred',
|
||||
modelName: 'preferred-model',
|
||||
protocol: compatibleDefault
|
||||
? 'openai-chat-completions' as const
|
||||
: 'openai-images-generations' as const
|
||||
}
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
modelProfiles: [firstCompatible, preferred],
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
deepseekHarnessPlatformModel: {
|
||||
source: 'profile',
|
||||
profileId: compatibleDefault ? preferred.id : firstCompatible.id,
|
||||
name: compatibleDefault ? preferred.name : firstCompatible.name,
|
||||
modelName: compatibleDefault ? preferred.modelName : firstCompatible.modelName
|
||||
}
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(await screen.findByRole('button', {
|
||||
name: locale === 'zh-CN' ? 'DeepSeek Harness(预览)' : 'DeepSeek Harness (Preview)'
|
||||
}))
|
||||
const source = screen.getByLabelText(locale === 'zh-CN'
|
||||
? 'DeepSeek Harness OpenAI 兼容模型连接'
|
||||
: 'DeepSeek Harness OpenAI-compatible model connection')
|
||||
expect(source).toHaveValue('platform')
|
||||
expect(within(source).getByRole('option', {
|
||||
name: locale === 'zh-CN'
|
||||
? '管理员环境优先,回退 GoodBuddy 兼容连接'
|
||||
: 'Administrator environment, then GoodBuddy fallback'
|
||||
})).not.toBeDisabled()
|
||||
const resolvedName = compatibleDefault ? 'Preferred · preferred-model' : 'First compatible · first-model'
|
||||
expect(screen.getByText(locale === 'zh-CN'
|
||||
? `未配置完整的管理员预置;已保存配置中的实际回退:${resolvedName}。`
|
||||
: `No complete administrator preset; actual fallback in saved settings: ${resolvedName}.`)).toBeInTheDocument()
|
||||
expect(source).toHaveAccessibleDescription(/Harness/)
|
||||
})
|
||||
|
||||
it.each([true, false])('keeps the Harness platform option usable without compatible saved profiles (administrator available: %s)', async (available) => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings, deepseekHarnessModelSource: { kind: 'default' },
|
||||
deepseekHarnessPlatformModel: available
|
||||
? { source: 'environment', name: '管理员预置模型', modelName: 'administrator-model' }
|
||||
: { source: 'unavailable' }
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek Harness(预览)' }))
|
||||
const source = screen.getByLabelText('DeepSeek Harness OpenAI 兼容模型连接')
|
||||
expect(source).not.toBeDisabled()
|
||||
expect(within(source).getByRole('option', { name: '跟随全局默认模型' })).toBeDisabled()
|
||||
fireEvent.change(source, { target: { value: 'platform' } })
|
||||
expect(source).toHaveValue('platform')
|
||||
expect(screen.getByText(available
|
||||
? '此选项实际使用管理员预置:administrator-model。'
|
||||
: '没有可用的管理员预置或兼容的 GoodBuddy 连接。')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() => expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deepseekHarnessModelSource: { kind: 'platform' } })
|
||||
))
|
||||
})
|
||||
|
||||
it('keeps the actual saved Harness fallback separate from the unsaved draft preview', async () => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
modelProfiles: [{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
protocol: 'openai-chat-completions', modelName: 'saved-model'
|
||||
}],
|
||||
deepseekHarnessPlatformModel: {
|
||||
source: 'profile', profileId: modelProfileId, name: '默认模型', modelName: 'saved-model'
|
||||
}
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()} onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
fireEvent.change(screen.getByLabelText('名称'), { target: { value: 'Draft connection' } })
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek Harness(预览)' }))
|
||||
expect(screen.getByText(/已保存配置中的实际回退:默认模型 · saved-model。/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/草稿兼容候选(未保存):Draft connection · saved-model。/)).toBeInTheDocument()
|
||||
expect(updateRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects, warns about, clears, and saves a custom binary', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
|
||||
@@ -1927,6 +1927,40 @@ export function SettingsPanel({
|
||||
defaultModelProfileId,
|
||||
isDeepseekHarnessCompatible
|
||||
)
|
||||
const harnessPlatformModel = settings?.deepseekHarnessPlatformModel
|
||||
const harnessDraftFallbackChanged =
|
||||
harnessPlatformModel &&
|
||||
harnessPlatformModel.source !== 'environment' &&
|
||||
(harnessPlatformModel.source === 'profile'
|
||||
? harnessPlatformModel.profileId !== defaultDeepseekHarnessModelProfile?.id ||
|
||||
harnessPlatformModel.name !== defaultDeepseekHarnessModelProfile?.name ||
|
||||
harnessPlatformModel.modelName !== defaultDeepseekHarnessModelProfile?.modelName
|
||||
: Boolean(defaultDeepseekHarnessModelProfile))
|
||||
const harnessPlatformSummary = [
|
||||
!harnessPlatformModel
|
||||
? t('runtime.deepseekHarness.platformSourceUnavailable')
|
||||
: harnessPlatformModel.source === 'environment'
|
||||
? t('runtime.deepseekHarness.environmentSource', {
|
||||
model: harnessPlatformModel.modelName
|
||||
})
|
||||
: harnessPlatformModel.source === 'profile'
|
||||
? t('runtime.deepseekHarness.platformFallback', {
|
||||
name: modelProfileDisplayName({
|
||||
id: harnessPlatformModel.profileId,
|
||||
name: harnessPlatformModel.name
|
||||
}),
|
||||
model: harnessPlatformModel.modelName
|
||||
})
|
||||
: t('runtime.deepseekHarness.platformNoFallback'),
|
||||
harnessDraftFallbackChanged
|
||||
? defaultDeepseekHarnessModelProfile
|
||||
? t('runtime.deepseekHarness.platformDraftFallback', {
|
||||
name: modelProfileDisplayName(defaultDeepseekHarnessModelProfile),
|
||||
model: defaultDeepseekHarnessModelProfile.modelName
|
||||
})
|
||||
: t('runtime.deepseekHarness.platformDraftNoFallback')
|
||||
: ''
|
||||
].filter(Boolean).join(' ')
|
||||
const activeRuntimeModelSource =
|
||||
agentRuntimeType === 'opencode'
|
||||
? opencodeModelSource
|
||||
@@ -2755,7 +2789,7 @@ export function SettingsPanel({
|
||||
detecting={detecting}
|
||||
modelConfiguration={
|
||||
activeRuntimeModelSource.kind === 'platform'
|
||||
? t('runtime.deepseekHarness.managedSource')
|
||||
? harnessPlatformSummary
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
@@ -2778,7 +2812,7 @@ export function SettingsPanel({
|
||||
aria-label={`DeepSeek Harness ${t(
|
||||
'runtime.deepseekHarness.connection'
|
||||
)}`}
|
||||
disabled={!defaultDeepseekHarnessModelProfile}
|
||||
aria-describedby="deepseek-harness-connection-description"
|
||||
onChange={(event) =>
|
||||
setDeepseekHarnessModelSource(
|
||||
parseModelSource(event.target.value)
|
||||
@@ -2790,8 +2824,8 @@ export function SettingsPanel({
|
||||
: deepseekHarnessModelSource.kind
|
||||
}
|
||||
>
|
||||
<option value="default">{t('runtime.followRecommended')}</option>
|
||||
<option value="platform">{t('runtime.ownConfiguration', { runtime: 'DeepSeek Harness' })}</option>
|
||||
<option disabled={!defaultDeepseekHarnessModelProfile} value="default">{t('runtime.followRecommended')}</option>
|
||||
<option value="platform">{t('runtime.deepseekHarness.platformSource')}</option>
|
||||
{modelProfiles.map((profile) => (
|
||||
<option
|
||||
disabled={!isDeepseekHarnessCompatible(profile)}
|
||||
@@ -2805,7 +2839,7 @@ export function SettingsPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
<small id="deepseek-harness-connection-description">
|
||||
{t(
|
||||
'runtime.deepseekHarness.connectionDescription'
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { changeUiLocale } from './i18n'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { gitStatusLetter, WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
import type { WorkspaceManagementAction, WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
|
||||
|
||||
beforeEach(() => vi.stubGlobal('goodbuddy', { workspace: { manage: vi.fn(async () => ({ kind: 'branches', current: 'main', branches: [] })) } }))
|
||||
|
||||
@@ -14,6 +15,78 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('WorkspaceFilesPanel', () => {
|
||||
it.each([false, true])('keeps Git operations usable when a task refresh occurs during history pagination (failure: %s)', async (fail) => {
|
||||
const head = 'a'.repeat(40)
|
||||
const initial = { oid: head, subject: 'Latest commit', author: 'Author', time: '2026-09-09T10:00:00Z', refs: 'HEAD -> main' }
|
||||
let resolvePage!: (value: WorkspaceManagementResult) => void
|
||||
let rejectPage!: (reason: Error) => void
|
||||
const page = new Promise<WorkspaceManagementResult>((resolve, reject) => { resolvePage = resolve; rejectPage = reject })
|
||||
const manage = vi.fn(async (_project: string, action: WorkspaceManagementAction): Promise<WorkspaceManagementResult> => {
|
||||
if (action.kind === 'history') return action.offset
|
||||
? page
|
||||
: { kind: 'history', head, commits: [initial], hasMore: true }
|
||||
if (action.kind === 'fetch') return { kind: 'done' }
|
||||
return { kind: 'branches', current: 'main', branches: [] }
|
||||
})
|
||||
vi.stubGlobal('goodbuddy', { workspace: { manage } })
|
||||
const props = {
|
||||
projectId: 'project', isRepository: true, changedFiles: [],
|
||||
onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })),
|
||||
onLoadDiff: vi.fn(), onOpenFile: vi.fn(), onRefresh: vi.fn(async () => undefined)
|
||||
}
|
||||
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={0} />)
|
||||
await waitFor(() => expect(props.onListDirectory).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交历史' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '加载更多提交' }))
|
||||
expect(screen.getByRole('button', { name: 'Fetch' })).toBeDisabled()
|
||||
expect(manage).toHaveBeenCalledWith('project', { kind: 'history', offset: 1, head })
|
||||
rerender(<WorkspaceFilesPanel {...props} refreshToken={1} />)
|
||||
await waitFor(() => expect(manage.mock.calls.filter(([, action]) => action.kind === 'branches')).toHaveLength(2))
|
||||
await act(async () => {
|
||||
if (fail) rejectPage(new Error('History temporarily unavailable'))
|
||||
else resolvePage({ kind: 'history', head, commits: [{ ...initial, oid: 'b'.repeat(40), subject: 'Older commit' }], hasMore: false })
|
||||
})
|
||||
if (fail) {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('History temporarily unavailable')
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeEnabled()
|
||||
} else expect(screen.getByText('Older commit')).toBeInTheDocument()
|
||||
expect(screen.getByText('Latest commit')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Fetch' })).toBeEnabled()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fetch' }))
|
||||
await waitFor(() => expect(props.onRefresh).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Fetch' })).toBeEnabled())
|
||||
expect(manage).toHaveBeenCalledWith('project', { kind: 'fetch' })
|
||||
})
|
||||
|
||||
it.each([false, true])('refreshes the browsed directory once even when its tree entry is not expanded (expanded: %s)', async (expanded) => {
|
||||
let version = 0
|
||||
const onListDirectory = vi.fn(async (path: string) => ({
|
||||
path, truncated: false, entries: path
|
||||
? [{ name: `file-${version}.txt`, path: `${path}/file-${version}.txt`, type: 'file' as const }]
|
||||
: [{ name: 'docs', path: 'docs', type: 'directory' as const }]
|
||||
}))
|
||||
const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile: vi.fn() }
|
||||
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
const directory = await screen.findByRole('button', { name: 'docs' })
|
||||
if (expanded) {
|
||||
fireEvent.click(directory)
|
||||
await screen.findByRole('button', { name: 'file-0.txt' })
|
||||
} else expect(directory).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'docs 的更多操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '浏览此目录' }))
|
||||
await screen.findByRole('button', { name: 'file-0.txt' })
|
||||
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location')
|
||||
const beforeRefresh = onListDirectory.mock.calls.length
|
||||
version++
|
||||
rerender(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
await screen.findByRole('button', { name: 'file-1.txt' })
|
||||
expect(screen.queryByRole('button', { name: 'file-0.txt' })).not.toBeInTheDocument()
|
||||
expect(onListDirectory.mock.calls.slice(beforeRefresh)).toEqual([[''], ['docs']])
|
||||
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location')
|
||||
})
|
||||
|
||||
it('keeps directory navigation and compact actions bounded independently', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
|
||||
expect(css.match(/\.workspace-files__actions\s*\{([^}]*)\}/)?.[1]).toContain('flex-wrap: nowrap')
|
||||
|
||||
@@ -254,9 +254,8 @@ export function WorkspaceFilesPanel({
|
||||
)
|
||||
|
||||
const refreshDirectories = useEffectEvent((generation: number) => {
|
||||
const paths = expandedState.projectId === projectId ? [...expandedState.value] : []
|
||||
const paths = new Set(['', browsedPath, ...(expandedState.projectId === projectId ? expandedState.value : [])])
|
||||
setLoadingState({ projectId, value: new Set() })
|
||||
void loadDirectory('', generation)
|
||||
for (const path of paths) void loadDirectory(path, generation)
|
||||
if (diff) openDiff(diff.path)
|
||||
})
|
||||
@@ -505,7 +504,7 @@ export function WorkspaceFilesPanel({
|
||||
{ value: 'changes', label: t('management.gitWorkspace') }
|
||||
]} /></div>}
|
||||
<div hidden={activeView !== 'changes'}>
|
||||
{showGit && <WorkspaceGitTools projectId={projectId} refreshToken={refreshToken} onRefresh={refresh} viewControl={
|
||||
{showGit && <WorkspaceGitTools key={projectId} projectId={projectId} refreshToken={refreshToken} onRefresh={refresh} viewControl={
|
||||
<SegmentedControl ariaLabel={t('management.changedView')} value={changeView} onChange={setChangeView} options={[{ value: 'list', label: t('management.list') }, { value: 'tree', label: t('management.tree') }]} />
|
||||
}>
|
||||
{gitError ? <p className="workspace-files__status" role="status">{t('sidebar.workspace.gitUnavailable', { error: gitError })}</p>
|
||||
|
||||
@@ -30,13 +30,16 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
|
||||
const commitBack = useRef<HTMLButtonElement>(null)
|
||||
const action = (value: WorkspaceManagementAction): Promise<WorkspaceManagementResult> => window.goodbuddy.workspace.manage(projectId, value)
|
||||
useEffect(() => {
|
||||
const branchCounter = branchRequest
|
||||
const detailCounter = request
|
||||
return () => { detailCounter.current++ }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const branchCounter = branchRequest
|
||||
const generation = ++branchRequest.current
|
||||
void window.goodbuddy.workspace.manage(projectId, { kind: 'branches' }).then((result) => {
|
||||
if (branchRequest.current === generation && result.kind === 'branches') setBranches(result)
|
||||
}).catch((reason: unknown) => { if (branchRequest.current === generation) setError(String(reason)) })
|
||||
return () => { branchCounter.current++; detailCounter.current++ }
|
||||
return () => { branchCounter.current++ }
|
||||
}, [projectId, refreshToken])
|
||||
useEffect(() => { if (path) diffBack.current?.focus(); else if (commit) commitBack.current?.focus() }, [path, commit])
|
||||
const run = async (value: WorkspaceManagementAction): Promise<void> => {
|
||||
|
||||
@@ -399,13 +399,24 @@ export const settings = {
|
||||
previewDescription: 'Developer preview · OpenAI-compatible',
|
||||
description:
|
||||
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Ask can call native read/skill plus enabled Web Search/Fetch, while Execute can use every enabled tool and DSH plugin capability. Cancellation and workspace boundaries remain in place.',
|
||||
managedSource:
|
||||
'Automatic: prefer the administrator connection, otherwise follow a compatible current model',
|
||||
platformSource: 'Administrator environment, then GoodBuddy fallback',
|
||||
environmentSource:
|
||||
'This option resolves to the administrator preset: {{model}}.',
|
||||
platformFallback:
|
||||
'No complete administrator preset; actual fallback in saved settings: {{name}} · {{model}}.',
|
||||
platformNoFallback:
|
||||
'No available administrator preset or compatible GoodBuddy connection.',
|
||||
platformSourceUnavailable:
|
||||
'Model source information is unavailable. Reload settings.',
|
||||
platformDraftFallback:
|
||||
'Draft compatible candidate (not saved): {{name}} · {{model}}. Saving resolves the source again against the administrator environment.',
|
||||
platformDraftNoFallback:
|
||||
'The unsaved draft has no compatible fallback connection.',
|
||||
connection: 'OpenAI-compatible model connection',
|
||||
connectionPlaceholder:
|
||||
'Automatically select a compatible model connection',
|
||||
connectionDescription:
|
||||
'Optionally select a GoodBuddy model connection. Otherwise GoodBuddy uses the administrator connection, the compatible current default, or the first compatible connection. It must use OpenAI Chat Completions with API-key authentication.',
|
||||
'Administrator environment first uses the deployment’s complete model URL, model name, and API key; otherwise it uses the compatible GoodBuddy default, then the first compatible connection. Follow the global default model skips the administrator preset; a named connection stays pinned. Connections must use OpenAI Chat Completions with API-key authentication. This does not load Harness user profiles or configuration files.',
|
||||
advancedDescription:
|
||||
'This Runtime always uses GoodBuddy’s bundled, version-pinned Host and does not load user profiles or custom Hosts. GoodBuddy manages enabled marketplace plugins and loads them with the Host.',
|
||||
marketplace: {
|
||||
|
||||
@@ -374,6 +374,7 @@ export const workspace = {
|
||||
browser: {
|
||||
title: 'Live browser',
|
||||
unbound: 'No conversation',
|
||||
workbarFull: 'The workbar has {{count}} open tabs. The Agent page is retained; close a closable tab to view it.',
|
||||
interact: 'Interact',
|
||||
interacting: 'Interacting',
|
||||
close: 'Close browser',
|
||||
|
||||
@@ -367,12 +367,23 @@ export const settings = {
|
||||
previewDescription: '开发者预览 · OpenAI 兼容',
|
||||
description:
|
||||
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Ask 可调用 Harness 原生 read/skill 与已启用的网页搜索/抓取,Execute 可调用全部已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
|
||||
managedSource:
|
||||
'自动选择:优先使用管理员预置连接,否则跟随当前兼容模型',
|
||||
platformSource: '管理员环境优先,回退 GoodBuddy 兼容连接',
|
||||
environmentSource:
|
||||
'此选项实际使用管理员预置:{{model}}。',
|
||||
platformFallback:
|
||||
'未配置完整的管理员预置;已保存配置中的实际回退:{{name}} · {{model}}。',
|
||||
platformNoFallback:
|
||||
'没有可用的管理员预置或兼容的 GoodBuddy 连接。',
|
||||
platformSourceUnavailable:
|
||||
'模型来源信息不可用,请重新加载设置。',
|
||||
platformDraftFallback:
|
||||
'草稿兼容候选(未保存):{{name}} · {{model}}。保存后按管理员环境重新解析来源。',
|
||||
platformDraftNoFallback:
|
||||
'未保存草稿中没有兼容回退连接。',
|
||||
connection: 'OpenAI 兼容模型连接',
|
||||
connectionPlaceholder: '自动选择兼容模型连接',
|
||||
connectionDescription:
|
||||
'可从 GoodBuddy 模型连接中显式指定;未指定时自动使用管理员预置连接、当前默认兼容连接或首个兼容连接。协议必须为 OpenAI Chat Completions,并使用 API Key。',
|
||||
'“管理员环境优先”先使用部署环境中完整的模型地址、模型名称和 API Key,否则使用 GoodBuddy 默认兼容连接,再回退首个兼容连接。“跟随全局默认模型”跳过管理员预置;指定连接则保持固定。连接必须使用 OpenAI Chat Completions 和 API Key。此选项不加载 Harness 用户 profile 或配置文件。',
|
||||
advancedDescription:
|
||||
'该 Runtime 始终使用 GoodBuddy 内置并固定版本的 Host,不加载用户 profile 或自定义 Host;已启用的市场插件由 GoodBuddy 托管并随 Host 启动。',
|
||||
marketplace: {
|
||||
|
||||
@@ -363,6 +363,7 @@ export const workspace = {
|
||||
browser: {
|
||||
title: '实时浏览器',
|
||||
unbound: '未绑定会话',
|
||||
workbarFull: '工作栏已打开 {{count}} 个页签。Agent 页面已保留,请关闭一个可关闭页签后查看。',
|
||||
interact: '交互',
|
||||
interacting: '交互中',
|
||||
close: '关闭浏览器',
|
||||
|
||||
Reference in New Issue
Block a user