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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '项目设置',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user