diff --git a/docs/design/web-shell/web-shell-new-task-workspace-scope.md b/docs/design/web-shell/web-shell-new-task-workspace-scope.md new file mode 100644 index 00000000000..fd4ba13af25 --- /dev/null +++ b/docs/design/web-shell/web-shell-new-task-workspace-scope.md @@ -0,0 +1,88 @@ +# Web Shell New task workspace scope + +## Problem + +The sidebar's global **New task** action currently sends an unscoped creation +intent. On daemons that support standalone sessions, that always starts a +standalone draft even when trusted workspaces are available. Creating a task in +a particular workspace is possible only from that workspace's row, which is +easy to miss and makes the primary action inconsistent with the project-first +sidebar. + +## Behavior + +The global action becomes a split button when more than one usable scope is +available: + +- The main button starts a task in the current trusted workspace. If the + current context is standalone or has no trusted workspace, it falls back to + the trusted primary workspace. +- The adjacent menu starts a task in any trusted, non-Live workspace. +- **No workspace** is an explicit menu action only when the daemon advertises + standalone sessions. +- Untrusted and Live workspaces are never creation targets. + +With only one usable scope, the control remains the existing single button. +Locked-workspace embeds therefore continue to create only in their locked +workspace, and older daemons without standalone support continue to create in +the primary workspace. In the collapsed sidebar, the compact icon keeps the +default one-click action; expanding the sidebar exposes the scope menu. + +The empty-state composer mirrors the same scope choice in its workspace menu. +When standalone sessions are supported, **No workspace** appears alongside the +trusted workspace choices even when only one workspace is registered. Choosing +it switches the unsubmitted draft to standalone without creating a session; +the first prompt performs the existing lazy standalone creation. The selector +stays visible on that standalone draft so the user can see the active scope and +switch back to a workspace before sending. Locked-workspace embeds and settled +standalone sessions do not expose this retargeting control. + +Selecting a menu item starts the draft immediately. The menu does not introduce +a second persistent workspace-selection state: `App` remains the authority for +the effective session context, while the sidebar only forwards the selected +cwd (or the existing unscoped value for standalone). + +## Failure and trust boundaries + +The existing new-session re-entry guard, busy state, error reporting, and +catalog invalidation remain shared by the main button and every menu action. +Workspace catalog refreshes stay cwd-qualified. The standalone action keeps +passing no cwd, so it does not refresh the primary workspace catalog. + +## Workspace management from a standalone task + +Choosing **No workspace** keeps the conversation standalone, but it does not +remove the daemon's workspace administration entry points: + +- **Plugins** remains visible when a trusted primary workspace exists. Opening + it manages that primary workspace and labels the target explicitly. It does + not change the current conversation context. Project skills can be managed, + but cannot be inserted directly into the standalone composer. +- **Scheduled Tasks** remains visible and keeps its existing trusted-workspace + aggregation and workspace picker. Opening the page does not change the + current conversation context. Bound runs and history navigation carry the + task's explicit workspace back to the session loader. A legacy unbound task + fails closed outside its owning workspace instead of running in the + standalone conversation. + +Channels, Goals, Git, worktrees, workspace settings, and workspace-scoped slash +commands remain unavailable without an active workspace conversation. Live +contexts also keep all workspace-management entries hidden. If there is no +trusted primary workspace, the standalone management entries stay hidden +instead of silently targeting an untrusted workspace. + +## Verification + +- Component tests cover the trusted-current default, primary fallback, + explicit workspace and standalone menu actions, filtering, catalog refresh, + legacy capability behavior, locked-workspace behavior, and the composer's + **No workspace** scope. +- Standalone Playwright coverage explicitly chooses **No workspace** from the + empty-state composer and checks the exact standalone route. +- Workspace Playwright coverage clicks the main action and checks the exact + workspace cwd sent to session creation. +- Manual cold-start verification switches among workspace and standalone + conversations and confirms the sidebar remains usable after each send. +- Standalone navigation tests confirm Plugins and Scheduled Tasks stay visible, + display their workspace scope, preserve `context=standalone`, and issue no + session-creation request merely by opening a management page. diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index bf66b25abc4..2e9c4e7d253 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -151,7 +151,10 @@ type ChatEditorTestProps = { composerScopeKey?: string; workspaceFeaturesEnabled?: boolean; selectedWorkspaceCwd?: string; + noWorkspaceSupported?: boolean; + noWorkspaceSelected?: boolean; onSelectWorkspace?: (cwd: string | undefined) => void; + onSelectNoWorkspace?: () => void; onCreateScratchWorkspace?: () => void; onOpenExistingWorkspace?: () => void; scratchWorkspaceSupported?: boolean; @@ -565,8 +568,10 @@ const { onRunPrompt?: ( prompt: string, sessionId: string | null, + workspaceCwd?: string, ) => Promise; onCreateViaChat?: () => void; + onOpenSession?: (sessionId: string, workspaceCwd?: string) => void; workspaces?: Array<{ id: string; cwd: string }>; lockedWorkspace?: { id: string; cwd: string; primary: boolean }; currentSession?: { @@ -1165,6 +1170,7 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { collapsed?: boolean; onOpenPlugins?: () => void; onOpenChannels?: () => void; + onOpenScheduledTasks?: () => void; onOpenDaemonStatus?: () => void; onOpenSessions?: () => void; onOpenSplitView?: () => void; @@ -1182,6 +1188,8 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { onOpenAddWorkspace?: () => void; onThemeChange?: (theme: 'light' | 'dark') => void; showSessionSourceSwitch?: boolean; + projectFeaturesEnabled?: boolean; + workspaceManagementEnabled?: boolean; }) => { // Expose the Daemon Status / Session Overview openers so tests can // exercise those activePanel branches (neither has a slash command). @@ -1193,6 +1201,12 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { 'data-show-session-source-switch': String( props.showSessionSourceSwitch, ), + 'data-project-features-enabled': String( + Boolean(props.projectFeaturesEnabled), + ), + 'data-workspace-management-enabled': String( + Boolean(props.workspaceManagementEnabled), + ), }, React.createElement( 'button', @@ -1306,15 +1320,28 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { }, 'delete session', ), - React.createElement( - 'button', - { - 'data-testid': 'open-plugins', - type: 'button', - onClick: props.onOpenPlugins, - }, - 'plugins', - ), + props.workspaceManagementEnabled + ? React.createElement( + 'button', + { + 'data-testid': 'open-plugins', + type: 'button', + onClick: props.onOpenPlugins, + }, + 'plugins', + ) + : null, + props.workspaceManagementEnabled + ? React.createElement( + 'button', + { + 'data-testid': 'open-scheduled-tasks', + type: 'button', + onClick: props.onOpenScheduledTasks, + }, + 'scheduled tasks', + ) + : null, React.createElement( 'button', { @@ -12091,6 +12118,85 @@ describe('App session callbacks', () => { }); }); + it('creates a standalone first prompt after the composer selects No workspace', async () => { + mockConnection.sessionId = undefined; + mockConnection.sessionContext = { + kind: 'workspace', + cwd: '/tmp/project', + }; + mockConnection.workspaceCwd = '/tmp/project'; + mockWorkspace.capabilities = { + features: ['standalone_sessions_v1'], + workspaces: [ + { + id: 'primary', + cwd: '/tmp/project', + primary: true, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + renderApp(); + await flush(); + + expect(testState.latestChatEditorProps).toMatchObject({ + noWorkspaceSupported: true, + noWorkspaceSelected: false, + workspaceFeaturesEnabled: true, + }); + expect(testState.latestChatEditorProps?.onSelectNoWorkspace).toEqual( + expect.any(Function), + ); + + await act(async () => { + testState.latestChatEditorProps?.onSelectNoWorkspace?.(); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalled(); + }); + }); + await flush(); + + expect(testState.latestChatEditorProps).toMatchObject({ + composerScopeKey: 'standalone', + noWorkspaceSupported: true, + noWorkspaceSelected: true, + workspaceFeaturesEnabled: false, + }); + expect(testState.latestChatEditorProps?.workspaces).toEqual([ + expect.objectContaining({ id: 'primary', cwd: '/tmp/project' }), + ]); + + act(() => { + testState.latestChatEditorProps?.onSelectWorkspace?.(undefined); + }); + await flush(); + expect(testState.latestChatEditorProps).toMatchObject({ + noWorkspaceSelected: false, + workspaceFeaturesEnabled: true, + }); + + await act(async () => { + testState.latestChatEditorProps?.onSelectNoWorkspace?.(); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); + }); + }); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('standalone prompt'); + await vi.waitFor(() => { + expect(mockSessionActions.createSession).toHaveBeenCalled(); + }); + }); + const createOptions = + mockSessionActions.createSession.mock.calls.at(-1)?.[0]; + expect(createOptions).toMatchObject({ + sessionContext: { kind: 'standalone' }, + }); + expect(createOptions?.workspaceCwd).toBeUndefined(); + }); + it('creates the first prompt in a newly selected secondary workspace', async () => { mockConnection.sessionId = undefined; mockConnection.sessionContext = { kind: 'workspace', cwd: '/tmp/project' }; @@ -25194,6 +25300,157 @@ describe('App session callbacks', () => { expect(onToast).toHaveBeenCalledWith('warning', expect.any(String)); }); + it('keeps explicit workspace management available without changing a standalone chat', async () => { + mockConnection.sessionContext = { kind: 'standalone' }; + mockConnection.workspaceCwd = ''; + mockWorkspace.capabilities = { + features: ['standalone_sessions_v1', 'scheduled_task_session_reuse'], + workspaces: [ + { + id: 'primary', + cwd: '/workspace', + displayName: 'Primary project', + primary: true, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + const { container } = renderApp(); + await flush(); + + const sidebar = container.querySelector('[data-testid="sidebar"]'); + expect(sidebar?.getAttribute('data-project-features-enabled')).toBe( + 'false', + ); + expect(sidebar?.getAttribute('data-workspace-management-enabled')).toBe( + 'true', + ); + + await act(async () => { + container + .querySelector('[data-testid="open-plugins"]') + ?.click(); + await Promise.resolve(); + }); + await flush(); + + expect( + container + .querySelector('[data-testid="inline-panel"]') + ?.getAttribute('aria-label'), + ).toBe('Plugins'); + const pluginScope = container.querySelector( + '[data-testid="plugin-management-workspace"]', + ); + expect(pluginScope?.textContent).toBe('Workspace: Primary project'); + expect(pluginScope?.getAttribute('title')).toBe('/workspace'); + + await act(async () => { + container + .querySelector( + '[data-testid="open-scheduled-tasks"]', + ) + ?.click(); + await Promise.resolve(); + }); + await flush(); + + expect( + container.querySelector('[data-testid="scheduled-tasks-page"]'), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="scheduled-tasks-management-workspace"]', + )?.textContent, + ).toBe('Workspace: Primary project'); + expect(testState.latestScheduledTasksProps?.workspaces).toEqual([ + expect.objectContaining({ id: 'primary', cwd: '/workspace' }), + ]); + expect(testState.latestScheduledTasksProps?.currentSession).toBeUndefined(); + expect( + testState.latestScheduledTasksProps?.currentSessionSchedulingAvailable, + ).toBe(false); + const runPrompt = testState.latestScheduledTasksProps?.onRunPrompt; + if (!runPrompt) throw new Error('onRunPrompt was not captured'); + await act(async () => { + await expect( + runPrompt('legacy task', null, '/workspace'), + ).rejects.toThrow(/no bound workspace session/); + }); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + act(() => { + testState.latestScheduledTasksProps?.onOpenSession?.( + 'task-session', + '/workspace', + ); + }); + await flush(); + expect(mockSessionActions.loadSession).toHaveBeenCalledWith( + 'task-session', + { + workspaceCwd: '/workspace', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, + }, + ); + expect(mockSessionActions.clearSession).not.toHaveBeenCalled(); + expect(mockSessionActions.createSession).not.toHaveBeenCalled(); + expect(mockConnection.sessionContext).toEqual({ kind: 'standalone' }); + }); + + it('hides standalone workspace management without a trusted primary workspace', async () => { + mockConnection.sessionContext = { kind: 'standalone' }; + mockConnection.workspaceCwd = ''; + mockWorkspace.capabilities = { + features: ['standalone_sessions_v1'], + workspaces: [ + { + id: 'primary', + cwd: '/workspace', + primary: true, + trusted: false, + }, + ], + } as typeof mockWorkspace.capabilities; + const { container } = renderApp(); + await flush(); + + const sidebar = container.querySelector('[data-testid="sidebar"]'); + expect(sidebar?.getAttribute('data-workspace-management-enabled')).toBe( + 'false', + ); + expect(container.querySelector('[data-testid="open-plugins"]')).toBeNull(); + expect( + container.querySelector('[data-testid="open-scheduled-tasks"]'), + ).toBeNull(); + }); + + it('keeps workspace management hidden in a Live context', async () => { + mockConnection.sessionContext = { kind: 'live' }; + mockConnection.workspaceCwd = ''; + mockWorkspace.capabilities = { + workspaces: [ + { + id: 'primary', + cwd: '/workspace', + primary: true, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + const { container } = renderApp(); + await flush(); + + const sidebar = container.querySelector('[data-testid="sidebar"]'); + expect(sidebar?.getAttribute('data-workspace-management-enabled')).toBe( + 'false', + ); + expect(container.querySelector('[data-testid="open-plugins"]')).toBeNull(); + expect( + container.querySelector('[data-testid="open-scheduled-tasks"]'), + ).toBeNull(); + }); + it('does not dispatch workspace management commands in a standalone chat', async () => { mockConnection.sessionContext = { kind: 'standalone' }; mockConnection.workspaceCwd = ''; @@ -25222,6 +25479,7 @@ describe('App session callbacks', () => { '/tools', '/agents', '/extensions manage', + '/schedule', '/goal', '/model --vision qwen-vl', '/resume', @@ -25250,7 +25508,7 @@ describe('App session callbacks', () => { expect(mockWorkspaceActions.loadEnv).not.toHaveBeenCalled(); expect(settingsSetValue).not.toHaveBeenCalled(); expect(mockSessionActions.loadSession).not.toHaveBeenCalled(); - expect(onToast).toHaveBeenCalledTimes(10); + expect(onToast).toHaveBeenCalledTimes(11); }); it('allows ordinary shell commands in a standalone chat', async () => { @@ -28632,7 +28890,13 @@ describe('App manual-run orchestration (scheduled tasks)', () => { // Opening the page with /schedule mounts the dialog and captures the handler. async function openRunHandler( container: HTMLElement, - ): Promise<(prompt: string, sessionId: string | null) => Promise> { + ): Promise< + ( + prompt: string, + sessionId: string | null, + workspaceCwd?: string, + ) => Promise + > { mockConnection.goalState ??= { v: 2, activity: 'idle', @@ -28772,10 +29036,13 @@ describe('App manual-run orchestration (scheduled tasks)', () => { // session-1 is the current, fully-loaded session, so tryFireBoundRun fires // right after loadSidebarSession without waiting on a dep-change effect. await act(async () => { - await expect(run('do the thing', 'session-1')).resolves.toBeUndefined(); + await expect( + run('do the thing', 'session-1', '/workspace'), + ).resolves.toBeUndefined(); }); expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1', { - workspaceCwd: undefined, + workspaceCwd: '/workspace', + sessionContext: { kind: 'workspace', cwd: '/workspace' }, }); }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 51cadef41d4..89bc29f5b57 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2522,13 +2522,14 @@ export function App({ () => workspaces.filter((entry) => entry.kind !== 'live'), [workspaces], ); - const trustedPrimaryWorkspaceCwd = useMemo( + const trustedPrimaryWorkspace = useMemo( () => ordinaryWorkspaces.find( (entry) => entry.primary && entry.trusted !== false, - )?.cwd, + ), [ordinaryWorkspaces], ); + const trustedPrimaryWorkspaceCwd = trustedPrimaryWorkspace?.cwd; const isKnownLiveWorkspaceCwd = useCallback( (cwd: string | undefined) => cwd !== undefined && @@ -2711,6 +2712,17 @@ export function App({ [pendingSessionContext, settledSessionContext], ); const workspaceContextActive = effectiveSessionContext?.kind === 'workspace'; + const composerWorkspaceSelectionActive = + workspaceContextActive || + (effectiveSessionContext?.kind === 'standalone' && !connection.sessionId); + const projectNavigationActive = + workspaceContextActive || effectiveSessionContext?.kind === 'standalone'; + const standaloneManagementWorkspace = + effectiveSessionContext?.kind === 'standalone' + ? trustedPrimaryWorkspace + : undefined; + const workspaceManagementActive = + workspaceContextActive || standaloneManagementWorkspace !== undefined; const pendingNonWorkspaceNavigation = pendingSessionContext !== undefined && pendingSessionContext.kind !== 'workspace' && @@ -5794,7 +5806,7 @@ export function App({ activePanel === 'mcp' || activePanel === 'skills' || activePanel === 'agents' || - activePanel === 'plugins' || + (activePanel === 'plugins' && !workspaceManagementActive) || activePanel === 'channels' ) { setActivePanel(null); @@ -5810,14 +5822,20 @@ export function App({ } setShowFallbacksDialog(false); if ( - mainView === 'scheduledTasks' || + (mainView === 'scheduledTasks' && !workspaceManagementActive) || mainView === 'goals' || mainView === 'cockpit' || mainView === 'split' ) { setMainView('chat'); } - }, [activePanel, mainView, modelDialogMode, workspaceContextActive]); + }, [ + activePanel, + mainView, + modelDialogMode, + workspaceContextActive, + workspaceManagementActive, + ]); const handleUseSkill = useCallback( (name: string) => { closePanel(); @@ -5866,6 +5884,7 @@ export function App({ }); }, [workspaceActions]); const openScheduledTasks = useCallback(() => { + if (!workspaceManagementActive) return; splitClassificationGenerationRef.current += 1; setActivePanel(null); // Route through showChat so leaving the cockpit strips its ?view=cockpit @@ -5874,7 +5893,7 @@ export function App({ // Forward navigation instead of the view actually on screen. showChat(); setMainView('scheduledTasks'); - }, [showChat]); + }, [showChat, workspaceManagementActive]); const openGoals = useCallback(() => { splitClassificationGenerationRef.current += 1; setActivePanel(null); @@ -9782,6 +9801,9 @@ export function App({ }, [switchWorkspace], ); + const handleSelectComposerNoWorkspace = useCallback(() => { + void createNewSession({ kind: 'global' }); + }, [createNewSession]); const handleCreateComposerScratchWorkspace = useCallback(() => { void handleCreateScratchWorkspace(); }, [handleCreateScratchWorkspace]); @@ -10511,9 +10533,22 @@ export function App({ ); }, [clearPendingBoundRun, enqueueManualRun]); const runTaskManually = useCallback( - (prompt: string, sessionId: string | null): Promise => { + ( + prompt: string, + sessionId: string | null, + workspaceCwd?: string, + ): Promise => { showChat(); if (!sessionId) { + if ( + workspaceCwd && + (effectiveSessionContext?.kind !== 'workspace' || + effectiveSessionContext.cwd !== workspaceCwd) + ) { + return Promise.reject( + new Error('The scheduled task has no bound workspace session.'), + ); + } // Unbound: runs in the current session — resolves at admission. return enqueueManualRun(prompt); } @@ -10533,7 +10568,7 @@ export function App({ reject, }; pendingBoundRunRef.current = pending; - loadSidebarSession(sessionId) + loadSidebarSession(sessionId, workspaceCwd) // Fire immediately when the session was already active (no dep change // to trigger the effect); a no-op if the load is still settling, in // which case the effect picks it up. @@ -10551,6 +10586,7 @@ export function App({ }, [ enqueueManualRun, + effectiveSessionContext, loadSidebarSession, clearPendingBoundRun, showChat, @@ -14023,6 +14059,7 @@ export function App({ }} onOpenPlugins={() => { closeMobileDrawer(); + if (!workspaceManagementActive) return; openPanel('plugins'); }} onOpenChannels={() => { @@ -14066,9 +14103,12 @@ export function App({ createNewSession( typeof workspaceCwd === 'string' ? { kind: 'workspace', cwd: workspaceCwd } - : { kind: 'global' }, + : { kind: 'global' }, ) } + defaultNewSessionWorkspaceCwd={ + activeWorkspaceCwd ?? trustedPrimaryWorkspaceCwd + } globalNewSessionUsesStandalone={ standaloneSessionsSupported && !lockedWorkspaceCwd } @@ -14088,6 +14128,8 @@ export function App({ pushToast('info', message) } projectFeaturesEnabled={workspaceContextActive} + workspaceManagementEnabled={workspaceManagementActive} + projectNavigationEnabled={projectNavigationActive} onSelectCurrentSession={() => { closeMobileDrawer(); splitFoldedByShrinkRef.current = false; @@ -14384,7 +14426,10 @@ export function App({ )} {activePanel && - (workspaceContextActive || activePanel === 'status') && ( + (workspaceContextActive || + activePanel === 'status' || + (workspaceManagementActive && + activePanel === 'plugins')) && (
) : activePanel === 'channels' ? ( @@ -14684,7 +14741,7 @@ export function App({
)} - {workspaceContextActive && mainView === 'scheduledTasks' && ( + {workspaceManagementActive && mainView === 'scheduledTasks' && (
{t('scheduledTasks.title')}
+ {standaloneManagementWorkspace ? ( +
+ {t('workspace.paneLabel', { + name: workspaceLabel(standaloneManagementWorkspace), + })} +
+ ) : null}
{ // Start a FRESH session and jump to it so the task- // creation chat doesn't pile onto the current @@ -14769,11 +14840,11 @@ export function App({ }, 0); }); }} - onOpenSession={(sessionId) => { + onOpenSession={(sessionId, workspaceCwd) => { // The task's bound session IS its run history — switch // to the chat view and load that session's transcript. showChat(); - loadSidebarSession(sessionId).catch( + loadSidebarSession(sessionId, workspaceCwd).catch( (error: unknown) => { reportError(error, 'Failed to open session'); }, @@ -15705,7 +15776,7 @@ export function App({ : undefined } workspaces={ - workspaceContextActive + composerWorkspaceSelectionActive ? composerWorkspaces : undefined } @@ -15720,7 +15791,17 @@ export function App({ : selectedWorkspaceCwd : undefined } - workspaceSelectionDisabled={!workspaceContextActive} + noWorkspaceSupported={ + composerWorkspaceSelectionActive && + standaloneSessionsSupported && + !lockedWorkspaceCwd + } + noWorkspaceSelected={ + effectiveSessionContext?.kind === 'standalone' + } + workspaceSelectionDisabled={ + !composerWorkspaceSelectionActive + } atWorkspaceCwd={ workspaceContextActive ? (ordinaryWorkspaces.find( @@ -15740,10 +15821,17 @@ export function App({ } workspaceFeaturesEnabled={workspaceContextActive} onSelectWorkspace={ - workspaceContextActive + composerWorkspaceSelectionActive ? handleSelectComposerWorkspace : undefined } + onSelectNoWorkspace={ + composerWorkspaceSelectionActive && + standaloneSessionsSupported && + !lockedWorkspaceCwd + ? handleSelectComposerNoWorkspace + : undefined + } scratchWorkspaceSupported={ workspaceContextActive && scratchWorkspaceRegistrationSupported diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index fbf07bed12c..c291fe87d6f 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -252,8 +252,11 @@ interface ChatEditorProps { trusted: boolean; }>; selectedWorkspaceCwd?: string; + noWorkspaceSupported?: boolean; + noWorkspaceSelected?: boolean; workspaceSelectionDisabled?: boolean; onSelectWorkspace?: (workspaceCwd: string | undefined) => void; + onSelectNoWorkspace?: () => void; scratchWorkspaceSupported?: boolean; existingFolderWorkspaceSupported?: boolean; workspaceMutationBusy?: boolean; @@ -1519,8 +1522,11 @@ export const ChatEditor = memo( onSelectReasoningEffort, workspaces, selectedWorkspaceCwd, + noWorkspaceSupported = false, + noWorkspaceSelected = false, workspaceSelectionDisabled = false, onSelectWorkspace, + onSelectNoWorkspace, scratchWorkspaceSupported = false, existingFolderWorkspaceSupported = false, workspaceMutationBusy = false, @@ -2434,14 +2440,19 @@ export const ChatEditor = memo( const normalizedModelChipLabel = modelChipLabel.endsWith(' · ') ? modelLabel : modelChipLabel; - const selectedWorkspace = workspaces?.find((entry) => - selectedWorkspaceCwd ? entry.cwd === selectedWorkspaceCwd : entry.primary, - ); + const selectedWorkspace = noWorkspaceSelected + ? undefined + : workspaces?.find((entry) => + selectedWorkspaceCwd + ? entry.cwd === selectedWorkspaceCwd + : entry.primary, + ); const selectedWorkspaceLabel = selectedWorkspace?.label ?? ''; const workspaceSelectVisible = Boolean( workspaces && onSelectWorkspace && (workspaces.length > 1 || + noWorkspaceSupported || scratchWorkspaceSupported || existingFolderWorkspaceSupported), ); @@ -3134,6 +3145,8 @@ export const ChatEditor = memo( {})} onOpenExistingFolder={ onOpenExistingWorkspace ?? (() => {}) diff --git a/packages/web-shell/client/components/WorkspaceSelector.test.tsx b/packages/web-shell/client/components/WorkspaceSelector.test.tsx index 76196c0523a..cc76a244cd1 100644 --- a/packages/web-shell/client/components/WorkspaceSelector.test.tsx +++ b/packages/web-shell/client/components/WorkspaceSelector.test.tsx @@ -71,6 +71,50 @@ describe('WorkspaceSelector', () => { expect(element.querySelector('button')).toBeNull(); }); + it('offers and selects No workspace when standalone is supported', async () => { + const onSelectNoWorkspace = vi.fn(); + const element = renderSelector({ + workspaces: [ + { + id: 'primary', + cwd: '/primary', + label: 'primary', + primary: true, + trusted: true, + }, + ], + scratchSupported: false, + existingFolderSupported: false, + noWorkspaceSupported: true, + onSelectNoWorkspace, + }); + const trigger = element.querySelector('button')!; + await act(async () => { + trigger.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + + const noWorkspace = [ + ...document.querySelectorAll('[role="menuitemradio"]'), + ].find((entry) => entry.textContent?.includes('No workspace')); + expect(noWorkspace).toBeDefined(); + await act(async () => { + noWorkspace?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onSelectNoWorkspace).toHaveBeenCalledOnce(); + }); + + it('shows No workspace as the selected scope', () => { + const element = renderSelector({ + noWorkspaceSupported: true, + noWorkspaceSelected: true, + }); + expect( + element.querySelector('[data-slot="select-value"]')?.textContent, + ).toBe('No workspace'); + }); + it('gates creation actions and disables untrusted workspaces', async () => { const onCreateScratch = vi.fn(); const element = renderSelector({ onCreateScratch }); diff --git a/packages/web-shell/client/components/WorkspaceSelector.tsx b/packages/web-shell/client/components/WorkspaceSelector.tsx index 598c43f2f9a..4a890d20923 100644 --- a/packages/web-shell/client/components/WorkspaceSelector.tsx +++ b/packages/web-shell/client/components/WorkspaceSelector.tsx @@ -1,5 +1,10 @@ import { useRef, useState } from 'react'; -import { FolderClosedIcon, FolderPlusIcon, LockIcon } from 'lucide-react'; +import { + FolderClosedIcon, + FolderPlusIcon, + LockIcon, + MessageCircleIcon, +} from 'lucide-react'; import { useI18n } from '../i18n'; import { DropdownMenu, @@ -20,6 +25,8 @@ import { TooltipTrigger, } from './ui/tooltip'; +const NO_WORKSPACE_VALUE = 'qwen-code:no-workspace'; + export interface WorkspaceSelectorOption { id: string; cwd: string; @@ -31,12 +38,15 @@ export interface WorkspaceSelectorOption { interface WorkspaceSelectorProps { workspaces: WorkspaceSelectorOption[]; selectedWorkspaceCwd?: string; + noWorkspaceSupported?: boolean; + noWorkspaceSelected?: boolean; disabled?: boolean; busy?: boolean; scratchSupported: boolean; existingFolderSupported: boolean; className?: string; onSelectWorkspace: (cwd: string | undefined) => void; + onSelectNoWorkspace?: () => void; onCreateScratch: () => void; onOpenExistingFolder: () => void; } @@ -48,12 +58,15 @@ interface WorkspaceSelectorProps { export function WorkspaceSelector({ workspaces, selectedWorkspaceCwd, + noWorkspaceSupported = false, + noWorkspaceSelected = false, disabled, busy, scratchSupported, existingFolderSupported, className, onSelectWorkspace, + onSelectNoWorkspace, onCreateScratch, onOpenExistingFolder, }: WorkspaceSelectorProps) { @@ -63,12 +76,17 @@ export function WorkspaceSelector({ const menuOpenRef = useRef(false); const suppressTooltipRef = useRef(false); const selected = workspaces.find((workspace) => - selectedWorkspaceCwd + !noWorkspaceSelected && selectedWorkspaceCwd ? workspace.cwd === selectedWorkspaceCwd - : workspace.primary, + : !noWorkspaceSelected && workspace.primary, ); + const selectedLabel = noWorkspaceSelected + ? t('sidebar.noWorkspace') + : (selected?.label ?? ''); const canCreate = scratchSupported || existingFolderSupported; - if (workspaces.length <= 1 && !canCreate) return null; + if (workspaces.length <= 1 && !noWorkspaceSupported && !canCreate) { + return null; + } return ( @@ -114,17 +132,25 @@ export function WorkspaceSelector({ } }} > - - {selected?.label ?? ''} + {noWorkspaceSelected ? ( + + ) : ( + + )} + {selectedLabel} - {selected?.label} + {selectedLabel} { + if (id === NO_WORKSPACE_VALUE) { + onSelectNoWorkspace?.(); + return; + } const next = workspaces.find((workspace) => workspace.id === id); if (!next?.trusted) return; onSelectWorkspace(next.primary ? undefined : next.cwd); @@ -148,6 +174,12 @@ export function WorkspaceSelector({ )} ))} + {noWorkspaceSupported && onSelectNoWorkspace && ( + + + {t('sidebar.noWorkspace')} + + )} {canCreate && ( <> diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx index a7e5db8cb8a..4c0335b49ff 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx @@ -67,10 +67,11 @@ let root: Root | null = null; async function mount( tasks: MockTask[], opts: { - onOpenSession?: (sessionId: string) => void; + onOpenSession?: (sessionId: string, workspaceCwd?: string) => void; onRunPrompt?: ( prompt: string, sessionId: string | null, + workspaceCwd?: string, ) => void | Promise; onError?: (error: unknown, message: string) => void; currentSession?: { @@ -83,6 +84,12 @@ async function mount( pendingInteractionCount?: number; }; currentSessionSchedulingAvailable?: boolean; + workspaces?: Array<{ + id: string; + cwd: string; + primary: boolean; + trusted: boolean; + }>; lockedWorkspace?: { id: string; cwd: string; @@ -124,6 +131,7 @@ async function mount( currentSessionSchedulingAvailable={ nextOpts.currentSessionSchedulingAvailable } + workspaces={nextOpts.workspaces} lockedWorkspace={nextOpts.lockedWorkspace} onError={nextOpts.onError ?? vi.fn()} /> @@ -1001,12 +1009,20 @@ describe('ScheduledTasksDialog run now', () => { const onRunPrompt = vi.fn(); await mount([baseTask({ sessionId: 'sess-9', prompt: 'do it' })], { onRunPrompt, + workspaces: [ + { + id: 'primary', + cwd: '/repo/main', + primary: true, + trusted: true, + }, + ], }); click(document.querySelector('[aria-label="Run now"]')); await flush(); // Server-side run record (updates last-run) + client run in the bound session. expect(actions.runScheduledTask).toHaveBeenCalledWith('t1', undefined); // consumed - expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9'); + expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9', '/repo/main'); }); it('passes a null sessionId through for an unbound task', async () => { @@ -1199,13 +1215,22 @@ describe('ScheduledTasksDialog view-history (bound session)', () => { ], }), ], - { onOpenSession }, + { + onOpenSession, + lockedWorkspace: { + id: 'locked', + cwd: '/repo/locked', + primary: false, + trusted: true, + kind: 'ordinary', + }, + }, ); expect(findButton('View conversation (1)')).toBeUndefined(); click(findButton('Run history (1)')); click(document.querySelector('[title="Open this run session"]')); - expect(onOpenSession).toHaveBeenCalledWith('child-1'); + expect(onOpenSession).toHaveBeenCalledWith('child-1', '/repo/locked'); }); it('opens the bound session when its history control is clicked', async () => { diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx index ca6f36ae136..d8cbcaf7a2d 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx @@ -97,13 +97,14 @@ interface ScheduledTasksDialogProps { onRunPrompt: ( prompt: string, sessionId: string | null, + workspaceCwd?: string, ) => void | Promise; /** Switch to the chat view with the composer primed to describe a task, so * the agent can create it conversationally via its cron_create tool. */ onCreateViaChat: () => void; /** Open a task's bound session — its transcript IS the task's run history. * When absent, tasks fall back to the inline fire-timestamp list. */ - onOpenSession?: (sessionId: string) => void; + onOpenSession?: (sessionId: string, workspaceCwd?: string) => void; /** Registered workspaces on a multi-workspace daemon (from capabilities). * When more than one is present the page aggregates every trusted workspace's * tasks (each card tagged with its workspace) and the New-task form offers a @@ -588,6 +589,36 @@ export function ScheduledTasksDialog({ const lockedWorkspaceId = lockedWorkspace ? workspaceActionId(lockedWorkspace) : undefined; + const resolveTaskWorkspaceCwd = useCallback( + (task: DaemonScheduledTask): string | undefined => + task.workspaceCwd ?? + lockedWorkspace?.cwd ?? + operableWorkspaces.find( + (workspace) => workspaceActionId(workspace) === task.workspaceId, + )?.cwd, + [lockedWorkspace?.cwd, operableWorkspaces, workspaceActionId], + ); + const runPromptInTaskWorkspace = useCallback( + (prompt: string, sessionId: string | null, task: DaemonScheduledTask) => { + const workspaceCwd = resolveTaskWorkspaceCwd(task); + return workspaceCwd + ? onRunPrompt(prompt, sessionId, workspaceCwd) + : onRunPrompt(prompt, sessionId); + }, + [onRunPrompt, resolveTaskWorkspaceCwd], + ); + const openTaskSession = useCallback( + (sessionId: string, task: DaemonScheduledTask) => { + if (!onOpenSession) return; + const workspaceCwd = resolveTaskWorkspaceCwd(task); + if (workspaceCwd) { + onOpenSession(sessionId, workspaceCwd); + } else { + onOpenSession(sessionId); + } + }, + [onOpenSession, resolveTaskWorkspaceCwd], + ); const [tasks, setTasks] = useState(null); const [loadError, setLoadError] = useState(null); @@ -1149,7 +1180,7 @@ export function ScheduledTasksDialog({ // if the session can't be opened), record AFTER — so a failed enqueue // leaves no false "ran" entry. A record failure is surfaced but the // history still catches up on the next refresh. - await onRunPrompt(fresh.prompt, fresh.sessionId); + await runPromptInTaskWorkspace(fresh.prompt, fresh.sessionId, task); try { await actions.runScheduledTask(fresh.id, task.workspaceId); await reload(); @@ -1166,7 +1197,7 @@ export function ScheduledTasksDialog({ await actions.runScheduledTask(fresh.id, task.workspaceId); await reload(); try { - await onRunPrompt(fresh.prompt, fresh.sessionId); + await runPromptInTaskWorkspace(fresh.prompt, fresh.sessionId, task); } catch (err) { onError(err, t('scheduledTasks.error.oneShotConsumedButFailed')); return; @@ -1178,7 +1209,7 @@ export function ScheduledTasksDialog({ setRunningTaskId(null); } }, - [actions, onError, onRunPrompt, reload, runningTaskId, t], + [actions, onError, reload, runPromptInTaskWorkspace, runningTaskId, t], ); const handleDelete = useCallback( @@ -1751,7 +1782,7 @@ export function ScheduledTasksDialog({ +
+ + {newSessionScopeMenuVisible && ( + + + + + + + {t('sidebar.workspaceSelectLabel')} + + + {newSessionWorkspaces.map((entry) => ( + handleNewSession(entry.cwd)} + > + + + {workspaceLabel(entry)} + + {entry.cwd === defaultNewSessionWorkspace?.cwd && ( + + {t('common.current')} + + )} + + ))} + + {globalNewSessionUsesStandalone && ( + <> + + handleNewSession()}> + + {t('sidebar.noWorkspace')} + + + )} + + + )} +
)}
{hasScrollingPrimaryNav && (
- {projectFeaturesEnabled && primaryNavItems.has('plugins') && ( + {workspaceManagementEnabled && primaryNavItems.has('plugins') && ( )} - {projectFeaturesEnabled && + {workspaceManagementEnabled && primaryNavItems.has('scheduledTasks') && ( - {!lockedWorkspaceCwd && onOpenAddWorkspace && ( + {workspaceMenuVisible && onOpenAddWorkspace && (
- + {onUseSkill ? ( + + ) : null}