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 }