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:
Jinjing
2026-10-22 23:00:37 -07:00
committed by GitHub
parent 52e413ec85
commit afe62effd3
8 changed files with 329 additions and 4 deletions
@@ -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
+8
View File
@@ -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
}