mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(workspace): show launch actions in empty tab groups (#32182)
* fix(workspace): show launch actions in empty tab groups * fix(workspace): resolve empty-pane browser policy by owner
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
|
||||
import { AgentTerminalLaunchButton } from './agent/AgentTerminalLaunchButton'
|
||||
import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { requestNativeChatComposerFocus } from './native-chat/use-native-chat-composer-reveal-focus'
|
||||
import { useTabAgentMenuLaunch } from './tab-bar/QuickLaunchButton'
|
||||
import { useNewTabAgentMenuRows } from './tab-bar/use-new-tab-agent-menu-rows'
|
||||
import {
|
||||
chatUnavailableLabel,
|
||||
rowId,
|
||||
rowLabel,
|
||||
rowTitle
|
||||
} from './tab-bar/tab-agent-row-presentation'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
export function WorkspaceEmptyAgentList({
|
||||
worktreeId,
|
||||
groupId
|
||||
}: {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
}): React.JSX.Element {
|
||||
const target = useAgentDetectionTargetForWorktree(worktreeId)
|
||||
const model = useNewTabAgentMenuRows(worktreeId, true, target)
|
||||
const launch = useTabAgentMenuLaunch({
|
||||
worktreeId,
|
||||
groupId,
|
||||
onFocusTerminal: focusTerminalTabSurface,
|
||||
onFocusChat: requestNativeChatComposerFocus
|
||||
})
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const primaryRow =
|
||||
model.state === 'settled'
|
||||
? model.rows.find((row) => row.kind !== 'chat-unavailable')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
{translate('components.WorkspaceEmptyState.startAgent', 'Start an agent')}
|
||||
</h3>
|
||||
{model.state === 'loading' ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{translate('auto.components.tab.bar.QuickLaunchButton.agentsLoading', 'Loading agents')}
|
||||
</p>
|
||||
) : model.rows.length === 0 ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{model.detection.needsServerUpdate
|
||||
? translate(
|
||||
'auto.components.tab.bar.QuickLaunchButton.needsServerUpdate',
|
||||
'Update Orca on this server to list this workspace’s agents'
|
||||
)
|
||||
: model.detection.detectionFailed
|
||||
? translate(
|
||||
'auto.components.tab.bar.QuickLaunchButton.hostUnreachable',
|
||||
'Can’t reach this workspace’s host to list its agents'
|
||||
)
|
||||
: model.detection.detectedIds?.length
|
||||
? translate(
|
||||
'auto.components.tab.bar.QuickLaunchButton.8dea9b5cdf',
|
||||
'No enabled agents'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.tab.bar.QuickLaunchButton.e518f544b1',
|
||||
'No agents detected'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 @sm:grid-cols-2">
|
||||
{model.rows.map((row) => (
|
||||
<div key={rowId(row)} className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={row === primaryRow ? 'default' : 'outline'}
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
disabled={row.kind === 'chat-unavailable'}
|
||||
aria-label={
|
||||
row.kind === 'chat-unavailable'
|
||||
? getAgentLabel(row.agent)
|
||||
: rowTitle(row, getAgentLabel(row.agent))
|
||||
}
|
||||
onClick={() => launch(row)}
|
||||
>
|
||||
<AgentIcon agent={row.agent} />
|
||||
<span className="truncate">{rowLabel(row)}</span>
|
||||
</Button>
|
||||
{row.kind === 'chat' ? (
|
||||
<AgentTerminalLaunchButton
|
||||
tabIndex={0}
|
||||
title={rowTitle({ ...row, kind: 'cli' }, getAgentLabel(row.agent))}
|
||||
className="my-0 size-9 opacity-100 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => launch({ ...row, kind: 'cli' })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{row.kind === 'chat-unavailable' ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{chatUnavailableLabel(row.reason)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
openSettingsTarget({ pane: 'agents', repoId: null })
|
||||
openSettingsPage()
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.tab.bar.QuickLaunchButton.348a04c1ad', 'Agent settings…')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { FileText, Globe, Terminal } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getWorkspaceClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
|
||||
import { showClientCreationActionError } from '@/lib/client-creation-action-error'
|
||||
import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { WorkspaceEmptyAgentList } from './WorkspaceEmptyAgentList'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
export function WorkspaceEmptyState({
|
||||
worktreeId,
|
||||
groupId
|
||||
}: {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
}): React.JSX.Element {
|
||||
const newTerminal = useAppStore((state) => state.openNewTerminalTabInActiveWorkspace)
|
||||
const newBrowser = useAppStore((state) => state.openNewBrowserTabInActiveWorkspace)
|
||||
const openModal = useAppStore((state) => state.openModal)
|
||||
const focusGroup = useAppStore((state) => state.focusGroup)
|
||||
const browserEnabled = useAppStore(
|
||||
(state) =>
|
||||
getWorkspaceClientCreationActionPolicy(state, worktreeId)['managed-browser'].state ===
|
||||
'enabled'
|
||||
)
|
||||
const openFile = async (): Promise<void> => {
|
||||
focusGroup(worktreeId, groupId)
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
const document = await window.api.app.pickFloatingMarkdownDocument()
|
||||
if (document) {
|
||||
openDocumentInFloatingWorkspace(useAppStore.getState().openFile, document, {
|
||||
targetGroupId: groupId
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
openModal('quick-open')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-1 items-center justify-center overflow-y-auto bg-background p-6 scrollbar-sleek">
|
||||
<div className="@container my-auto w-full max-w-xl py-8">
|
||||
<h2 className="text-lg font-medium text-foreground">
|
||||
{translate('components.WorkspaceEmptyState.title', 'What would you like to do?')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'components.WorkspaceEmptyState.description',
|
||||
'No tabs are open here. Start an agent or open a tool to get going.'
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-6 space-y-1 border-b border-border pb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="w-full justify-start"
|
||||
onClick={() => void openFile().catch(showClientCreationActionError)}
|
||||
>
|
||||
<FileText className="text-muted-foreground" />
|
||||
{translate('components.WorkspaceEmptyState.openFile', 'Open a file')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="w-full justify-start"
|
||||
onClick={() => void newTerminal(groupId).catch(showClientCreationActionError)}
|
||||
>
|
||||
<Terminal className="text-muted-foreground" />
|
||||
{translate('components.WorkspaceEmptyState.terminal', 'Open a terminal')}
|
||||
</Button>
|
||||
{browserEnabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="w-full justify-start"
|
||||
onClick={() => void newBrowser(groupId).catch(showClientCreationActionError)}
|
||||
>
|
||||
<Globe className="text-muted-foreground" />
|
||||
{translate('components.WorkspaceEmptyState.browser', 'Open a browser')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<WorkspaceEmptyAgentList worktreeId={worktreeId} groupId={groupId} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ vi.mock('@/store', async () => {
|
||||
return { useAppStore }
|
||||
})
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: (agent: string) => (agent === 'codex' ? 'Codex' : 'Claude'),
|
||||
getAgentCatalog: () => [
|
||||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' }
|
||||
@@ -67,6 +68,7 @@ vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: launchMoc
|
||||
type DivProps = { disabled?: boolean; title?: string; onSelect?: () => void }
|
||||
|
||||
import { QuickLaunchAgentMenuItems, ProjectedAgentMenuItems } from './QuickLaunchButton'
|
||||
import { WorkspaceEmptyAgentList } from '../WorkspaceEmptyAgentList'
|
||||
import { useTabBarCreateMenuController } from './use-tab-bar-create-menu-controller'
|
||||
import * as menuRows from './use-new-tab-agent-menu-rows'
|
||||
import {
|
||||
@@ -76,6 +78,43 @@ import {
|
||||
|
||||
const WORKTREE_ID = 'worktree-1'
|
||||
|
||||
it('offers separate chat and CLI launches on the empty workspace with the same owning host', () => {
|
||||
const model = vi.spyOn(menuRows, 'useNewTabAgentMenuRows').mockReturnValueOnce({
|
||||
state: 'settled',
|
||||
rows: [{ kind: 'chat', agent: 'codex', executionHostId: 'runtime:server' }],
|
||||
detection: {
|
||||
detectedIds: ['codex'],
|
||||
detectionFailed: false,
|
||||
needsServerUpdate: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
refresh: async () => ['codex']
|
||||
}
|
||||
})
|
||||
launchMock.mockReset().mockReturnValue({ surface: { kind: 'host-published' } })
|
||||
const view = render(<WorkspaceEmptyAgentList worktreeId={WORKTREE_ID} groupId="group-1" />)
|
||||
model.mockRestore()
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Launch Codex in a new terminal' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Start a chat with Codex' }))
|
||||
|
||||
expect(launchMock.mock.calls.map(([args]) => args)).toEqual([
|
||||
expect.objectContaining({
|
||||
requestedSurface: 'terminal',
|
||||
selectedExecutionHostId: 'runtime:server',
|
||||
worktreeId: WORKTREE_ID,
|
||||
groupId: 'group-1'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
requestedSurface: 'chat',
|
||||
selectedExecutionHostId: 'runtime:server',
|
||||
worktreeId: WORKTREE_ID,
|
||||
groupId: 'group-1'
|
||||
})
|
||||
])
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
it('gives static/search no agent actions while loading, retaining settings and generic terminal', () => {
|
||||
const model = vi
|
||||
.spyOn(menuRows, 'useNewTabAgentMenuRows')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { useAppStore } from '../../store'
|
||||
import TabBar from '../tab-bar/TabBar'
|
||||
import { WorkspaceEmptyState } from '../WorkspaceEmptyState'
|
||||
|
||||
import { TabBarQuickCommandsButton } from '../tab-bar/TabBarQuickCommandsButton'
|
||||
import { TabGroupPaneActionsMenu } from './TabGroupPaneActionsMenu'
|
||||
@@ -329,6 +330,12 @@ export default function TabGroupPanel({
|
||||
className="relative flex-1 min-h-0 overflow-hidden"
|
||||
style={bodyAnchorStyle}
|
||||
>
|
||||
{isVisible &&
|
||||
!activeTab &&
|
||||
model.groupTabs.length === 0 &&
|
||||
clientHostedRows.length === 0 ? (
|
||||
<WorkspaceEmptyState worktreeId={worktreeId} groupId={groupId} />
|
||||
) : null}
|
||||
{/* Why: empty anchor so the agent-sessions tour reads as a terminal-area tip, not toolbar chrome. */}
|
||||
{isFocused ? (
|
||||
<div
|
||||
|
||||
@@ -18718,6 +18718,14 @@
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"WorkspaceEmptyState": {
|
||||
"title": "What would you like to do?",
|
||||
"description": "No tabs are open here. Start an agent or open a tool to get going.",
|
||||
"startAgent": "Start an agent",
|
||||
"terminal": "Open a terminal",
|
||||
"browser": "Open a browser",
|
||||
"openFile": "Open a file"
|
||||
},
|
||||
"onboarding": {
|
||||
"flow": {
|
||||
"stepTooltip": {
|
||||
|
||||
@@ -72,13 +72,29 @@ export function resolveClientCreationActionPolicy(args: {
|
||||
export function getClientCreationActionPolicy(
|
||||
state: AppState,
|
||||
worktreeId: string | null
|
||||
): ClientCreationActionPolicy {
|
||||
if (worktreeId) {
|
||||
return getWorkspaceClientCreationActionPolicy(state, worktreeId)
|
||||
}
|
||||
return creationPolicyForRuntime(state, state.settings?.activeRuntimeEnvironmentId?.trim() ?? null)
|
||||
}
|
||||
|
||||
export function getWorkspaceClientCreationActionPolicy(
|
||||
state: AppState,
|
||||
worktreeId: string
|
||||
): ClientCreationActionPolicy {
|
||||
const floatingWorkspace = worktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
const runtimeEnvironmentId = floatingWorkspace
|
||||
? null
|
||||
: worktreeId
|
||||
? runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId)
|
||||
: (state.settings?.activeRuntimeEnvironmentId?.trim() ?? null)
|
||||
: runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId)
|
||||
return creationPolicyForRuntime(state, runtimeEnvironmentId, floatingWorkspace)
|
||||
}
|
||||
|
||||
function creationPolicyForRuntime(
|
||||
state: AppState,
|
||||
runtimeEnvironmentId: string | null,
|
||||
floatingWorkspace = false
|
||||
): ClientCreationActionPolicy {
|
||||
const runtimeStatus = runtimeEnvironmentId
|
||||
? (state.runtimeStatusByEnvironmentId?.get(runtimeEnvironmentId)?.status ?? null)
|
||||
: null
|
||||
|
||||
@@ -204,6 +204,50 @@ describe('Cmd+J lifted creation actions', () => {
|
||||
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a terminal in the invoking group’s workspace when another workspace is selected', async () => {
|
||||
const store = createTestStore()
|
||||
seedActiveWorkspace(store)
|
||||
store.setState({ activeWorktreeId: 'another-workspace' })
|
||||
|
||||
await store.getState().openNewTerminalTabInActiveWorkspace('group-1')
|
||||
|
||||
expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'runtime-1',
|
||||
targetGroupId: 'group-1',
|
||||
activate: true
|
||||
})
|
||||
expect(store.getState().tabsByWorktree['another-workspace'] ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a floating terminal locally while a paired workspace is selected', async () => {
|
||||
const store = createTestStore()
|
||||
seedActiveWorkspace(store)
|
||||
store.setState({
|
||||
groupsByWorktree: {
|
||||
...store.getState().groupsByWorktree,
|
||||
[FLOATING_TERMINAL_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'floating-group',
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
await store.getState().openNewTerminalTabInActiveWorkspace('floating-group')
|
||||
|
||||
expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled()
|
||||
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([])
|
||||
const tabs = store.getState().tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(store.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual([
|
||||
expect.objectContaining({ entityId: tabs[0].id, groupId: 'floating-group' })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not create a local folder terminal while paired ownership is unresolved', async () => {
|
||||
const folderId = 'folder-1'
|
||||
const workspaceKey = folderWorkspaceKey(folderId)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner'
|
||||
import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { findGroupAndWorktree } from '../slices/tab-group-state'
|
||||
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
|
||||
|
||||
export function createActiveWorkspaceTerminalActions(
|
||||
@@ -13,7 +14,8 @@ export function createActiveWorkspaceTerminalActions(
|
||||
return {
|
||||
openNewTerminalTabInActiveWorkspace: async (groupId) => {
|
||||
const state = get()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
const worktreeId =
|
||||
findGroupAndWorktree(state.groupsByWorktree, groupId)?.worktreeId ?? state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user