From afe62effd3aadd1e4606e76eec94a453d2ef5e5f Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 22 Oct 2026 23:00:37 -0700
Subject: [PATCH] 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
---
.../components/WorkspaceEmptyAgentList.tsx | 121 ++++++++++++++++++
.../src/components/WorkspaceEmptyState.tsx | 88 +++++++++++++
.../QuickLaunchButton.launch-status.test.tsx | 39 ++++++
.../components/tab-group/TabGroupPanel.tsx | 7 +
src/renderer/src/i18n/locales/en.json | 8 ++
.../src/lib/client-creation-action-policy.ts | 22 +++-
.../store/slices/cmd-j-create-actions.test.ts | 44 +++++++
.../terminal-active-workspace-creation.ts | 4 +-
8 files changed, 329 insertions(+), 4 deletions(-)
create mode 100644 src/renderer/src/components/WorkspaceEmptyAgentList.tsx
create mode 100644 src/renderer/src/components/WorkspaceEmptyState.tsx
diff --git a/src/renderer/src/components/WorkspaceEmptyAgentList.tsx b/src/renderer/src/components/WorkspaceEmptyAgentList.tsx
new file mode 100644
index 00000000000..61acbaadbd2
--- /dev/null
+++ b/src/renderer/src/components/WorkspaceEmptyAgentList.tsx
@@ -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 (
+
+
+ {translate('components.WorkspaceEmptyState.startAgent', 'Start an agent')}
+
+ {model.state === 'loading' ? (
+
+ {translate('auto.components.tab.bar.QuickLaunchButton.agentsLoading', 'Loading agents')}
+
+ ) : model.rows.length === 0 ? (
+
+ {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'
+ )}
+
+ ) : (
+
+ {model.rows.map((row) => (
+
+
+
+ {row.kind === 'chat' ? (
+
launch({ ...row, kind: 'cli' })}
+ />
+ ) : null}
+
+ {row.kind === 'chat-unavailable' ? (
+
+ {chatUnavailableLabel(row.reason)}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/WorkspaceEmptyState.tsx b/src/renderer/src/components/WorkspaceEmptyState.tsx
new file mode 100644
index 00000000000..053ad0a2ecf
--- /dev/null
+++ b/src/renderer/src/components/WorkspaceEmptyState.tsx
@@ -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 => {
+ 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 (
+
+
+
+ {translate('components.WorkspaceEmptyState.title', 'What would you like to do?')}
+
+
+ {translate(
+ 'components.WorkspaceEmptyState.description',
+ 'No tabs are open here. Start an agent or open a tool to get going.'
+ )}
+
+
+
+
+ {browserEnabled ? (
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
index 9352e9b27c6..c775a5766d2 100644
--- a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
+++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
@@ -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()
+ 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')
diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
index 6e623c14482..1de05feda10 100644
--- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx
+++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
@@ -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 ? (
+
+ ) : null}
{/* Why: empty anchor so the agent-sessions tour reads as a terminal-area tip, not toolbar chrome. */}
{isFocused ? (
{
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)
diff --git a/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts
index c1636f3cd70..ffce04170b0 100644
--- a/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts
+++ b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts
@@ -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
}