feat(agent/agentcontainers): auto detect dev containers (#18950)

Relates to https://github.com/coder/internal/issues/711

This PR implements a project discovery mechanism that searches for any
dev container projects and makes them visible in the UI so that they can
be started. To make the wording on the site more clear, "Rebuild" has
been changed to "Start" when there is no container associated with a
known dev container configuration. I've also made it so that site will
show the dev container config path when there is no other name
available.

### Design decisions

Just want to ensure my explanation for a few design decisions are noted
down:
- We only search for dev container configurations inside git
repositories
- We only search for these git repositories if they're at the top level
or a direct child of the agent directory.

This limited approach is to reduce the amount of files we ultimately
walk when trying to find these projects. It makes sense to limit it to
only the agent directory, although I'm open to expanding how deep we
search.
This commit is contained in:
Danielle Maywood
2025-07-22 19:02:43 +01:00
committed by GitHub
parent c6efe64a65
commit f41275eb39
11 changed files with 473 additions and 26 deletions
+1 -1
View File
@@ -1168,7 +1168,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
// return existing devcontainers but actual container detection
// and creation will be deferred.
a.containerAPI.Init(
agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName),
agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName, manifest.Directory),
agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts),
agentcontainers.WithSubAgentClient(agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI)),
)
+139 -4
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"maps"
"net/http"
"os"
@@ -21,6 +22,7 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/spf13/afero"
"golang.org/x/xerrors"
"cdr.dev/slog"
@@ -56,10 +58,12 @@ type API struct {
cancel context.CancelFunc
watcherDone chan struct{}
updaterDone chan struct{}
discoverDone chan struct{}
updateTrigger chan chan error // Channel to trigger manual refresh.
updateInterval time.Duration // Interval for periodic container updates.
logger slog.Logger
watcher watcher.Watcher
fs afero.Fs
execer agentexec.Execer
commandEnv CommandEnv
ccli ContainerCLI
@@ -71,9 +75,12 @@ type API struct {
subAgentURL string
subAgentEnv []string
ownerName string
workspaceName string
parentAgent string
projectDiscovery bool // If we should perform project discovery or not.
ownerName string
workspaceName string
parentAgent string
agentDirectory string
mu sync.RWMutex // Protects the following fields.
initDone chan struct{} // Closed by Init.
@@ -192,11 +199,12 @@ func WithSubAgentEnv(env ...string) Option {
// WithManifestInfo sets the owner name, and workspace name
// for the sub-agent.
func WithManifestInfo(owner, workspace, parentAgent string) Option {
func WithManifestInfo(owner, workspace, parentAgent, agentDirectory string) Option {
return func(api *API) {
api.ownerName = owner
api.workspaceName = workspace
api.parentAgent = parentAgent
api.agentDirectory = agentDirectory
}
}
@@ -261,6 +269,21 @@ func WithWatcher(w watcher.Watcher) Option {
}
}
// WithFileSystem sets the file system used for discovering projects.
func WithFileSystem(fileSystem afero.Fs) Option {
return func(api *API) {
api.fs = fileSystem
}
}
// WithProjectDiscovery sets if the API should attempt to discover
// projects on the filesystem.
func WithProjectDiscovery(projectDiscovery bool) Option {
return func(api *API) {
api.projectDiscovery = projectDiscovery
}
}
// ScriptLogger is an interface for sending devcontainer logs to the
// controlplane.
type ScriptLogger interface {
@@ -331,6 +354,9 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
api.watcher = watcher.NewNoop()
}
}
if api.fs == nil {
api.fs = afero.NewOsFs()
}
if api.subAgentClient.Load() == nil {
var c SubAgentClient = noopSubAgentClient{}
api.subAgentClient.Store(&c)
@@ -372,6 +398,12 @@ func (api *API) Start() {
return
}
if api.projectDiscovery && api.agentDirectory != "" {
api.discoverDone = make(chan struct{})
go api.discover()
}
api.watcherDone = make(chan struct{})
api.updaterDone = make(chan struct{})
@@ -379,6 +411,106 @@ func (api *API) Start() {
go api.updaterLoop()
}
func (api *API) discover() {
defer close(api.discoverDone)
defer api.logger.Debug(api.ctx, "project discovery finished")
api.logger.Debug(api.ctx, "project discovery started")
if err := api.discoverDevcontainerProjects(); err != nil {
api.logger.Error(api.ctx, "discovering dev container projects", slog.Error(err))
}
if err := api.RefreshContainers(api.ctx); err != nil {
api.logger.Error(api.ctx, "refreshing containers after discovery", slog.Error(err))
}
}
func (api *API) discoverDevcontainerProjects() error {
isGitProject, err := afero.DirExists(api.fs, filepath.Join(api.agentDirectory, ".git"))
if err != nil {
return xerrors.Errorf(".git dir exists: %w", err)
}
// If the agent directory is a git project, we'll search
// the project for any `.devcontainer/devcontainer.json`
// files.
if isGitProject {
return api.discoverDevcontainersInProject(api.agentDirectory)
}
// The agent directory is _not_ a git project, so we'll
// search the top level of the agent directory for any
// git projects, and search those.
entries, err := afero.ReadDir(api.fs, api.agentDirectory)
if err != nil {
return xerrors.Errorf("read agent directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
isGitProject, err = afero.DirExists(api.fs, filepath.Join(api.agentDirectory, entry.Name(), ".git"))
if err != nil {
return xerrors.Errorf(".git dir exists: %w", err)
}
// If this directory is a git project, we'll search
// it for any `.devcontainer/devcontainer.json` files.
if isGitProject {
if err := api.discoverDevcontainersInProject(filepath.Join(api.agentDirectory, entry.Name())); err != nil {
return err
}
}
}
return nil
}
func (api *API) discoverDevcontainersInProject(projectPath string) error {
devcontainerConfigPaths := []string{
"/.devcontainer/devcontainer.json",
"/.devcontainer.json",
}
return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, _ error) error {
if info.IsDir() {
return nil
}
for _, relativeConfigPath := range devcontainerConfigPaths {
if !strings.HasSuffix(path, relativeConfigPath) {
continue
}
workspaceFolder := strings.TrimSuffix(path, relativeConfigPath)
api.logger.Debug(api.ctx, "discovered dev container project", slog.F("workspace_folder", workspaceFolder))
api.mu.Lock()
if _, found := api.knownDevcontainers[workspaceFolder]; !found {
api.logger.Debug(api.ctx, "adding dev container project", slog.F("workspace_folder", workspaceFolder))
dc := codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "", // Updated later based on container state.
WorkspaceFolder: workspaceFolder,
ConfigPath: path,
Status: "", // Updated later based on container state.
Dirty: false, // Updated later based on config file changes.
Container: nil,
}
api.knownDevcontainers[workspaceFolder] = dc
}
api.mu.Unlock()
}
return nil
})
}
func (api *API) watcherLoop() {
defer close(api.watcherDone)
defer api.logger.Debug(api.ctx, "watcher loop stopped")
@@ -1808,6 +1940,9 @@ func (api *API) Close() error {
if api.updaterDone != nil {
<-api.updaterDone
}
if api.discoverDone != nil {
<-api.discoverDone
}
// Wait for all async tasks to complete.
api.asyncWg.Wait()
+264 -2
View File
@@ -20,6 +20,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@@ -1685,7 +1686,7 @@ func TestAPI(t *testing.T) {
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithDevcontainerCLI(fakeDCCLI),
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"),
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"),
)
api.Start()
apiClose := func() {
@@ -2669,7 +2670,7 @@ func TestAPI(t *testing.T) {
agentcontainers.WithSubAgentClient(fSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"),
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"),
)
api.Start()
defer api.Close()
@@ -3196,3 +3197,264 @@ func TestWithDevcontainersNameGeneration(t *testing.T) {
assert.Equal(t, "bar-project", response.Devcontainers[0].Name, "second devcontainer should has a collision and uses the folder name with a prefix")
assert.Equal(t, "baz-project", response.Devcontainers[1].Name, "third devcontainer should use the folder name with a prefix since it collides with the first two")
}
func TestDevcontainerDiscovery(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows")
}
// We discover dev container projects by searching
// for git repositories at the agent's directory,
// and then recursively walking through these git
// repositories to find any `.devcontainer/devcontainer.json`
// files. These tests are to validate that behavior.
tests := []struct {
name string
agentDir string
fs map[string]string
expected []codersdk.WorkspaceAgentDevcontainer
}{
{
name: "GitProjectInRootDir/SingleProject",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder",
ConfigPath: "/home/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInRootDir/MultipleProjects",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/.git/HEAD": "",
"/home/coder/.devcontainer/devcontainer.json": "",
"/home/coder/site/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder",
ConfigPath: "/home/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/site",
ConfigPath: "/home/coder/site/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInChildDir/SingleProject",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInChildDir/MultipleProjects",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
"/home/coder/coder/site/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/coder/site",
ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInMultipleChildDirs/SingleProjectEach",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
"/home/coder/envbuilder/.git/HEAD": "",
"/home/coder/envbuilder/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/envbuilder",
ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
{
name: "GitProjectInMultipleChildDirs/MultipleProjectEach",
agentDir: "/home/coder",
fs: map[string]string{
"/home/coder/coder/.git/HEAD": "",
"/home/coder/coder/.devcontainer/devcontainer.json": "",
"/home/coder/coder/site/.devcontainer/devcontainer.json": "",
"/home/coder/envbuilder/.git/HEAD": "",
"/home/coder/envbuilder/.devcontainer/devcontainer.json": "",
"/home/coder/envbuilder/x/.devcontainer/devcontainer.json": "",
},
expected: []codersdk.WorkspaceAgentDevcontainer{
{
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/coder/site",
ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/envbuilder",
ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
{
WorkspaceFolder: "/home/coder/envbuilder/x",
ConfigPath: "/home/coder/envbuilder/x/.devcontainer/devcontainer.json",
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
},
},
},
}
initFS := func(t *testing.T, files map[string]string) afero.Fs {
t.Helper()
fs := afero.NewMemMapFs()
for name, content := range files {
err := afero.WriteFile(fs, name, []byte(content+"\n"), 0o600)
require.NoError(t, err)
}
return fs
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
logger = testutil.Logger(t)
mClock = quartz.NewMock(t)
tickerTrap = mClock.Trap().TickerFunc("updaterLoop")
r = chi.NewRouter()
)
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithFileSystem(initFS(t, tt.fs)),
agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", tt.agentDir),
agentcontainers.WithContainerCLI(&fakeContainerCLI{}),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithProjectDiscovery(true),
)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Wait until all projects have been discovered
require.Eventuallyf(t, func() bool {
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
got := codersdk.WorkspaceAgentListContainersResponse{}
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err)
return len(got.Devcontainers) == len(tt.expected)
}, testutil.WaitShort, testutil.IntervalFast, "dev containers never found")
// Now projects have been discovered, we'll allow the updater loop
// to set the appropriate status for these containers.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Now we'll fetch the list of dev containers
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
got := codersdk.WorkspaceAgentListContainersResponse{}
err := json.NewDecoder(rec.Body).Decode(&got)
require.NoError(t, err)
// We will set the IDs of each dev container to uuid.Nil to simplify
// this check.
for idx := range got.Devcontainers {
got.Devcontainers[idx].ID = uuid.Nil
}
// Sort the expected dev containers and got dev containers by their workspace folder.
// This helps ensure a deterministic test.
slices.SortFunc(tt.expected, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder)
})
slices.SortFunc(got.Devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder)
})
require.Equal(t, tt.expected, got.Devcontainers)
})
}
t.Run("NoErrorWhenAgentDirAbsent", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
// Given: We have an empty agent directory
agentDir := ""
api := agentcontainers.NewAPI(logger,
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", agentDir),
agentcontainers.WithContainerCLI(&fakeContainerCLI{}),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithProjectDiscovery(true),
)
// When: We start and close the API
api.Start()
api.Close()
// Then: We expect there to have been no errors.
// This is implicitly handled by `testutil.Logger` failing when it
// detects an error has been logged.
})
}
+25 -16
View File
@@ -40,22 +40,23 @@ import (
func (r *RootCmd) workspaceAgent() *serpent.Command {
var (
auth string
logDir string
scriptDataDir string
pprofAddress string
noReap bool
sshMaxTimeout time.Duration
tailnetListenPort int64
prometheusAddress string
debugAddress string
slogHumanPath string
slogJSONPath string
slogStackdriverPath string
blockFileTransfer bool
agentHeaderCommand string
agentHeader []string
devcontainers bool
auth string
logDir string
scriptDataDir string
pprofAddress string
noReap bool
sshMaxTimeout time.Duration
tailnetListenPort int64
prometheusAddress string
debugAddress string
slogHumanPath string
slogJSONPath string
slogStackdriverPath string
blockFileTransfer bool
agentHeaderCommand string
agentHeader []string
devcontainers bool
devcontainerProjectDiscovery bool
)
cmd := &serpent.Command{
Use: "agent",
@@ -364,6 +365,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
Devcontainers: devcontainers,
DevcontainerAPIOptions: []agentcontainers.Option{
agentcontainers.WithSubAgentURL(r.agentURL.String()),
agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery),
},
})
@@ -510,6 +512,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
Description: "Allow the agent to automatically detect running devcontainers.",
Value: serpent.BoolOf(&devcontainers),
},
{
Flag: "devcontainers-project-discovery-enable",
Default: "true",
Env: "CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE",
Description: "Allow the agent to search the filesystem for devcontainer projects.",
Value: serpent.BoolOf(&devcontainerProjectDiscovery),
},
}
return cmd
+1
View File
@@ -118,6 +118,7 @@ func TestExpRpty(t *testing.T) {
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.Devcontainers = true
o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions,
agentcontainers.WithProjectDiscovery(false),
agentcontainers.WithContainerLabelIncludeFilter(wantLabel, "true"),
)
})
+1
View File
@@ -406,6 +406,7 @@ func TestOpenVSCodeDevContainer(t *testing.T) {
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.Devcontainers = true
o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions,
agentcontainers.WithProjectDiscovery(false),
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
+2
View File
@@ -2031,6 +2031,7 @@ func TestSSH_Container(t *testing.T) {
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.Devcontainers = true
o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions,
agentcontainers.WithProjectDiscovery(false),
agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"),
)
})
@@ -2072,6 +2073,7 @@ func TestSSH_Container(t *testing.T) {
o.Devcontainers = true
o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions,
agentcontainers.WithContainerCLI(mLister),
agentcontainers.WithProjectDiscovery(false),
agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"),
)
})
+3
View File
@@ -36,6 +36,9 @@ OPTIONS:
--devcontainers-enable bool, $CODER_AGENT_DEVCONTAINERS_ENABLE (default: true)
Allow the agent to automatically detect running devcontainers.
--devcontainers-project-discovery-enable bool, $CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE (default: true)
Allow the agent to search the filesystem for devcontainer projects.
--log-dir string, $CODER_AGENT_LOG_DIR (default: /tmp)
Specify the location for the agent log files.
@@ -91,6 +91,29 @@ export const Recreating: Story = {
},
};
export const NoContainerOrSubAgent: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
container: undefined,
agent: undefined,
},
subAgents: [],
},
};
export const NoContainerOrAgentOrName: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
container: undefined,
agent: undefined,
name: "",
},
subAgents: [],
},
};
export const NoSubAgent: Story = {
args: {
devcontainer: {
@@ -218,7 +218,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
text-sm font-semibold text-content-primary
md:overflow-visible"
>
{subAgent?.name ?? devcontainer.name}
{subAgent?.name ??
(devcontainer.name || devcontainer.config_path)}
{devcontainer.container && (
<span className="text-content-tertiary">
{" "}
@@ -253,7 +254,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
disabled={devcontainer.status === "starting"}
>
<Spinner loading={devcontainer.status === "starting"} />
Rebuild
{devcontainer.container === undefined ? "Start" : "Rebuild"}
</Button>
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
+10 -1
View File
@@ -137,7 +137,16 @@ export const AgentRow: FC<AgentRowProps> = ({
const [showParentApps, setShowParentApps] = useState(false);
let shouldDisplayAppsSection = shouldDisplayAgentApps;
if (devcontainers && devcontainers.length > 0 && !showParentApps) {
if (
devcontainers &&
devcontainers.find(
// We only want to hide the parent apps by default when there are dev
// containers that are either starting or running. If they are all in
// the stopped state, it doesn't make sense to hide the parent apps.
(dc) => dc.status === "running" || dc.status === "starting",
) !== undefined &&
!showParentApps
) {
shouldDisplayAppsSection = false;
}