diff --git a/agent/agent.go b/agent/agent.go index 75117769d8e2d..f9278b91a84ae 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -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)), ) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index dc92a4d38d9a2..10020e4ec5c30 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -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() diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 9451461bb3215..8e1987cbb4735 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -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" @@ -1678,7 +1679,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() { @@ -2662,7 +2663,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() @@ -3189,3 +3190,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. + }) +} diff --git a/cli/agent.go b/cli/agent.go index 2285d44fc3584..4f50fbfe88942 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -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 diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 213764bb40113..c7a0c47d18908 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -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"), ) }) diff --git a/cli/open_test.go b/cli/open_test.go index e8d4aa3e65b2e..688fc24b5e84d 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -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()), diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 7a91cfa3ce365..d11748a51f8b8 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -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"), ) }) diff --git a/cli/testdata/coder_agent_--help.golden b/cli/testdata/coder_agent_--help.golden index 3dcbb343149d3..0627016855e08 100644 --- a/cli/testdata/coder_agent_--help.golden +++ b/cli/testdata/coder_agent_--help.golden @@ -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. diff --git a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx index 75c53d8b65c62..33f9f0e49594d 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx @@ -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: { diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index bd2f05b123cad..4f1f75feff539 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -218,7 +218,8 @@ export const AgentDevcontainerCard: FC = ({ text-sm font-semibold text-content-primary md:overflow-visible" > - {subAgent?.name ?? devcontainer.name} + {subAgent?.name ?? + (devcontainer.name || devcontainer.config_path)} {devcontainer.container && ( {" "} @@ -253,7 +254,8 @@ export const AgentDevcontainerCard: FC = ({ disabled={devcontainer.status === "starting"} > - Rebuild + + {devcontainer.container === undefined ? "Start" : "Rebuild"} {showDevcontainerControls && displayApps.includes("ssh_helper") && ( diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 0b5d8a5dc15c3..20c551fc73065 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -137,7 +137,16 @@ export const AgentRow: FC = ({ 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; }