feat: gate remote projects as a technical preview

Remote Host and managed SSH project entry points were always exposed while the signed artifact workflow remains preview-stage. Remote Projects now defaults off behind its own Platform Features tab, preserves saved Hosts, projects, and credentials, and returns active remote work to a local project when disabled.

Main IPC enforces the same setting while still allowing in-flight cancellation. Application settings migrate to version 9 with the preview disabled.

Release note: 新增“远程项目(技术预览)”独立页签和开关,默认关闭;启用后显示 SSH Host 与远程项目功能,关闭不会删除已有配置。
This commit is contained in:
mesalogo
2026-08-25 00:16:02 +08:00
parent 607bb33249
commit 6c74f057eb
23 changed files with 1267 additions and 115 deletions
+240 -4
View File
@@ -899,6 +899,35 @@ function deferred<T>(): {
return { promise, resolve, reject }
}
function installRemoteProjectsSetting(enabled: boolean): {
updateSettings: NonNullable<DesktopApi['updates']>['updateSettings']
} {
let settings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: enabled,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
const updateSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => {
settings = { ...settings, ...input }
return { ...settings }
})
api.updates = {
getSettings: vi.fn(async () => ({ ...settings })),
updateSettings,
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
}
return { updateSettings }
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
@@ -1008,6 +1037,7 @@ describe('App', () => {
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
api.channels = undefined
api.updates = undefined
newConversationListener = undefined
beforeQuitListener = undefined
browserListener = undefined
@@ -1930,6 +1960,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -2024,6 +2055,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -2033,6 +2065,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -2128,6 +2161,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -2137,6 +2171,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -3973,6 +4008,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -5592,8 +5628,19 @@ describe('App', () => {
await waitFor(() =>
expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce()
)
const projectSaveIndex = vi
.mocked(api.conversations.saveLocal)
.mock.calls.findIndex(([batch]) =>
batch.some(
(conversation) =>
conversation.header.projectId === secondProject.id
)
)
expect(projectSaveIndex).toBeGreaterThanOrEqual(0)
expect(
vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1)
vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder[
projectSaveIndex
]
).toBeLessThan(
vi.mocked(
api.conversationQueue.enqueueUser
@@ -5637,7 +5684,169 @@ describe('App', () => {
)
})
it('keeps saved remote projects hidden while the preview is disabled', async () => {
installRemoteProjectsSetting(false)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000116',
name: '已保存远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000216',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
render(<App />)
const trigger = await screen.findByRole('button', {
name: '当前项目'
})
fireEvent.click(trigger)
const menu = screen.getByRole('menu', { name: '当前项目' })
expect(
within(menu).queryByText(remoteProject.name)
).not.toBeInTheDocument()
expect(api.projects.remote.activate).not.toHaveBeenCalled()
})
it('falls back to a local project when the remote preview is disabled', async () => {
const { updateSettings } = installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000117',
name: '预览远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000217',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockResolvedValueOnce(
remoteProject
)
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
)
fireEvent.click(screen.getByText('本地工作区'))
fireEvent.click(
await screen.findByRole('tab', { name: '平台功能' })
)
fireEvent.click(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
)
const remoteProjectsSwitch = await screen.findByRole('switch', {
name: '远程项目(技术预览)'
})
expect(remoteProjectsSwitch).toBeChecked()
fireEvent.click(remoteProjectsSwitch)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
remoteProjectsEnabled: false
})
)
await waitFor(() =>
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
)
fireEvent.click(
screen.getByRole('button', { name: '当前项目' })
)
expect(
screen.queryByText(remoteProject.name)
).not.toBeInTheDocument()
expect(api.projects.setArchived).not.toHaveBeenCalled()
expect(api.projects.delete).not.toHaveBeenCalled()
})
it('cancels pending remote activation when the preview is disabled', async () => {
const { updateSettings } = installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000118',
name: '连接中的远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000218',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
const activation = deferred<typeof remoteProject>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockReturnValueOnce(
activation.promise
)
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await screen.findByRole('progressbar', {
name: '远程项目连接进度'
})
fireEvent.click(screen.getByText('本地工作区'))
fireEvent.click(
await screen.findByRole('tab', { name: '平台功能' })
)
fireEvent.click(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
)
fireEvent.click(
await screen.findByRole('switch', {
name: '远程项目(技术预览)'
})
)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
remoteProjectsEnabled: false
})
)
expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce()
await act(async () => {
activation.resolve(remoteProject)
await activation.promise
})
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
})
it('keeps the current project active when remote activation fails', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000104',
@@ -5659,9 +5868,11 @@ describe('App', () => {
new Error('Agent upload failed')
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
await waitFor(() =>
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
)
selectProjectOption(remoteProject.name)
@@ -5676,6 +5887,7 @@ describe('App', () => {
})
it('revalidates the current SSH project when it is selected again', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000114',
@@ -5718,6 +5930,7 @@ describe('App', () => {
})
it('keeps Host-stale projects blocked and preserves queued dispatches until reactivation succeeds', async () => {
installRemoteProjectsSetting(true)
const hostId = '00000000-0000-4000-8000-000000000215'
const candidateId =
'00000000-0000-4000-8000-000000000315'
@@ -5967,6 +6180,7 @@ describe('App', () => {
})
it('shows every direct remote activation phase and blocks conflicting project actions', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000105',
@@ -6041,6 +6255,7 @@ describe('App', () => {
})
it('cancels direct remote activation without switching projects', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000106',
@@ -8111,6 +8326,19 @@ describe('App', () => {
})
it('edits and safely deletes the current project from project settings', async () => {
const hiddenRemoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000119',
name: '隐藏远程项目',
rootPath: '/srv/hidden',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000219',
remoteRootPath: '/srv/hidden'
},
builtInDefault: false
}
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
@@ -8122,6 +8350,7 @@ describe('App', () => {
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
hiddenRemoteProject,
project,
secondProject
])
@@ -9309,6 +9538,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -9318,6 +9548,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -9363,6 +9594,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -9415,6 +9647,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate' as const,
@@ -9446,6 +9679,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -9455,6 +9689,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -9489,6 +9724,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
+64 -8
View File
@@ -1421,6 +1421,15 @@ function getProjectDefaultRuntimeSelection(
: selection
}
function isOrdinaryLocalProject(
project: AssistantProject
): boolean {
return (
project.kind === 'user' &&
project.executionSpace.kind === 'local'
)
}
function resolveContextMetricsRuntimeSelection(
selection: AgentRuntimeSelection,
settings: RuntimeSettings
@@ -2322,6 +2331,8 @@ function App(): React.JSX.Element {
useState<SettingsCategoryId>()
const [settingsInitialChannel, setSettingsInitialChannel] =
useState<ProjectChannel>()
const [remoteProjectsEnabled, setRemoteProjectsEnabled] =
useState(false)
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [
magicNotesShowIncompleteTodoCount,
@@ -2728,6 +2739,7 @@ function App(): React.JSX.Element {
void updates
.getSettings()
.then(async (settings) => {
setRemoteProjectsEnabled(settings.remoteProjectsEnabled)
setMagicNotesEnabled(settings.magicNotesEnabled)
setMagicNotesShowIncompleteTodoCount(
settings.magicNotesShowIncompleteTodoCount
@@ -4843,6 +4855,12 @@ function App(): React.JSX.Element {
project: AssistantProject
current: boolean
}> => {
if (
!remoteProjectsEnabled ||
project.executionSpace.kind !== 'ssh'
) {
return { project, current: false }
}
const requestId = remoteProjectActivationRequestRef.current + 1
remoteProjectActivationRequestRef.current = requestId
const control = {
@@ -4938,7 +4956,7 @@ function App(): React.JSX.Element {
}
}
},
[notify]
[notify, remoteProjectsEnabled]
)
const cancelRemoteProjectActivation = useCallback((): void => {
@@ -5044,6 +5062,9 @@ function App(): React.JSX.Element {
const handleSshHostUpdated = useCallback(
(hostId: string): void => {
if (!remoteProjectsEnabled) {
return
}
const affectedProjects = projects.filter(
(candidate) =>
candidate.executionSpace.kind === 'ssh' &&
@@ -5085,6 +5106,7 @@ function App(): React.JSX.Element {
markRemoteProjectsRequiringActivation,
notify,
projects,
remoteProjectsEnabled,
resumeProjectConversationQueues
]
)
@@ -5100,11 +5122,7 @@ function App(): React.JSX.Element {
return
}
setProjects(value)
const project = value.find(
(candidate) =>
candidate.kind === 'user' &&
candidate.executionSpace.kind === 'local'
)
const project = value.find(isOrdinaryLocalProject)
if (!project) {
throw new Error('没有可用的本地项目')
}
@@ -5806,6 +5824,9 @@ function App(): React.JSX.Element {
commitProjectSelection(project)
return
}
if (!remoteProjectsEnabled) {
return
}
void activateRemoteProject(project).then(
(activated) => {
if (activated.current) {
@@ -5871,6 +5892,12 @@ function App(): React.JSX.Element {
const loadCommittedRemoteProject = async (
project: AssistantProject
): Promise<void> => {
if (
!remoteProjectsEnabled ||
project.executionSpace.kind !== 'ssh'
) {
return
}
setProjects((current) =>
current.some((candidate) => candidate.id === project.id)
? current.map((candidate) =>
@@ -5884,12 +5911,34 @@ function App(): React.JSX.Element {
resumeProjectConversationQueues(project.id)
}
const handleRemoteProjectsEnabledChange = (
enabled: boolean
): void => {
setRemoteProjectsEnabled(enabled)
if (enabled) {
return
}
remoteProjectActivationRequestRef.current += 1
if (remoteProjectActivationControlRef.current) {
cancelRemoteProjectActivation()
}
if (activeProject?.executionSpace.kind !== 'ssh') {
return
}
const localProject = projects.find(isOrdinaryLocalProject)
if (localProject) {
commitProjectSelection(localProject)
}
}
const archiveProject = async (projectId: string): Promise<void> => {
await window.goodbuddy.projects.setArchived(projectId, true)
clearRemoteProjectActivationRequirement(projectId)
const remaining = projects.filter((project) => project.id !== projectId)
setProjects(remaining)
const next = remaining[0]
const next = remoteProjectsEnabled
? remaining[0]
: remaining.find(isOrdinaryLocalProject)
if (next) {
selectProject(next.id)
}
@@ -5940,7 +5989,9 @@ function App(): React.JSX.Element {
: []
})
)
const next = remainingProjects[0]
const next = remoteProjectsEnabled
? remainingProjects[0]
: remainingProjects.find(isOrdinaryLocalProject)
if (next) {
setActiveProjectId(next.id)
setWorkMode(
@@ -7839,6 +7890,7 @@ function App(): React.JSX.Element {
onUpdate={updateProject}
projects={projects}
remoteActivation={remoteProjectActivation}
remoteProjectsEnabled={remoteProjectsEnabled}
/>
{activeProject?.kind !== 'channel' && (
@@ -10013,6 +10065,7 @@ function App(): React.JSX.Element {
initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onAppearanceThemeChange={setAppearanceTheme}
onClearLocalData={clearLocalData}
onClose={() => {
@@ -10047,6 +10100,9 @@ function App(): React.JSX.Element {
}
}
}
onRemoteProjectsEnabledChange={
handleRemoteProjectsEnabledChange
}
onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onSshHostUpdated={handleSshHostUpdated}
@@ -189,6 +189,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -202,6 +203,7 @@ beforeEach(() => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -1070,6 +1072,7 @@ describe('MagicNotesWorkspace', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
@@ -1103,6 +1106,7 @@ describe('MagicNotesWorkspace', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
@@ -1219,6 +1223,7 @@ describe('MagicNotesWorkspace', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
@@ -28,6 +28,7 @@ type PlatformFeaturesSettingsSectionProps = {
onMagicNotesShowIncompleteTodoCountChange: (
enabled: boolean
) => void
onRemoteProjectsEnabledChange: (enabled: boolean) => void
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
onShortcutSettingsChanged?: (
@@ -35,7 +36,10 @@ type PlatformFeaturesSettingsSectionProps = {
) => void
}
type PlatformFeaturesTab = 'general' | 'magic-notes'
type PlatformFeaturesTab =
| 'general'
| 'remote-projects'
| 'magic-notes'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
@@ -52,6 +56,7 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({
onMagicNotesEnabledChange,
onMagicNotesShowIncompleteTodoCountChange,
onRemoteProjectsEnabledChange,
onNotify,
onDirtyChange,
onShortcutSettingsChanged
@@ -296,6 +301,32 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeRemoteProjects = async (
enabled: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
remoteProjectsEnabled: enabled
})
setSettings(nextSettings)
onRemoteProjectsEnabledChange(
nextSettings.remoteProjectsEnabled
)
} catch {
setError(
t('platformFeatures.errors.saveRemoteProjectsFailed')
)
} finally {
setSaving(false)
}
}
const changeCommentMode = async (
magicNoteCommentMode: MagicNoteCommentMode
): Promise<void> => {
@@ -380,6 +411,10 @@ export function PlatformFeaturesSettingsSection({
id: 'general',
label: t('platformFeatures.tabs.general')
},
{
id: 'remote-projects',
label: t('platformFeatures.remoteProjects.title')
},
{
id: 'magic-notes',
label: t('platformFeatures.tabs.magicNotes')
@@ -568,6 +603,54 @@ export function PlatformFeaturesSettingsSection({
)}
</section>
<section
aria-labelledby="platform-features-tab-remote-projects"
className="settings-section"
hidden={activeSection !== 'remote-projects'}
id="platform-features-panel-remote-projects"
role="tabpanel"
>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t('platformFeatures.remoteProjects.title')}
</strong>
<small>
{t(
'platformFeatures.remoteProjects.description'
)}
</small>
</div>
</div>
<label className="toggle-row">
<input
aria-label={t(
'platformFeatures.remoteProjects.title'
)}
checked={settings.remoteProjectsEnabled}
disabled={saving}
onChange={(event) =>
void changeRemoteProjects(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span>
{t('platformFeatures.remoteProjects.enabled')}
</span>
</label>
</article>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
</section>
<section
aria-labelledby="platform-features-tab-magic-notes"
className="settings-section"
+102 -24
View File
@@ -69,7 +69,14 @@ afterEach(async () => {
})
function renderSwitcher(
currentProject: AssistantProject = project
currentProject: AssistantProject = project,
{
projects = [currentProject],
remoteProjectsEnabled = true
}: {
projects?: AssistantProject[]
remoteProjectsEnabled?: boolean
} = {}
): {
onCreate: ReturnType<typeof vi.fn>
onRemoteCommitted: ReturnType<typeof vi.fn>
@@ -96,7 +103,8 @@ function renderSwitcher(
onSelect={vi.fn()}
onSelectRoot={onSelectRoot}
onUpdate={onUpdate}
projects={[currentProject]}
projects={projects}
remoteProjectsEnabled={remoteProjectsEnabled}
runtimeSettings={runtimeSettings}
/>
)
@@ -154,39 +162,43 @@ function installRemoteApi() {
])
)
const cancelDirectoryBrowse = vi.fn(async () => undefined)
const getSnapshot = vi.fn(async () => ({
hosts: [
{
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent' as const,
credentialConfigured: true,
credentialSource: 'system-agent' as const,
hostKey: { state: 'verified' as const, generation: 1 },
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
],
secureStorageAvailable: true
}))
const onSaveProgress = vi.fn(
(listener: (value: { phase: 'host' | 'agent' }) => void) => {
progress = listener
return vi.fn()
}
)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
sshHosts: {
browseDirectories,
cancelDirectoryBrowse,
getSnapshot: vi.fn(async () => ({
hosts: [
{
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
credentialConfigured: true,
credentialSource: 'system-agent',
hostKey: { state: 'verified', generation: 1 },
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
],
secureStorageAvailable: true
}))
getSnapshot
},
projects: {
remote: {
save,
cancelCurrent,
onSaveProgress: vi.fn((listener) => {
progress = listener
return vi.fn()
})
onSaveProgress
}
}
}
@@ -196,6 +208,8 @@ function installRemoteApi() {
cancelCurrent,
browseDirectories,
cancelDirectoryBrowse,
getSnapshot,
onSaveProgress,
emit: (phase: 'host' | 'agent') => progress?.({ phase })
}
}
@@ -308,6 +322,70 @@ describe('ProjectSwitcher runtime fields', () => {
})
describe('ProjectSwitcher managed SSH projects', () => {
it('hides remote projects and APIs when the feature is disabled', async () => {
await i18n.changeLanguage('en-US')
const api = installRemoteApi()
const remoteProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'Remote project',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project'
},
runtimeSelection: { provider: 'opencode' }
}
renderSwitcher(project, {
projects: [project, remoteProject],
remoteProjectsEnabled: false
})
fireEvent.click(screen.getByLabelText('Current project'))
const menu = screen.getByRole('menu', { name: 'Current project' })
expect(
within(menu).queryByRole('group', { name: 'Remote projects' })
).not.toBeInTheDocument()
expect(within(menu).queryByText('Remote project')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('New project'))
const dialog = screen.getByRole('dialog', { name: 'New project' })
expect(
within(dialog).queryByRole('button', { name: 'Managed SSH' })
).not.toBeInTheDocument()
expect(api.getSnapshot).not.toHaveBeenCalled()
expect(api.onSaveProgress).not.toHaveBeenCalled()
expect(api.save).not.toHaveBeenCalled()
})
it('refuses settings for a saved SSH project while disabled', () => {
const api = installRemoteApi()
const remoteProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'Remote project',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project'
},
runtimeSelection: { provider: 'opencode' }
}
renderSwitcher(remoteProject, { remoteProjectsEnabled: false })
const settings = screen.getByLabelText('项目设置')
expect(settings).toBeDisabled()
fireEvent.click(settings)
expect(
screen.queryByRole('dialog', { name: '项目设置' })
).not.toBeInTheDocument()
expect(api.getSnapshot).not.toHaveBeenCalled()
expect(api.onSaveProgress).not.toHaveBeenCalled()
expect(api.save).not.toHaveBeenCalled()
})
it('uses remote work directory copy and opens from a valid typed path', async () => {
await i18n.changeLanguage('en-US')
const api = installRemoteApi()
+98 -27
View File
@@ -49,6 +49,7 @@ import { sshHostErrorMessage } from './ssh-host-ui'
type ProjectSwitcherProps = {
projects: AssistantProject[]
activeProjectId: string
remoteProjectsEnabled?: boolean
remoteActivation?: {
projectId: string
phase: RemoteProjectSavePhase
@@ -173,6 +174,7 @@ function isRemoteAbsolutePath(value: string): boolean {
export function ProjectSwitcher({
projects,
activeProjectId,
remoteProjectsEnabled = false,
remoteActivation,
runtimeSettings,
onArchive,
@@ -249,13 +251,16 @@ export function ProjectSwitcher({
const channelProjects = projects.filter(
(project) => project.kind === 'channel'
)
const userProjects = [...localProjects, ...remoteProjects]
const userProjects = [
...localProjects,
...(remoteProjectsEnabled ? remoteProjects : [])
]
const busy =
saving ||
remoteSaving ||
archiving ||
deleting ||
remoteActivation !== undefined
(remoteProjectsEnabled && remoteActivation !== undefined)
const remoteFieldsDisabled = busy
const remoteDirectoryBrowseDisabled =
remoteFieldsDisabled ||
@@ -278,7 +283,11 @@ export function ProjectSwitcher({
: t('projectSwitcher.selector.empty')
useEffect(() => {
if (!dialogMode || executionSpaceKind !== 'ssh') {
if (
!remoteProjectsEnabled ||
!dialogMode ||
executionSpaceKind !== 'ssh'
) {
return
}
let active = true
@@ -318,10 +327,14 @@ export function ProjectSwitcher({
return () => {
active = false
}
}, [dialogMode, executionSpaceKind, t])
}, [dialogMode, executionSpaceKind, remoteProjectsEnabled, t])
useEffect(() => {
if (!dialogMode || executionSpaceKind !== 'ssh') {
if (
!remoteProjectsEnabled ||
!dialogMode ||
executionSpaceKind !== 'ssh'
) {
return
}
const remoteApi = window.goodbuddy.projects.remote
@@ -331,7 +344,28 @@ export function ProjectSwitcher({
return remoteApi.onSaveProgress((progress) =>
setRemoteSavePhase(progress.phase)
)
}, [dialogMode, executionSpaceKind])
}, [dialogMode, executionSpaceKind, remoteProjectsEnabled])
useEffect(() => {
if (
remoteProjectsEnabled ||
dialogMode !== 'settings' ||
activeProject?.executionSpace.kind !== 'ssh'
) {
return
}
let active = true
queueMicrotask(() => {
if (active) {
setConfirmingDelete(false)
setDeleteConfirmation('')
setDialogMode(undefined)
}
})
return () => {
active = false
}
}, [activeProject, dialogMode, remoteProjectsEnabled])
const resetRemoteDraft = (): void => {
directoryBrowseRequestRef.current += 1
@@ -349,7 +383,9 @@ export function ProjectSwitcher({
const cancelRemoteDirectoryBrowse = useCallback((): void => {
directoryBrowseRequestRef.current += 1
const api = window.goodbuddy.sshHosts
const api = remoteProjectsEnabled
? window.goodbuddy.sshHosts
: undefined
if (api) {
void api.cancelDirectoryBrowse().catch(() => undefined)
}
@@ -360,11 +396,14 @@ export function ProjectSwitcher({
requestAnimationFrame(() =>
directoryPickerTriggerRef.current?.focus()
)
}, [])
}, [remoteProjectsEnabled])
const browseRemoteDirectories = async (
path?: string
): Promise<void> => {
if (!remoteProjectsEnabled) {
return
}
const api = window.goodbuddy.sshHosts
if (!api || !remoteHostId) {
return
@@ -465,7 +504,7 @@ export function ProjectSwitcher({
return
}
if (event.key === 'Escape' && !archiving && !deleting) {
if (remoteSaving) {
if (remoteProjectsEnabled && remoteSaving) {
void window.goodbuddy.projects.remote
.cancelCurrent()
.catch(() => undefined)
@@ -485,18 +524,21 @@ export function ProjectSwitcher({
deleting,
dialogMode,
directoryPickerOpen,
remoteProjectsEnabled,
remoteSaving
])
const closeDialog = (): void => {
directoryBrowseRequestRef.current += 1
if (directoryPickerOpen) {
const api = window.goodbuddy.sshHosts
const api = remoteProjectsEnabled
? window.goodbuddy.sshHosts
: undefined
if (api) {
void api.cancelDirectoryBrowse().catch(() => undefined)
}
}
if (remoteSaving) {
if (remoteProjectsEnabled && remoteSaving) {
void window.goodbuddy.projects.remote
.cancelCurrent()
.catch(() => undefined)
@@ -520,7 +562,11 @@ export function ProjectSwitcher({
const saveRemoteProject = async (): Promise<void> => {
setError(undefined)
if (!draft.name.trim() || !validateRemoteDraft()) {
if (
!remoteProjectsEnabled ||
!draft.name.trim() ||
!validateRemoteDraft()
) {
return
}
setRemoteSaving(true)
@@ -668,7 +714,9 @@ export function ProjectSwitcher({
aria-haspopup="menu"
aria-label={t('projectSwitcher.selector.ariaLabel')}
className="project-switcher__trigger"
disabled={remoteActivation !== undefined}
disabled={
remoteProjectsEnabled && remoteActivation !== undefined
}
onClick={() => setProjectMenuOpen((open) => !open)}
onKeyDown={(event) => {
if (
@@ -738,11 +786,17 @@ export function ProjectSwitcher({
label: t('projectSwitcher.selector.userProjects'),
icon: Folder
},
{
projects: remoteProjects,
label: t('projectSwitcher.selector.remoteProjects'),
icon: Server
},
...(remoteProjectsEnabled
? [
{
projects: remoteProjects,
label: t(
'projectSwitcher.selector.remoteProjects'
),
icon: Server
}
]
: []),
{
projects: channelProjects,
label: t('projectSwitcher.selector.channelProjects'),
@@ -823,7 +877,9 @@ export function ProjectSwitcher({
<button
aria-label={t('projectSwitcher.selector.create')}
className="icon-button"
disabled={remoteActivation !== undefined}
disabled={
remoteProjectsEnabled && remoteActivation !== undefined
}
onClick={() => {
setProjectMenuOpen(false)
setError(undefined)
@@ -851,11 +907,22 @@ export function ProjectSwitcher({
<button
aria-label={t('projectSwitcher.selector.settings')}
className="icon-button"
disabled={!activeProject || remoteActivation !== undefined}
disabled={
!activeProject ||
(remoteProjectsEnabled && remoteActivation !== undefined) ||
(!remoteProjectsEnabled &&
activeProject.executionSpace.kind === 'ssh')
}
onClick={() => {
if (!activeProject) {
return
}
if (
!remoteProjectsEnabled &&
activeProject.executionSpace.kind === 'ssh'
) {
return
}
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
@@ -900,7 +967,7 @@ export function ProjectSwitcher({
<Settings size={15} />
</button>
</div>
{remoteActivation && (
{remoteProjectsEnabled && remoteActivation && (
<RemoteProjectProgress
cancelling={remoteActivation.cancelling}
onCancel={onCancelRemoteActivation}
@@ -1035,12 +1102,16 @@ export function ProjectSwitcher({
'projectSwitcher.remote.executionSpaces.local'
)
},
{
value: 'ssh',
label: t(
'projectSwitcher.remote.executionSpaces.ssh'
)
}
...(remoteProjectsEnabled
? [
{
value: 'ssh' as const,
label: t(
'projectSwitcher.remote.executionSpaces.ssh'
)
}
]
: [])
]}
value={executionSpaceKind}
/>
+120 -1
View File
@@ -364,7 +364,8 @@ const heartbeatSettingsProps = {
onUpdateProject: vi.fn(async () => {
throw new Error('Project update is not used in this test')
}),
projects: []
projects: [],
remoteProjectsEnabled: true
}
const assistantExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000101',
@@ -461,6 +462,7 @@ let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -683,6 +685,7 @@ describe('SettingsPanel runtime files', () => {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -1734,6 +1737,122 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('persists Remote Projects from its own tab and notifies its parent', async () => {
const onRemoteProjectsEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const remoteProjectsTab = await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
expect(remoteProjectsTab).toHaveAttribute(
'aria-selected',
'false'
)
expect(
screen.queryByRole('switch', {
name: '远程项目(技术预览)'
})
).not.toBeInTheDocument()
fireEvent.click(remoteProjectsTab)
expect(remoteProjectsTab).toHaveAttribute('aria-selected', 'true')
const remoteProjectsSwitch = await screen.findByRole('switch', {
name: '远程项目(技术预览)'
})
expect(remoteProjectsSwitch).not.toBeChecked()
fireEvent.click(remoteProjectsSwitch)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
remoteProjectsEnabled: true
})
)
expect(onRemoteProjectsEnabledChange).toHaveBeenCalledWith(true)
expect(remoteProjectsSwitch).toBeChecked()
})
it('shows the SSH Hosts category only while Remote Projects is enabled', () => {
const commonProps = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toBeInTheDocument()
})
it('falls back to Platform features when Remote Projects is disabled from SSH Hosts', async () => {
const commonProps = {
...heartbeatSettingsProps,
initialCategory: 'ssh-hosts' as const,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
await screen.findByText('尚未配置 SSH 主机')
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
expect(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
).toBeInTheDocument()
})
it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
+25 -6
View File
@@ -60,7 +60,7 @@ import {
SettingsWarningList
} from './SettingsPrimitives'
import {
settingsCategoryList,
getSettingsCategoryList,
type SettingsCategoryId
} from './settings-categories'
import type { AppearanceTheme } from './theme'
@@ -115,7 +115,6 @@ function toEmbeddingConnectionDrafts(
}))
}
const settingsTabs = settingsCategoryList.map(({ id }) => id)
const contextCompressionTokenScale = 1_000
const minimumContextCompressionTriggerThousands = 8
const maximumContextCompressionTriggerThousands = 1_000
@@ -218,7 +217,9 @@ type SettingsPanelProps = {
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange?: (
enabled: boolean
) => void
@@ -585,7 +586,9 @@ export function SettingsPanel({
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onMagicNotesEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onMagicNotesShowIncompleteTodoCountChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
@@ -707,8 +710,16 @@ export function SettingsPanel({
const [clearingLocalData, setClearingLocalData] = useState(false)
const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false)
const [activeTab, setActiveTab] =
useState<SettingsCategoryId>(initialCategory ?? 'runtime')
const [selectedTab, setActiveTab] =
useState<SettingsCategoryId>(
initialCategory === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: initialCategory ?? 'runtime'
)
const activeTab =
selectedTab === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: selectedTab
const [modelType, setModelType] = useState<ModelType>('llm')
const [speechModelDraftId, setSpeechModelDraftId] = useState<
string | null | undefined
@@ -959,6 +970,11 @@ export function SettingsPanel({
return true
}
const visibleSettingsCategories = getSettingsCategoryList(
remoteProjectsEnabled
)
const settingsTabs = visibleSettingsCategories.map(({ id }) => id)
const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
tab: SettingsCategoryId
@@ -1820,7 +1836,7 @@ export function SettingsPanel({
className="settings-tabs"
role="tablist"
>
{settingsCategoryList.map((category) => (
{visibleSettingsCategories.map((category) => (
<button
aria-controls={`settings-panel-${category.id}`}
aria-label={t(
@@ -2036,6 +2052,9 @@ export function SettingsPanel({
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
onNotify={onNotify}
onShortcutSettingsChanged={onShortcutSettingsChanged}
/>
@@ -3584,7 +3603,7 @@ export function SettingsPanel({
/>
)}
{activeTab === 'ssh-hosts' && (
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
<SshHostsSettingsSection
onDirtyChange={setSshHostsDirty}
onHostUpdated={onSshHostUpdated}
@@ -21,6 +21,7 @@ describe('UpdateSettingsSection', () => {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -444,6 +444,8 @@ export const settingsSections = {
serviceUnavailable:
'Application settings are not available in this version',
readFailed: 'Could not load platform feature settings',
saveRemoteProjectsFailed:
'Could not save the Remote Projects setting. Try again.',
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
saveIncompleteTodoCountFailed:
'Could not save the incomplete to-do count setting. Try again.',
@@ -460,6 +462,12 @@ export const settingsSections = {
general: 'General',
magicNotes: 'Magic Notes'
},
remoteProjects: {
title: 'Remote Projects (Technical Preview)',
description:
'Enables SSH Host management and GoodBuddy-managed remote projects.',
enabled: 'Enable Remote Projects'
},
shortcut: {
title: 'Global quick access',
description:
@@ -421,6 +421,7 @@ export const settingsSections = {
errors: {
serviceUnavailable: '当前版本未提供应用设置服务',
readFailed: '读取平台功能设置失败',
saveRemoteProjectsFailed: '保存远程项目设置失败,请重试',
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
saveIncompleteTodoCountFailed:
'保存未完成待办数量设置失败,请重试',
@@ -434,6 +435,11 @@ export const settingsSections = {
general: '通用设置',
magicNotes: '魔法笔记'
},
remoteProjects: {
title: '远程项目(技术预览)',
description: '启用 SSH Host 管理和由 GoodBuddy 托管的远程项目。',
enabled: '启用远程项目'
},
shortcut: {
title: '全局快捷唤起',
description: '在其他应用前台时,也可以显示或隐藏 GoodBuddy。',
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getSettingsCategoryList } from './settings-categories'
describe('getSettingsCategoryList', () => {
it('hides SSH hosts while Remote Projects is disabled', () => {
expect(
getSettingsCategoryList(false).map(({ id }) => id)
).not.toContain('ssh-hosts')
})
it('includes SSH hosts while Remote Projects is enabled', () => {
expect(
getSettingsCategoryList(true).map(({ id }) => id)
).toContain('ssh-hosts')
})
})
+8
View File
@@ -59,6 +59,14 @@ export type SettingsCategoryDefinition =
(typeof settingsCategoryList)[number]
export type SettingsCategoryId = SettingsCategoryDefinition['id']
export function getSettingsCategoryList(
remoteProjectsEnabled: boolean
): readonly SettingsCategoryDefinition[] {
return remoteProjectsEnabled
? settingsCategoryList
: settingsCategoryList.filter(({ id }) => id !== 'ssh-hosts')
}
type LocalizedSettingsCategoryDefinition = SettingsCategoryDefinition & {
readonly label: string
readonly navigationDescription: string