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; } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy