feat: keep managed SSH prompts running on Hosts

Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages.

Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4.

Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
This commit is contained in:
mesalogo
2026-08-31 11:04:41 +08:00
parent f6f8d5e053
commit 561de29f00
75 changed files with 13439 additions and 1327 deletions
+289
View File
@@ -128,7 +128,13 @@ let remoteProjectSaveProgressListener:
DesktopApi['projects']['remote']['onSaveProgress']
>[0]
| undefined
let remoteProjectRecoveryProgressListener:
| Parameters<
DesktopApi['projects']['remote']['onRecoveryProgress']
>[0]
| undefined
const removeMaximizedChangedListener = vi.fn()
const removeProjectRecoveryListener = vi.fn()
const run = vi.fn<DesktopApi['agent']['run']>()
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const projectId = '00000000-0000-4000-8000-000000000101'
@@ -405,6 +411,14 @@ const api: DesktopApi = {
remoteProjectSaveProgressListener = undefined
}
}
}),
getRecoverySnapshot: vi.fn(async () => ({
recoveries: []
})),
retryRecovery: vi.fn(),
onRecoveryProgress: vi.fn((listener) => {
remoteProjectRecoveryProgressListener = listener
return removeProjectRecoveryListener
})
}
},
@@ -995,6 +1009,18 @@ describe('App', () => {
}
}
})
remoteProjectRecoveryProgressListener = undefined
removeProjectRecoveryListener.mockReset()
vi.mocked(api.projects.remote.getRecoverySnapshot)
.mockReset()
.mockResolvedValue({ recoveries: [] })
vi.mocked(api.projects.remote.retryRecovery).mockReset()
vi.mocked(api.projects.remote.onRecoveryProgress)
.mockReset()
.mockImplementation((listener) => {
remoteProjectRecoveryProgressListener = listener
return removeProjectRecoveryListener
})
conversationQueueChangeListener = undefined
conversationQueueDispatchListener = undefined
vi.mocked(api.conversationQueue.list)
@@ -5949,6 +5975,269 @@ describe('App', () => {
).toBeInTheDocument()
})
it('keeps managed SSH histories visible while independently blocking recovery projects', async () => {
installRemoteProjectsSetting(true)
const first = {
...project,
id: '00000000-0000-4000-8000-000000000131',
name: '恢复项目一',
rootPath: '/srv/one',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000231',
remoteRootPath: '/srv/one'
},
builtInDefault: false
}
const second = {
...first,
id: '00000000-0000-4000-8000-000000000132',
name: '恢复项目二',
rootPath: '/srv/two',
executionSpace: {
...first.executionSpace,
remoteRootPath: '/srv/two'
}
}
const firstConversationId =
'00000000-0000-4000-8000-000000000331'
const secondConversationId =
'00000000-0000-4000-8000-000000000332'
const firstRequestId =
'00000000-0000-4000-8000-000000000431'
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
first,
second
])
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: firstConversationId,
projectId: first.id,
runtimeSelection: { provider: 'opencode' },
title: '恢复前的会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000531',
role: 'assistant',
content: '恢复期间仍可查看的历史',
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
},
{
id: secondConversationId,
projectId: second.id,
runtimeSelection: { provider: 'opencode' },
title: '第二个恢复会话',
updatedAt: 1_774_000_000_000,
messages: []
}
])
vi.mocked(
api.projects.remote.getRecoverySnapshot
).mockResolvedValueOnce({
recoveries: [
{
projectId: first.id,
requestId: firstRequestId,
stage: 'cursor',
current: '125'
},
{
projectId: second.id,
requestId:
'00000000-0000-4000-8000-000000000432',
stage: 'agent'
}
]
})
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
await waitFor(() =>
expect(
api.projects.remote.getRecoverySnapshot
).toHaveBeenCalledOnce()
)
vi.mocked(api.conversationQueue.ready).mockClear()
selectProjectOption(first.name)
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(first.name)
expect(
await screen.findByText('恢复期间仍可查看的历史')
).toBeInTheDocument()
expect(
screen.getByText('正在恢复对话,已同步到事件 125')
).toBeInTheDocument()
expect(api.conversationQueue.ready).not.toHaveBeenCalledWith(
firstConversationId
)
const firstComposer = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.change(firstComposer, {
target: { value: '恢复完成后发送' }
})
fireEvent.keyDown(firstComposer, { key: 'Enter' })
expect(firstComposer).toHaveValue('恢复完成后发送')
expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled()
expect(screen.getByLabelText('发送')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
const menu = screen.getByRole('menu', { name: '当前项目' })
expect(
within(menu).getByText('正在恢复远端 Agent…')
).toBeInTheDocument()
fireEvent.click(
within(menu).getByText(project.name, { selector: 'b' })
.closest('[role="menuitemradio"]')!
)
const localComposer = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.change(localComposer, {
target: { value: '本地项目不受影响' }
})
await waitFor(() =>
expect(screen.getByLabelText('发送')).toBeEnabled()
)
fireEvent.keyDown(localComposer, { key: 'Enter' })
await waitFor(() =>
expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce()
)
selectProjectOption(first.name)
act(() => {
remoteProjectRecoveryProgressListener?.({
projectId: first.id,
requestId:
'00000000-0000-4000-8000-000000000499',
stage: 'completed'
})
})
expect(screen.getByLabelText('发送')).toBeDisabled()
expect(firstComposer).toHaveValue('恢复完成后发送')
act(() => {
remoteProjectRecoveryProgressListener?.({
projectId: first.id,
requestId: firstRequestId,
stage: 'completed'
})
})
await waitFor(() =>
expect(screen.getByText('恢复完成')).toBeInTheDocument()
)
expect(screen.getByLabelText('发送')).toBeEnabled()
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
firstConversationId
)
)
expect(api.conversationQueue.ready).not.toHaveBeenCalledWith(
secondConversationId
)
})
it('retries a failed recovery with a new request ID and cleans up its listener', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000133',
name: '失败的恢复项目',
rootPath: '/srv/failed',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000233',
remoteRootPath: '/srv/failed'
},
builtInDefault: false
}
const oldRequestId =
'00000000-0000-4000-8000-000000000433'
const newRequestId =
'00000000-0000-4000-8000-000000000434'
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(
api.projects.remote.getRecoverySnapshot
).mockResolvedValueOnce({
recoveries: [
{
projectId: remoteProject.id,
requestId: oldRequestId,
stage: 'failed',
message: '无法连接 Host',
retryable: true
}
]
})
vi.mocked(api.projects.remote.retryRecovery).mockResolvedValueOnce({
projectId: remoteProject.id,
requestId: newRequestId,
stage: 'network'
})
const { unmount } = render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
expect(
await screen.findByRole('alert')
).toHaveTextContent('恢复失败:无法连接 Host')
fireEvent.click(
screen.getByRole('button', {
name: `重试恢复项目 ${remoteProject.name}`
})
)
await waitFor(() =>
expect(
api.projects.remote.retryRecovery
).toHaveBeenCalledWith(remoteProject.id)
)
expect(
await screen.findByText('正在恢复网络连接…')
).toBeInTheDocument()
act(() => {
remoteProjectRecoveryProgressListener?.({
projectId: remoteProject.id,
requestId: oldRequestId,
stage: 'completed'
})
})
expect(screen.getByText('正在恢复网络连接…')).toBeInTheDocument()
expect(screen.getByLabelText('发送')).toBeDisabled()
act(() => {
remoteProjectRecoveryProgressListener?.({
projectId: remoteProject.id,
requestId: newRequestId,
stage: 'completed'
})
})
expect(
await screen.findByText('恢复完成')
).toBeInTheDocument()
act(() => {
remoteProjectRecoveryProgressListener?.({
projectId: remoteProject.id,
requestId: newRequestId,
stage: 'network'
})
})
expect(screen.getByText('恢复完成')).toBeInTheDocument()
unmount()
expect(removeProjectRecoveryListener).toHaveBeenCalledOnce()
})
it('switches between projects on one Host without a connection phase', async () => {
installRemoteProjectsSetting(true)
const first = {
+222 -7
View File
@@ -203,6 +203,9 @@ import type {
AppNotificationTone
} from './notifications'
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import type {
RemoteProjectRecoveryState
} from '../../shared/remote-project-recovery-contracts'
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
import { scheduleIdleRoutePreload } from './idle-route-preload'
import { createPreloadableComponent } from './preloadable-component'
@@ -1432,19 +1435,23 @@ function mergePersistedConversations(
const localMessageById = new Map(
local.messages.map((message) => [message.id, message])
)
const localIsNewer = local.updatedAt > conversation.updatedAt
const serverMessageIds = new Set(
conversation.messages.map((message) => message.id)
)
const messages = [
...conversation.messages.map(
(message) => localMessageById.get(message.id) ?? message
(message) =>
localIsNewer
? localMessageById.get(message.id) ?? message
: message
),
...local.messages.filter(
(message) => !serverMessageIds.has(message.id)
)
].slice(-500)
const next =
local.updatedAt > conversation.updatedAt
localIsNewer
? { ...local, messages }
: { ...conversation, messages }
persistedLocal.set(conversation.id, conversation)
@@ -1488,6 +1495,33 @@ function isManagedSshProject(
)
}
function isProjectRecoveryUnsettled(
state: RemoteProjectRecoveryState | undefined
): boolean {
return Boolean(
state &&
state.stage !== 'completed'
)
}
function remoteRecoveryStageOrder(
stage: RemoteProjectRecoveryState['stage']
): number {
switch (stage) {
case 'network':
return 0
case 'agent':
return 1
case 'runtime':
return 2
case 'cursor':
return 3
case 'completed':
case 'failed':
return 4
}
}
function isOrdinaryLocalProject(
project: AssistantProject
): boolean {
@@ -1989,6 +2023,15 @@ function App(): React.JSX.Element {
const migrationConversations = useRef(conversations)
const [projects, setProjects] = useState<AssistantProject[]>([])
const projectsRef = useRef(projects)
const [projectRecoveryByProjectId, setProjectRecoveryByProjectId] =
useState<Record<string, RemoteProjectRecoveryState>>({})
const [projectRecoverySnapshotReady, setProjectRecoverySnapshotReady] =
useState(false)
const projectRecoverySnapshotReadyRef = useRef(false)
const projectRecoveryByProjectIdRef = useRef<
Record<string, RemoteProjectRecoveryState>
>({})
const retryingRecoveryProjectIdsRef = useRef(new Set<string>())
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
const assistantTasksRef = useRef(assistantTasks)
const [tokenUsage, setTokenUsage] =
@@ -2596,13 +2639,23 @@ function App(): React.JSX.Element {
)
useEffect(() => {
if (!conversationStoreReady) {
if (!conversationStoreReady || !projectRecoverySnapshotReady) {
return
}
const conversationIds = [
...new Set(
conversationsRef.current
.filter((conversation) => !conversation.remote)
.filter(
(conversation) =>
!conversation.remote &&
!isProjectRecoveryUnsettled(
conversation.projectId
? projectRecoveryByProjectIdRef.current[
conversation.projectId
]
: undefined
)
)
.map((conversation) => conversation.id)
)
]
@@ -2619,7 +2672,7 @@ function App(): React.JSX.Element {
dedupeKey: 'conversation-queue-resume'
})
})
}, [conversationStoreReady])
}, [conversationStoreReady, projectRecoverySnapshotReady])
useEffect(() => {
const sweep = (): void => {
@@ -2773,6 +2826,11 @@ function App(): React.JSX.Element {
projectsRef.current = projects
}, [projects])
useLayoutEffect(() => {
projectRecoveryByProjectIdRef.current =
projectRecoveryByProjectId
}, [projectRecoveryByProjectId])
useEffect(() => {
assistantTasksRef.current = assistantTasks
}, [assistantTasks])
@@ -2938,6 +2996,14 @@ function App(): React.JSX.Element {
)
const activeProjectUsesManagedSsh =
isManagedSshProject(activeProject)
const activeProjectRecovery =
activeProjectUsesManagedSsh && activeProject
? projectRecoveryByProjectId[activeProject.id]
: undefined
const activeProjectRecoveryBlocked =
activeProjectUsesManagedSsh &&
(!projectRecoverySnapshotReady ||
isProjectRecoveryUnsettled(activeProjectRecovery))
const cachedWorkspaceViewKeys = useMemo(
() => new Set(cachedWorkspaceViews.map((entry) => entry.key)),
[cachedWorkspaceViews]
@@ -4591,6 +4657,20 @@ function App(): React.JSX.Element {
activeRuns.current.delete(event.requestId)
setConversationActivity(run.conversationId, false)
requestAnimationFrame(() => {
if (
run.projectId &&
((!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find(
(project) => project.id === run.projectId
)
)) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[run.projectId]
))
) {
return
}
void window.goodbuddy.conversationQueue
.ready(run.conversationId)
.catch(() => {
@@ -4999,6 +5079,19 @@ function App(): React.JSX.Element {
const resumeProjectConversationQueues = useCallback(
(projectId: string): void => {
if (
(!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find(
(project) => project.id === projectId
)
)) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[projectId]
)
) {
return
}
const conversationIds = conversationsRef.current
.filter(
(conversation) =>
@@ -5026,6 +5119,96 @@ function App(): React.JSX.Element {
[notify]
)
const applyProjectRecoveryState = useCallback(
(state: RemoteProjectRecoveryState): void => {
const previous =
projectRecoveryByProjectIdRef.current[state.projectId]
if (previous) {
if (previous.requestId !== state.requestId) {
if (
!['completed', 'failed'].includes(previous.stage) ||
state.stage !== 'network'
) {
return
}
} else if (
remoteRecoveryStageOrder(state.stage) <
remoteRecoveryStageOrder(previous.stage) ||
(previous.stage === 'cursor' &&
state.stage === 'cursor' &&
BigInt(state.current) < BigInt(previous.current)) ||
['completed', 'failed'].includes(previous.stage)
) {
return
}
}
const next = {
...projectRecoveryByProjectIdRef.current,
[state.projectId]: state
}
projectRecoveryByProjectIdRef.current = next
setProjectRecoveryByProjectId(next)
if (
state.stage === 'completed' &&
previous?.stage !== 'completed'
) {
resumeProjectConversationQueues(state.projectId)
}
},
[resumeProjectConversationQueues]
)
useEffect(() => {
const recoveryApi = window.goodbuddy.projects.remote
let active = true
const removeListener = recoveryApi.onRecoveryProgress((state) => {
if (active) {
applyProjectRecoveryState(state)
}
})
void recoveryApi.getRecoverySnapshot().then(
(snapshot) => {
if (active) {
snapshot.recoveries.forEach(applyProjectRecoveryState)
projectRecoverySnapshotReadyRef.current = true
setProjectRecoverySnapshotReady(true)
}
},
() => {
// Main reports actionable recovery failures as project states.
if (active) {
projectRecoverySnapshotReadyRef.current = true
setProjectRecoverySnapshotReady(true)
}
}
)
return () => {
active = false
removeListener()
}
}, [applyProjectRecoveryState])
const retryProjectRecovery = useCallback(
async (projectId: string): Promise<void> => {
if (retryingRecoveryProjectIdsRef.current.has(projectId)) {
return
}
retryingRecoveryProjectIdsRef.current.add(projectId)
try {
const state =
await window.goodbuddy.projects.remote.retryRecovery(
projectId
)
applyProjectRecoveryState(state)
} catch {
// Preserve the existing local failure without duplicating a toast.
} finally {
retryingRecoveryProjectIdsRef.current.delete(projectId)
}
},
[applyProjectRecoveryState]
)
useEffect(() => {
let active = true
const initialization = Promise.all([
@@ -5763,7 +5946,13 @@ function App(): React.JSX.Element {
return
}
commitProjectSelection(project)
resumeProjectConversationQueues(project.id)
if (
!isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[project.id]
)
) {
resumeProjectConversationQueues(project.id)
}
}
const createProject = async (
@@ -5824,7 +6013,13 @@ function App(): React.JSX.Element {
: [project, ...current]
)
commitProjectSelection(project)
resumeProjectConversationQueues(project.id)
if (
!isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[project.id]
)
) {
resumeProjectConversationQueues(project.id)
}
}
const handleRemoteProjectsEnabledChange = (
@@ -6455,6 +6650,23 @@ function App(): React.JSX.Element {
conversation.id === queuedInput.conversationId
)
: activeConversation
const recoveryProjectId =
queuedInput?.projectId ?? conversationSnapshot?.projectId
if (
recoveryProjectId &&
(((!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find(
(project) => project.id === recoveryProjectId
)
))) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[recoveryProjectId]
))
) {
await releaseQueuedItem()
return
}
const command =
!queuedInput &&
activeRuntimeSelection?.provider === 'opencode'
@@ -7781,11 +7993,13 @@ function App(): React.JSX.Element {
<ProjectSwitcher
activeProjectId={activeProjectId}
recoveryByProjectId={projectRecoveryByProjectId}
runtimeSettings={runtimeSettings}
onArchive={archiveProject}
onCreate={createProject}
onDelete={deleteProject}
onRemoteCommitted={loadCommittedRemoteProject}
onRetryRecovery={retryProjectRecovery}
onSelect={selectProject}
onSelectRoot={() =>
window.goodbuddy.settings.selectWorkspace()
@@ -9331,6 +9545,7 @@ function App(): React.JSX.Element {
)
)) ||
selectingContextFiles ||
activeProjectRecoveryBlocked ||
!runtime?.available ||
runtimeSwitching ||
runtimeStatusKey !== activeRuntimeSelectionKey
+294 -5
View File
@@ -1,4 +1,5 @@
import {
act,
cleanup,
fireEvent,
render,
@@ -16,8 +17,14 @@ import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contrac
import type {
SshDirectoryBrowseResult,
SshHost,
SshHostRemoteEnvironment
SshHostAgentConnectionState,
SshHostAgentConnectionStatus,
SshHostRemoteEnvironment,
SshHostsSnapshot
} from '../../shared/ssh-host-contracts'
import type {
RemoteProjectRecoveryState
} from '../../shared/remote-project-recovery-contracts'
import i18n from './i18n'
import { ProjectSwitcher } from './ProjectSwitcher'
@@ -76,10 +83,15 @@ function renderSwitcher(
currentProject: AssistantProject = project,
{
projects = [currentProject],
remoteProjectsEnabled = true
remoteProjectsEnabled = true,
recoveryByProjectId = {}
}: {
projects?: AssistantProject[]
remoteProjectsEnabled?: boolean
recoveryByProjectId?: Record<
string,
RemoteProjectRecoveryState
>
} = {}
): {
onCreate: ReturnType<typeof vi.fn>
@@ -101,6 +113,7 @@ function renderSwitcher(
const onSelectRoot = vi.fn(async () => undefined)
const onDelete = vi.fn(async () => undefined)
const onSelect = vi.fn()
const onRetryRecovery = vi.fn(async () => undefined)
render(
<ProjectSwitcher
activeProjectId={currentProject.id}
@@ -108,10 +121,12 @@ function renderSwitcher(
onCreate={onCreate}
onDelete={onDelete}
onRemoteCommitted={onRemoteCommitted}
onRetryRecovery={onRetryRecovery}
onSelect={onSelect}
onSelectRoot={onSelectRoot}
onUpdate={onUpdate}
projects={projects}
recoveryByProjectId={recoveryByProjectId}
remoteProjectsEnabled={remoteProjectsEnabled}
runtimeSettings={runtimeSettings}
/>
@@ -145,9 +160,15 @@ function directoryResult(
}
function installRemoteApi(
options: { hostValidated?: boolean } = {}
options: {
hostValidated?: boolean
connectionState?: SshHostAgentConnectionState
} = {}
) {
let progress: ((value: { phase: 'host' | 'agent' }) => void) | undefined
let connectionStatusListener:
| ((status: SshHostAgentConnectionStatus) => void)
| undefined
const savedProject: AssistantProject = {
...project,
id: remoteProjectId,
@@ -194,10 +215,19 @@ function installRemoteApi(
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
const getSnapshot = vi.fn(async () => ({
const getSnapshot = vi.fn(async (): Promise<SshHostsSnapshot> => ({
hosts: [host],
secureStorageAvailable: true
secureStorageAvailable: true,
agentConnectionStatusByHostId: {
[hostId]: options.connectionState ?? 'disconnected'
}
}))
const onAgentConnectionStatus = vi.fn(
(listener: (status: SshHostAgentConnectionStatus) => void) => {
connectionStatusListener = listener
return vi.fn()
}
)
const currentVersion = {
version: '1.0.0',
architecture: 'x64' as const
@@ -242,6 +272,7 @@ function installRemoteApi(
browseDirectories,
cancelDirectoryBrowse,
getSnapshot,
onAgentConnectionStatus,
getRemoteEnvironment,
updateRemoteEnvironment
},
@@ -260,9 +291,19 @@ function installRemoteApi(
browseDirectories,
cancelDirectoryBrowse,
getSnapshot,
host,
onAgentConnectionStatus,
getRemoteEnvironment,
updateRemoteEnvironment,
onSaveProgress,
emitConnectionStatus: (
state: SshHostAgentConnectionStatus['state'],
requestedHostId = hostId
) =>
connectionStatusListener?.({
hostId: requestedHostId,
state
}),
emit: (phase: 'host' | 'agent') => progress?.({ phase })
}
}
@@ -375,6 +416,254 @@ describe('ProjectSwitcher runtime fields', () => {
})
describe('ProjectSwitcher managed SSH projects', () => {
it('groups remote projects by SSH Host and shows live connection status beside the Host', async () => {
await i18n.changeLanguage('en-US')
const api = installRemoteApi({
connectionState: 'connecting'
})
const secondHostId =
'00000000-0000-4000-8000-000000000202'
const secondHost: SshHost = {
...api.host,
id: secondHostId,
name: 'Deploy host',
hostname: 'deploy.example.com'
}
api.getSnapshot.mockResolvedValue({
hosts: [api.host, secondHost],
secureStorageAvailable: true,
agentConnectionStatusByHostId: {
[hostId]: 'connecting',
[secondHostId]: 'ready'
}
})
const firstRemote: AssistantProject = {
...project,
id: remoteProjectId,
name: 'API service',
rootPath: '/srv/api',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/api'
},
runtimeSelection: { provider: 'opencode' }
}
const secondRemote: AssistantProject = {
...firstRemote,
id: '00000000-0000-4000-8000-000000000204',
name: 'Web service',
rootPath: '/srv/web',
executionSpace: {
kind: 'ssh',
hostId: secondHostId,
remoteRootPath: '/srv/web'
}
}
renderSwitcher(project, {
projects: [project, firstRemote, secondRemote]
})
fireEvent.click(
screen.getByRole('button', { name: 'Current project' })
)
const menu = screen.getByRole('menu', {
name: 'Current project'
})
const buildHost = await waitFor(() =>
within(menu).getByRole('group', {
name: 'SSH host: Build host'
})
)
const deployHost = within(menu).getByRole('group', {
name: 'SSH host: Deploy host'
})
expect(buildHost).toHaveTextContent('Connecting')
expect(buildHost).toHaveTextContent('/srv/api')
expect(deployHost).toHaveTextContent('Ready')
expect(deployHost).toHaveTextContent('/srv/web')
expect(within(menu).queryByText(/Managed SSH/u)).not.toBeInTheDocument()
act(() => api.emitConnectionStatus('ready'))
await waitFor(() =>
expect(buildHost).toHaveTextContent('Ready')
)
})
it('does not let an older Host snapshot overwrite a newer connection event', async () => {
await i18n.changeLanguage('en-US')
const api = installRemoteApi()
let resolveSnapshot:
| ((snapshot: SshHostsSnapshot) => void)
| undefined
api.getSnapshot.mockReturnValueOnce(
new Promise<SshHostsSnapshot>((resolve) => {
resolveSnapshot = resolve
})
)
const remoteProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'API service',
rootPath: '/srv/api',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/api'
},
runtimeSelection: { provider: 'opencode' }
}
renderSwitcher(project, {
projects: [project, remoteProject]
})
fireEvent.click(
screen.getByRole('button', { name: 'Current project' })
)
await waitFor(() =>
expect(api.getSnapshot).toHaveBeenCalledOnce()
)
await waitFor(() =>
expect(api.onAgentConnectionStatus).toHaveBeenCalledOnce()
)
act(() => api.emitConnectionStatus('ready'))
await waitFor(() =>
expect(
screen.getByText('Ready', {
selector: '.project-switcher__host-status'
})
).toBeInTheDocument()
)
await act(async () => {
resolveSnapshot?.({
hosts: [api.host],
secureStorageAvailable: true,
agentConnectionStatusByHostId: {
[hostId]: 'disconnected'
}
})
await Promise.resolve()
})
const hostGroup = await screen.findByRole('group', {
name: 'SSH host: Build host'
})
expect(hostGroup).toHaveTextContent('Ready')
expect(hostGroup).not.toHaveTextContent('Disconnected')
})
it('shows independent exact recovery stages and decimal cursor progress', async () => {
await i18n.changeLanguage('en-US')
const first: AssistantProject = {
...project,
id: remoteProjectId,
name: 'First remote',
rootPath: '/srv/first',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/first'
},
runtimeSelection: { provider: 'opencode' }
}
const second: AssistantProject = {
...first,
id: '00000000-0000-4000-8000-000000000204',
name: 'Second remote',
rootPath: '/srv/second',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/second'
}
}
renderSwitcher(first, {
projects: [first, second],
recoveryByProjectId: {
[first.id]: {
projectId: first.id,
requestId: '00000000-0000-4000-8000-000000000301',
stage: 'agent'
},
[second.id]: {
projectId: second.id,
requestId: '00000000-0000-4000-8000-000000000302',
stage: 'cursor',
current: '15'
}
}
})
expect(
screen.getByRole('button', { name: 'Current project' })
).toHaveTextContent('Restoring remote Agent…')
fireEvent.click(
screen.getByRole('button', { name: 'Current project' })
)
const menu = screen.getByRole('menu', {
name: 'Current project'
})
expect(
within(menu).getByText('Restoring remote Agent…')
).toBeInTheDocument()
expect(
within(menu).getByText('Restoring conversation at event 15')
).toBeInTheDocument()
})
it('renders an accessible failure and retries only the affected project', async () => {
await i18n.changeLanguage('en-US')
const remoteProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'Remote project',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project'
},
runtimeSelection: { provider: 'opencode' }
}
const onRetryRecovery = vi.fn(async () => undefined)
render(
<ProjectSwitcher
activeProjectId={remoteProject.id}
onArchive={vi.fn(async () => undefined)}
onCreate={vi.fn()}
onDelete={vi.fn()}
onRemoteCommitted={vi.fn()}
onRetryRecovery={onRetryRecovery}
onSelect={vi.fn()}
onSelectRoot={vi.fn()}
onUpdate={vi.fn()}
projects={[remoteProject]}
recoveryByProjectId={{
[remoteProject.id]: {
projectId: remoteProject.id,
requestId: '00000000-0000-4000-8000-000000000303',
stage: 'failed',
message: 'Host unreachable',
retryable: true
}
}}
remoteProjectsEnabled
/>
)
expect(screen.getAllByRole('alert')[0]).toHaveTextContent(
'Recovery failed: Host unreachable'
)
fireEvent.click(
screen.getByRole('button', {
name: 'Retry recovery for project Remote project'
})
)
await waitFor(() =>
expect(onRetryRecovery).toHaveBeenCalledWith(remoteProject.id)
)
})
it('hides remote projects and APIs when the feature is disabled', async () => {
await i18n.changeLanguage('en-US')
const api = installRemoteApi()
+496 -142
View File
@@ -3,8 +3,10 @@ import {
ArrowUp,
Check,
ChevronDown,
CircleAlert,
Folder,
FolderOpen,
LoaderCircle,
Plus,
RadioTower,
RefreshCw,
@@ -13,6 +15,7 @@ import {
Trash2,
X
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
@@ -30,8 +33,13 @@ import {
} from '../../shared/remote-project-candidate-contracts'
import type {
SshDirectoryBrowseResult,
SshHost,
SshHostAgentConnectionState,
SshHostsSnapshot
} from '../../shared/ssh-host-contracts'
import type {
RemoteProjectRecoveryState
} from '../../shared/remote-project-recovery-contracts'
import { trapTabFocus } from './dialog-focus'
import {
getDefaultRuntimeSelection
@@ -57,10 +65,14 @@ type ProjectSwitcherProps = {
onSelect: (projectId: string) => void
onSelectRoot: () => Promise<string | undefined>
onRemoteCommitted: (project: AssistantProject) => Promise<void>
onRetryRecovery?: (projectId: string) => Promise<void>
onUpdate: (
projectId: string,
input: ProjectCreateInput
) => Promise<AssistantProject>
recoveryByProjectId?: Readonly<
Record<string, RemoteProjectRecoveryState>
>
}
const remoteProjectPhases: readonly RemoteProjectSavePhase[] = [
@@ -74,6 +86,46 @@ type RemoteHostReadiness =
| { status: 'ready' }
| { status: 'unready' }
function ProjectRecoveryStatus({
state
}: {
state: RemoteProjectRecoveryState
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const working =
state.stage === 'network' ||
state.stage === 'agent' ||
state.stage === 'runtime' ||
state.stage === 'cursor'
const text =
state.stage === 'cursor'
? t('projectSwitcher.recovery.stages.cursor', {
current: state.current
})
: state.stage === 'failed'
? t('projectSwitcher.recovery.failed', {
message: state.message
})
: t(`projectSwitcher.recovery.stages.${state.stage}`)
return (
<span
aria-busy={working ? 'true' : undefined}
aria-live={state.stage === 'failed' ? 'assertive' : 'polite'}
className={`project-recovery-status project-recovery-status--${state.stage}`}
role={state.stage === 'failed' ? 'alert' : 'status'}
>
{working ? (
<LoaderCircle aria-hidden="true" size={13} />
) : state.stage === 'completed' ? (
<Check aria-hidden="true" size={13} />
) : (
<CircleAlert aria-hidden="true" size={13} />
)}
<span>{text}</span>
</span>
)
}
function RemoteProjectProgress({
phase,
phases = remoteProjectPhases,
@@ -161,9 +213,11 @@ export function ProjectSwitcher({
onCreate,
onDelete,
onRemoteCommitted,
onRetryRecovery,
onSelect,
onSelectRoot,
onUpdate
onUpdate,
recoveryByProjectId = {}
}: ProjectSwitcherProps): React.JSX.Element {
const { t } = useTranslation('workspace')
const [dialogMode, setDialogMode] = useState<
@@ -181,6 +235,21 @@ export function ProjectSwitcher({
'local' | 'ssh'
>('local')
const [sshHosts, setSshHosts] = useState<SshHostsSnapshot>()
const [
agentConnectionStatusByHostId,
setAgentConnectionStatusByHostId
] = useState<Record<string, SshHostAgentConnectionState>>({})
const agentConnectionStatusRevisionRef = useRef(0)
const liveAgentConnectionStatusRef = useRef(
new Map<
string,
{
revision: number
state: SshHostAgentConnectionState
}
>()
)
const pendingSshHostsSnapshotsRef = useRef(0)
const [loadingSshHosts, setLoadingSshHosts] = useState(false)
const [remoteHostReadiness, setRemoteHostReadiness] = useState<
Record<string, RemoteHostReadiness>
@@ -210,6 +279,70 @@ export function ProjectSwitcher({
'create' | 'settings' | 'picker' | undefined
>(undefined)
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
useState<string>()
const applySshHostsSnapshot = useCallback(
(
snapshot: SshHostsSnapshot,
requestRevision: number
): void => {
setSshHosts(snapshot)
const next = {
...(snapshot.agentConnectionStatusByHostId ?? {})
}
for (const [
hostId,
update
] of liveAgentConnectionStatusRef.current) {
if (update.revision <= requestRevision) {
continue
}
if (update.state === 'disconnected') {
delete next[hostId]
} else {
next[hostId] = update.state
}
}
setAgentConnectionStatusByHostId((current) => {
const currentKeys = Object.keys(current)
const nextKeys = Object.keys(next)
return currentKeys.length === nextKeys.length &&
nextKeys.every((hostId) => current[hostId] === next[hostId])
? current
: next
})
},
[]
)
const beginSshHostsSnapshot = useCallback((): number => {
pendingSshHostsSnapshotsRef.current += 1
return agentConnectionStatusRevisionRef.current
}, [])
const finishSshHostsSnapshot = useCallback((): void => {
pendingSshHostsSnapshotsRef.current = Math.max(
0,
pendingSshHostsSnapshotsRef.current - 1
)
if (pendingSshHostsSnapshotsRef.current === 0) {
liveAgentConnectionStatusRef.current.clear()
}
}, [])
const retryRecovery = useCallback(
(projectId: string): void => {
if (!onRetryRecovery) {
return
}
setRetryingRecoveryProjectId(projectId)
void onRetryRecovery(projectId)
.catch(() => undefined)
.finally(() =>
setRetryingRecoveryProjectId((current) =>
current === projectId ? undefined : current
)
)
},
[onRetryRecovery]
)
const [draft, setDraft] = useState<ProjectCreateInput>({
name: '',
description: '',
@@ -222,6 +355,10 @@ export function ProjectSwitcher({
const activeProjectDisplay = activeProject
? getProjectDisplayText(activeProject, t)
: undefined
const activeProjectRecovery =
activeProject?.executionSpace.kind === 'ssh'
? recoveryByProjectId[activeProject.id]
: undefined
const settingsProject = projects.find(
(project) => project.id === settingsProjectId
)
@@ -235,6 +372,41 @@ export function ProjectSwitcher({
project.kind === 'user' &&
project.executionSpace.kind === 'ssh'
)
const sshHostById = new Map(
(sshHosts?.hosts ?? []).map((host) => [host.id, host])
)
const remoteProjectGroups = [
...remoteProjects.reduce(
(
groups,
project
) => {
if (project.executionSpace.kind !== 'ssh') {
return groups
}
const hostId = project.executionSpace.hostId
const current = groups.get(hostId)
if (current) {
current.projects.push(project)
} else {
groups.set(hostId, {
hostId,
host: sshHostById.get(hostId),
projects: [project]
})
}
return groups
},
new Map<
string,
{
hostId: string
host?: SshHost
projects: AssistantProject[]
}
>()
).values()
]
const channelProjects = projects.filter(
(project) => project.kind === 'channel'
)
@@ -273,7 +445,7 @@ export function ProjectSwitcher({
return
}
let active = true
const api = window.goodbuddy.sshHosts
const api = window.goodbuddy?.sshHosts
if (!api) {
queueMicrotask(() => {
if (active) {
@@ -285,47 +457,123 @@ export function ProjectSwitcher({
})
return
}
void api.getSnapshot().then(
(snapshot) => {
if (!active) {
return
}
setSshHosts(snapshot)
const readiness: Record<string, RemoteHostReadiness> =
Object.fromEntries(
snapshot.hosts.map((host) => [
host.id,
host.hostKey.state === 'verified' &&
host.lastValidatedAt !== undefined
? { status: 'ready' as const }
: { status: 'unready' as const }
])
const requestRevision = beginSshHostsSnapshot()
void api
.getSnapshot()
.then(
(snapshot) => {
if (!active) {
return
}
applySshHostsSnapshot(snapshot, requestRevision)
const readiness: Record<string, RemoteHostReadiness> =
Object.fromEntries(
snapshot.hosts.map((host) => [
host.id,
host.hostKey.state === 'verified' &&
host.lastValidatedAt !== undefined
? { status: 'ready' as const }
: { status: 'unready' as const }
])
)
setRemoteHostReadiness(readiness)
setRemoteHostId((current) =>
current &&
snapshot.hosts.some((host) => host.id === current)
? current
: snapshot.hosts.find(
(host) =>
readiness[host.id]?.status === 'ready'
)?.id ??
snapshot.hosts[0]?.id ??
''
)
setRemoteHostReadiness(readiness)
setRemoteHostId((current) =>
current &&
snapshot.hosts.some((host) => host.id === current)
? current
: snapshot.hosts.find(
(host) =>
readiness[host.id]?.status === 'ready'
)?.id ??
snapshot.hosts[0]?.id ??
''
)
setLoadingSshHosts(false)
},
() => {
if (active) {
setError(t('projectSwitcher.remote.errors.loadHosts'))
setLoadingSshHosts(false)
},
() => {
if (active) {
setError(t('projectSwitcher.remote.errors.loadHosts'))
setLoadingSshHosts(false)
}
}
}
)
)
.finally(finishSshHostsSnapshot)
return () => {
active = false
}
}, [dialogMode, executionSpaceKind, remoteProjectsEnabled, t])
}, [
applySshHostsSnapshot,
beginSshHostsSnapshot,
dialogMode,
executionSpaceKind,
finishSshHostsSnapshot,
remoteProjectsEnabled,
t
])
useEffect(() => {
if (!remoteProjectsEnabled || !projectMenuOpen) {
return
}
const api = window.goodbuddy?.sshHosts
if (!api) {
return
}
let active = true
const requestRevision = beginSshHostsSnapshot()
void api
.getSnapshot()
.then(
(snapshot) => {
if (!active) {
return
}
applySshHostsSnapshot(snapshot, requestRevision)
},
() => undefined
)
.finally(finishSshHostsSnapshot)
return () => {
active = false
}
}, [
applySshHostsSnapshot,
beginSshHostsSnapshot,
finishSshHostsSnapshot,
projectMenuOpen,
remoteProjectsEnabled
])
useEffect(() => {
const api = remoteProjectsEnabled
? window.goodbuddy?.sshHosts
: undefined
if (!api) {
return
}
return api.onAgentConnectionStatus(({ hostId, state }) => {
const revision =
agentConnectionStatusRevisionRef.current + 1
agentConnectionStatusRevisionRef.current = revision
liveAgentConnectionStatusRef.current.set(hostId, {
revision,
state
})
setAgentConnectionStatusByHostId((current) => {
if (state === 'disconnected') {
if (!(hostId in current)) {
return current
}
const next = { ...current }
delete next[hostId]
return next
}
return current[hostId] === state
? current
: { ...current, [hostId]: state }
})
})
}, [remoteProjectsEnabled])
useEffect(() => {
if (
@@ -763,9 +1011,108 @@ export function ProjectSwitcher({
}
}
const renderProjectMenuItem = (
project: AssistantProject,
ProjectIcon: LucideIcon
): React.JSX.Element => {
const selected = project.id === activeProjectId
const projectDisplay = getProjectDisplayText(project, t)
const recovery =
project.executionSpace.kind === 'ssh'
? recoveryByProjectId[project.id]
: undefined
const detail =
project.kind === 'channel'
? t('projectSwitcher.selector.remoteDetail', {
channel: project.channel
? projectChannelLabels[project.channel]
: t('projectSwitcher.selector.remoteChannel'),
path: project.rootPath
})
: project.executionSpace.kind === 'ssh'
? t('projectSwitcher.selector.remotePath', {
path: project.rootPath
})
: t('projectSwitcher.selector.localDetail', {
path: project.rootPath
})
return (
<div
className={`project-switcher__menu-item${
recovery?.stage === 'failed' && recovery.retryable
? ' project-switcher__menu-item--with-retry'
: ''
}`}
key={project.id}
>
<button
aria-checked={selected}
onClick={() => {
if (!selected || project.executionSpace.kind === 'ssh') {
onSelect(project.id)
}
setProjectMenuOpen(false)
requestAnimationFrame(() =>
projectPickerButtonRef.current?.focus()
)
}}
role="menuitemradio"
tabIndex={selected ? 0 : -1}
type="button"
>
<ProjectIcon aria-hidden="true" size={16} />
<span>
<b>{projectDisplay.name}</b>
<small>{detail}</small>
{recovery && <ProjectRecoveryStatus state={recovery} />}
</span>
{selected && <Check aria-hidden="true" size={14} />}
</button>
{recovery?.stage === 'failed' &&
recovery.retryable &&
onRetryRecovery && (
<button
aria-label={t(
'projectSwitcher.recovery.retryNamed',
{ name: projectDisplay.name }
)}
className="project-switcher__menu-retry"
disabled={retryingRecoveryProjectId === project.id}
onClick={() => retryRecovery(project.id)}
role="menuitem"
tabIndex={-1}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
)}
<button
aria-label={t(
'projectSwitcher.selector.settingsNamed',
{ name: projectDisplay.name }
)}
className="project-switcher__menu-settings"
onClick={() => openProjectSettings(project, 'picker')}
role="menuitem"
tabIndex={-1}
type="button"
>
<Settings aria-hidden="true" size={14} />
</button>
</div>
)
}
return (
<div className="project-switcher">
<div className="project-switcher__row">
<div
className={`project-switcher__row${
activeProjectRecovery?.stage === 'failed' &&
activeProjectRecovery.retryable
? ' project-switcher__row--with-retry'
: ''
}`}
>
<div
className="project-switcher__picker"
ref={projectPickerRef}
@@ -791,9 +1138,14 @@ export function ProjectSwitcher({
title={activeProjectDisplay?.name}
type="button"
>
<span>
{activeProjectDisplay?.name ??
t('projectSwitcher.selector.empty')}
<span className="project-switcher__trigger-copy">
<span>
{activeProjectDisplay?.name ??
t('projectSwitcher.selector.empty')}
</span>
{activeProjectRecovery && (
<ProjectRecoveryStatus state={activeProjectRecovery} />
)}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
@@ -840,119 +1192,121 @@ export function ProjectSwitcher({
ref={projectPickerMenuRef}
role="menu"
>
{[
{
projects: localProjects,
label: t('projectSwitcher.selector.userProjects'),
icon: Folder
},
...(remoteProjectsEnabled
? [
{
projects: remoteProjects,
label: t(
'projectSwitcher.selector.remoteProjects'
),
icon: Server
}
]
: []),
{
projects: channelProjects,
label: t('projectSwitcher.selector.channelProjects'),
icon: RadioTower
}
].map((group) =>
group.projects.length > 0 ? (
{localProjects.length > 0 && (
<div
aria-label={t(
'projectSwitcher.selector.userProjects'
)}
className="project-switcher__group"
role="group"
>
<strong>
{t('projectSwitcher.selector.userProjects')}
</strong>
{localProjects.map((project) =>
renderProjectMenuItem(project, Folder)
)}
</div>
)}
{remoteProjectsEnabled &&
remoteProjectGroups.length > 0 && (
<div
aria-label={group.label}
aria-label={t(
'projectSwitcher.selector.remoteProjects'
)}
className="project-switcher__group"
key={group.label}
role="group"
>
<strong>{group.label}</strong>
{group.projects.map((project) => {
const selected = project.id === activeProjectId
const ProjectIcon = group.icon
const projectDisplay = getProjectDisplayText(
project,
t
)
const detail =
project.kind === 'channel'
? t('projectSwitcher.selector.remoteDetail', {
channel: project.channel
? projectChannelLabels[project.channel]
: t(
'projectSwitcher.selector.remoteChannel'
),
path: project.rootPath
})
: project.executionSpace.kind === 'ssh'
? t(
'projectSwitcher.selector.managedSshDetail',
{ path: project.rootPath }
)
: t(
'projectSwitcher.selector.localDetail',
{ path: project.rootPath }
)
return (
<div
className="project-switcher__menu-item"
key={project.id}
>
<button
aria-checked={selected}
onClick={() => {
if (
!selected ||
project.executionSpace.kind === 'ssh'
) {
onSelect(project.id)
}
setProjectMenuOpen(false)
requestAnimationFrame(() =>
projectPickerButtonRef.current?.focus()
)
}}
role="menuitemradio"
tabIndex={selected ? 0 : -1}
type="button"
>
<ProjectIcon aria-hidden="true" size={16} />
<span>
<b>{projectDisplay.name}</b>
<small>{detail}</small>
</span>
{selected && (
<Check aria-hidden="true" size={14} />
)}
</button>
<button
<strong>
{t('projectSwitcher.selector.remoteProjects')}
</strong>
{remoteProjectGroups.map(
({ hostId, host, projects: hostProjects }) => {
const hostName =
host?.name ??
t(
'projectSwitcher.selector.unavailableHost'
)
const state =
agentConnectionStatusByHostId[hostId] ??
'disconnected'
return (
<div
aria-label={t(
'projectSwitcher.selector.settingsNamed',
{ name: projectDisplay.name }
'projectSwitcher.selector.remoteHostGroup',
{ host: hostName }
)}
className="project-switcher__menu-settings"
onClick={() =>
openProjectSettings(project, 'picker')
}
role="menuitem"
tabIndex={-1}
type="button"
className="project-switcher__host-group"
key={hostId}
role="group"
>
<Settings aria-hidden="true" size={14} />
</button>
</div>
)
})}
<div className="project-switcher__host-heading">
<span
title={
host
? `${host.username}@${host.hostname}:${host.port}`
: undefined
}
>
<Server aria-hidden="true" size={14} />
<b>{hostName}</b>
</span>
<span
className={`status-badge project-switcher__host-status project-switcher__host-status--${state}`}
>
{t(
`projectSwitcher.selector.connectionStates.${state}`
)}
</span>
</div>
{hostProjects.map((project) =>
renderProjectMenuItem(project, Folder)
)}
</div>
)
}
)}
</div>
) : null
)}
{channelProjects.length > 0 && (
<div
aria-label={t(
'projectSwitcher.selector.channelProjects'
)}
className="project-switcher__group"
role="group"
>
<strong>
{t('projectSwitcher.selector.channelProjects')}
</strong>
{channelProjects.map((project) =>
renderProjectMenuItem(project, RadioTower)
)}
</div>
)}
</div>
)}
</div>
{activeProject &&
activeProjectRecovery?.stage === 'failed' &&
activeProjectRecovery.retryable &&
onRetryRecovery && (
<button
aria-label={t(
'projectSwitcher.recovery.retryNamed',
{ name: activeProjectDisplay?.name }
)}
className="icon-button project-switcher__active-retry"
disabled={
retryingRecoveryProjectId === activeProject.id
}
onClick={() => retryRecovery(activeProject.id)}
title={t('projectSwitcher.recovery.retry')}
type="button"
>
<RefreshCw aria-hidden="true" size={15} />
</button>
)}
<button
aria-label={t('projectSwitcher.selector.create')}
className="icon-button"
@@ -18,13 +18,33 @@ export const workspace = {
channelProjects: 'Remote channels',
empty: 'No project selected',
localDetail: 'Local folder · {{path}}',
managedSshDetail: 'Managed SSH · {{path}}',
remotePath: '{{path}}',
remoteHostGroup: 'SSH host: {{host}}',
unavailableHost: 'Host unavailable',
connectionStates: {
disconnected: 'Disconnected',
connecting: 'Connecting',
ready: 'Ready',
error: 'Error'
},
remoteDetail: '{{channel}} · Remote channel · {{path}}',
remoteChannel: 'Remote channel',
create: 'New project',
settings: 'Project settings',
settingsNamed: 'Manage project {{name}}'
},
recovery: {
stages: {
network: 'Restoring network connection…',
agent: 'Restoring remote Agent…',
runtime: 'Restoring OpenCode Runtime…',
cursor: 'Restoring conversation at event {{current}}',
completed: 'Recovery completed'
},
failed: 'Recovery failed: {{message}}',
retry: 'Retry recovery',
retryNamed: 'Retry recovery for project {{name}}'
},
dialog: {
createTitle: 'New project',
settingsTitle: 'Project settings',
@@ -15,13 +15,33 @@ export const workspace = {
channelProjects: '远程通道',
empty: '未选择项目',
localDetail: '本地目录 · {{path}}',
managedSshDetail: '托管 SSH · {{path}}',
remotePath: '{{path}}',
remoteHostGroup: 'SSH 主机:{{host}}',
unavailableHost: '主机不可用',
connectionStates: {
disconnected: '未连接',
connecting: '连接中',
ready: '就绪',
error: '异常'
},
remoteDetail: '{{channel}} · 远程通道 · {{path}}',
remoteChannel: '远程通道',
create: '新建项目',
settings: '项目设置',
settingsNamed: '管理项目 {{name}}'
},
recovery: {
stages: {
network: '正在恢复网络连接…',
agent: '正在恢复远端 Agent…',
runtime: '正在恢复 OpenCode Runtime…',
cursor: '正在恢复对话,已同步到事件 {{current}}',
completed: '恢复完成'
},
failed: '恢复失败:{{message}}',
retry: '重试恢复',
retryNamed: '重试恢复项目 {{name}}'
},
dialog: {
createTitle: '新建项目',
settingsTitle: '项目设置',
+146
View File
@@ -2413,6 +2413,10 @@ textarea:focus-visible {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.project-switcher__row--with-retry {
grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.project-switcher__picker {
min-width: 0;
}
@@ -2450,6 +2454,17 @@ textarea:focus-visible {
white-space: nowrap;
}
.project-switcher__trigger-copy {
display: grid;
gap: 1px;
}
.project-switcher__trigger-copy > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-switcher__menu {
position: absolute;
z-index: 30;
@@ -2484,6 +2499,76 @@ textarea:focus-visible {
font-weight: 650;
}
.project-switcher__host-group {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.project-switcher__host-group + .project-switcher__host-group {
padding-top: var(--space-1);
border-top: 1px solid var(--border-subtle);
}
.project-switcher__host-heading {
display: flex;
min-width: 0;
min-height: 28px;
align-items: center;
justify-content: space-between;
padding: var(--space-1) var(--space-2);
border-radius: calc(var(--radius-control) - 2px);
background: var(--surface-subtle);
color: var(--text-secondary);
gap: var(--space-2);
}
.project-switcher__host-heading > span:first-child {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.project-switcher__host-heading b {
overflow: hidden;
font-size: var(--font-caption);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-switcher__host-group > .project-switcher__menu-item {
margin-left: var(--space-2);
}
.project-switcher__host-status {
min-height: 18px;
padding: 1px 6px;
flex: 0 0 auto;
line-height: 1.25;
}
.project-switcher__host-status--disconnected {
background: var(--surface-muted);
color: var(--text-muted);
}
.project-switcher__host-status--connecting {
background: var(--accent-subtle);
color: var(--accent);
}
.project-switcher__host-status--ready {
background: var(--success-subtle);
color: var(--success);
}
.project-switcher__host-status--error {
background: var(--danger-subtle);
color: var(--danger);
}
.project-switcher__menu-item {
position: relative;
min-width: 0;
@@ -2517,6 +2602,11 @@ textarea:focus-visible {
gap: 2px;
}
.project-switcher__menu-item--with-retry
> [role="menuitemradio"] {
padding-right: 76px;
}
.project-switcher__menu-item > [role="menuitemradio"] b,
.project-switcher__menu-item > [role="menuitemradio"] small {
overflow: hidden;
@@ -2555,6 +2645,62 @@ textarea:focus-visible {
color var(--motion-fast) ease-out;
}
.project-switcher__menu-retry {
position: absolute;
top: 50%;
right: 36px;
display: grid;
width: 32px;
height: 32px;
padding: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--danger);
place-items: center;
transform: translateY(-50%);
}
.project-switcher__menu-retry:hover,
.project-switcher__menu-retry:focus-visible {
background: var(--danger-subtle);
}
.project-switcher__active-retry {
color: var(--danger);
}
.project-recovery-status {
display: flex;
min-width: 0;
align-items: center;
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 500;
gap: var(--space-1);
line-height: 1.25;
}
.project-recovery-status > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-recovery-status--completed {
color: var(--success);
}
.project-recovery-status--failed {
color: var(--danger);
}
.project-recovery-status--network > svg,
.project-recovery-status--agent > svg,
.project-recovery-status--runtime > svg,
.project-recovery-status--cursor > svg {
animation: ssh-host-dialog-spin 900ms linear infinite;
}
.project-switcher__menu-item:hover
.project-switcher__menu-settings,
.project-switcher__menu-item:focus-within