diff --git a/cli/completion/completion.go b/cli/completion/completion.go index 8b8f2528..8341a310 100644 --- a/cli/completion/completion.go +++ b/cli/completion/completion.go @@ -65,6 +65,7 @@ func FullSpec() CommandSpec { {Name: "daemon", Short: "d"}, {Name: "no-auth-detect"}, {Name: "no-browser"}, + {Name: "no-codex-auto-install"}, {Name: "log-level", TakesValue: true, Values: logLevelValues()}, {Name: "log", TakesValue: true}, {Name: "pid", TakesValue: true}, diff --git a/cli/completion/completion_test.go b/cli/completion/completion_test.go index 0c59d72a..455158f9 100644 --- a/cli/completion/completion_test.go +++ b/cli/completion/completion_test.go @@ -25,7 +25,7 @@ func TestCompleteSubcommandsAndFlags(t *testing.T) { assertContainsAll(t, got, "list", "create", "start", "stop", "delete", "logs", "--help") got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "serve", "--"}) - assertContainsAll(t, got, "--daemon", "--no-auth-detect", "--no-browser", "--log-level", "--log", "--pid") + assertContainsAll(t, got, "--daemon", "--no-auth-detect", "--no-browser", "--no-codex-auto-install", "--log-level", "--log", "--pid") got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "upgrade", "--"}) assertContainsAll(t, got, "--check", "--no-restart") diff --git a/cli/serve/serve.go b/cli/serve/serve.go index 0f289353..a6b48c71 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -45,6 +45,7 @@ import ( internalonboard "csgclaw/internal/onboard" "csgclaw/internal/participant" runtimecodex "csgclaw/internal/runtime/codex" + "csgclaw/internal/runtimecatalog" "csgclaw/internal/sandboxproviders" "csgclaw/internal/scheduledtask" "csgclaw/internal/server" @@ -64,6 +65,7 @@ var ( NewTeamService = newTeamService NewAgentTaskService = newAgentTaskService NewScheduledTaskService = newScheduledTaskService + NewAgentRuntimeService = func() *runtimecatalog.Service { return runtimecatalog.NewService() } CheckModelProvider = checkModelProvider CheckCatalogModelProvider = agent.CheckModelProvider EnsureCLIProxy = func(ctx context.Context) error { @@ -123,6 +125,7 @@ func (c serveCmd) Run(ctx context.Context, run *command.Context, args []string, fs.BoolVar(daemon, "d", false, "run server in background") noBrowser := fs.Bool("no-browser", false, "do not open the browser after startup") noAuthDetect := fs.Bool("no-auth-detect", false, "disable automatic auth detection during startup") + noCodexAutoInstall := fs.Bool("no-codex-auto-install", false, "do not automatically install Codex CLI during startup") logLevel := fs.String("log-level", "info", "log level: debug, info, warn, error") defaultLogPath, err := defaultServerLogPath() @@ -172,15 +175,17 @@ func (c serveCmd) Run(ctx context.Context, run *command.Context, args []string, cfg.Server.AdvertiseBaseURL = strings.TrimRight(globals.Endpoint, "/") } + serveOpts := serveOptions{ + NoBrowser: *noBrowser, + NoAuthDetect: *noAuthDetect, + NoCodexAutoInstall: *noCodexAutoInstall, + } if *daemon { serveGlobals := globals serveGlobals.Config = configPath - return serveBackground(run, cfg, serveGlobals, *logPath, *pidPath, *logLevel, *noBrowser, *noAuthDetect) + return serveBackground(run, cfg, serveGlobals, *logPath, *pidPath, *logLevel, serveOpts) } - return serveForegroundWithConfigPath(ctx, run, cfg, configPath, globals.Output, serveOptions{ - NoBrowser: *noBrowser, - NoAuthDetect: *noAuthDetect, - }) + return serveForegroundWithConfigPath(ctx, run, cfg, configPath, globals.Output, serveOpts) } func (stopCmd) Name() string { @@ -253,6 +258,7 @@ func (c internalServeCmd) Run(ctx context.Context, run *command.Context, args [] logLevel := fs.String("log-level", "info", "log level: debug, info, warn, error") noBrowser := fs.Bool("no-browser", false, "do not open the browser after startup") noAuthDetect := fs.Bool("no-auth-detect", false, "disable automatic auth detection during startup") + noCodexAutoInstall := fs.Bool("no-codex-auto-install", false, "do not automatically install Codex CLI during startup") if err := fs.Parse(args); err != nil { return err } @@ -312,8 +318,9 @@ func (c internalServeCmd) Run(ctx context.Context, run *command.Context, args [] return err } return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, configPath, globals.Output, serveOptions{ - NoBrowser: *noBrowser, - NoAuthDetect: *noAuthDetect, + NoBrowser: *noBrowser, + NoAuthDetect: *noAuthDetect, + NoCodexAutoInstall: *noCodexAutoInstall, }) } @@ -339,8 +346,9 @@ func officialInstallGuidance(goos string) (string, string) { } type serveOptions struct { - NoBrowser bool - NoAuthDetect bool + NoBrowser bool + NoAuthDetect bool + NoCodexAutoInstall bool } func serveForegroundWithConfigPath(ctx context.Context, run *command.Context, cfg config.Config, configPath string, output string, opts ...serveOptions) error { @@ -386,7 +394,7 @@ func serveForegroundWithConfigPath(ctx context.Context, run *command.Context, cf return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, configPath, output, opts...) } -func serveBackground(run *command.Context, cfg config.Config, globals command.GlobalOptions, logPath, pidPath, logLevel string, noBrowser, noAuthDetect bool) error { +func serveBackground(run *command.Context, cfg config.Config, globals command.GlobalOptions, logPath, pidPath, logLevel string, opts serveOptions) error { exe, err := os.Executable() if err != nil { return fmt.Errorf("resolve executable: %w", err) @@ -400,19 +408,7 @@ func serveBackground(run *command.Context, cfg config.Config, globals command.Gl } defer logFile.Close() - childArgs := []string{"_serve", "--pid", pidPath} - if globals.Config != "" { - childArgs = append(childArgs, "--config", globals.Config) - } - if strings.TrimSpace(logLevel) != "" { - childArgs = append(childArgs, "--log-level", logLevel) - } - if noBrowser { - childArgs = append(childArgs, "--no-browser") - } - if noAuthDetect { - childArgs = append(childArgs, "--no-auth-detect") - } + childArgs := backgroundServeArgs(globals.Config, pidPath, logLevel, opts) cmd := exec.Command(exe, childArgs...) cmd.Stdout = logFile cmd.Stderr = logFile @@ -450,6 +446,26 @@ func serveBackground(run *command.Context, cfg config.Config, globals command.Gl return nil } +func backgroundServeArgs(configPath, pidPath, logLevel string, opts serveOptions) []string { + args := []string{"_serve", "--pid", pidPath} + if configPath != "" { + args = append(args, "--config", configPath) + } + if strings.TrimSpace(logLevel) != "" { + args = append(args, "--log-level", logLevel) + } + if opts.NoBrowser { + args = append(args, "--no-browser") + } + if opts.NoAuthDetect { + args = append(args, "--no-auth-detect") + } + if opts.NoCodexAutoInstall { + args = append(args, "--no-codex-auto-install") + } + return args +} + func applyNoAuthDetectEnv(disabled bool) func() { if !disabled { return func() {} @@ -616,6 +632,7 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co if agentManagerSvc != nil { defer agentManagerSvc.Close() } + agentRuntimeSvc := NewAgentRuntimeService() return RunServer(server.Options{ ListenAddr: cfg.Server.ListenAddr, Service: svc, @@ -629,6 +646,7 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co Team: teamSvc, AgentTask: agentTaskSvc, ScheduledTask: scheduledTaskSvc, + AgentRuntimes: agentRuntimeSvc, TeamAdapters: teamAdapters, Upgrade: upgradeManager, ActivityDecider: channelActivityDecider(codexBridgeMgr), @@ -653,6 +671,11 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co }() } go func() { + if !serveOpts.NoCodexAutoInstall && agentRuntimeSvc != nil { + if _, err := agentRuntimeSvc.EnsureCodex(ctx); err != nil { + slog.Warn("Codex CLI auto-install failed", "error", err) + } + } if agentManagerSvc != nil { if err := agentManagerSvc.Start(ctx); err != nil { slog.Warn("bootstrap manager failed to start", "error", err) diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index f9f438c2..7eb0c8ca 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -1,11 +1,16 @@ package serve import ( + "archive/tar" "bytes" + "compress/gzip" "context" + "encoding/binary" "errors" "fmt" "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -16,18 +21,26 @@ import ( "csgclaw/cli/command" "csgclaw/internal/agent" "csgclaw/internal/channel/feishu" + "csgclaw/internal/codexcli" "csgclaw/internal/config" "csgclaw/internal/im" "csgclaw/internal/llm" internalonboard "csgclaw/internal/onboard" "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/runtimecatalog" "csgclaw/internal/sandboxproviders" "csgclaw/internal/server" "csgclaw/internal/upgrade" appversion "csgclaw/internal/version" ) +func init() { + codexPath := filepath.Join(os.TempDir(), "csgclaw-serve-test-codex.exe") + _ = os.WriteFile(codexPath, []byte("#!/bin/sh\nexit 0\n"), 0o755) + _ = os.Setenv("CSGCLAW_CODEX_PATH", codexPath) +} + func TestValidateServeInstallationRejectsReleaseOutsideBundle(t *testing.T) { originalVersion := appversion.Version appversion.Version = "v0.3.12" @@ -1027,6 +1040,255 @@ func TestServeForegroundEnsuresBootstrapManagerBeforeConfiguredAgents(t *testing } } +func TestServeForegroundAutoInstallsCodexBeforeManager(t *testing.T) { + restore := stubServeDependencies(t) + defer restore() + + target := filepath.Join(t.TempDir(), "bin", "codex") + t.Setenv(codexcli.EnvBinaryPath, target) + binaryPayload := serveTestMachOBinary("arm64") + payload := serveTestCodexArchive(t, "codex-aarch64-apple-darwin", string(binaryPayload)) + downloadStarted := make(chan struct{}) + releaseDownload := make(chan struct{}) + downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/macos/arm64" || r.URL.Query().Get("package") != "codex-cli" { + t.Errorf("download URL = %s, want /macos/arm64?package=codex-cli", r.URL.String()) + } + close(downloadStarted) + <-releaseDownload + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer downloadServer.Close() + installer := codexcli.NewInstaller(codexcli.InstallerOptions{ + BaseURL: downloadServer.URL, + GOOS: "darwin", + GOARCH: "arm64", + }) + runtimeService := runtimecatalog.NewService( + runtimecatalog.WithCodexInstaller(installer), + runtimecatalog.WithPlatform("darwin", "arm64"), + ) + originalNewAgentRuntimeService := NewAgentRuntimeService + NewAgentRuntimeService = func() *runtimecatalog.Service { return runtimeService } + t.Cleanup(func() { NewAgentRuntimeService = originalNewAgentRuntimeService }) + + managerStarted := make(chan struct{}) + EnsureBootstrapManager = func(context.Context, *agent.Service) error { + path, err := (codexcli.Locator{}).Locate() + if err != nil { + return fmt.Errorf("locate Codex before manager startup: %w", err) + } + if path != target { + return fmt.Errorf("Codex path before manager startup = %q, want %q", path, target) + } + close(managerStarted) + return nil + } + RunServer = func(opts server.Options) error { + if opts.AgentRuntimes != runtimeService { + return fmt.Errorf("AgentRuntimes = %p, want shared service %p", opts.AgentRuntimes, runtimeService) + } + if opts.OnReady == nil { + return errors.New("OnReady is nil") + } + opts.OnReady(nil, nil) + return nil + } + + if err := serveForeground(context.Background(), testContext(), config.Config{Server: config.ServerConfig{ListenAddr: "127.0.0.1:18080"}}, "json"); err != nil { + t.Fatalf("serveForeground() error = %v", err) + } + select { + case <-downloadStarted: + case <-time.After(time.Second): + t.Fatal("Codex auto-install did not start") + } + select { + case <-managerStarted: + t.Fatal("manager started before Codex installation completed") + default: + } + close(releaseDownload) + select { + case <-managerStarted: + case <-time.After(time.Second): + t.Fatal("manager did not start after Codex installation completed") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", target, err) + } + if !bytes.Equal(data, binaryPayload) { + t.Fatalf("installed Codex does not match the validated fixture") + } +} + +func TestServeRunSkipsCodexAutoInstallWhenDisabled(t *testing.T) { + restore := stubServeDependencies(t) + defer restore() + + home := t.TempDir() + t.Setenv("HOME", home) + target := filepath.Join(home, ".csgclaw", "bin", "codex") + t.Setenv(codexcli.EnvBinaryPath, target) + downloadAttempted := make(chan struct{}, 1) + downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + select { + case downloadAttempted <- struct{}{}: + default: + } + http.Error(w, "unexpected download", http.StatusInternalServerError) + })) + defer downloadServer.Close() + installer := codexcli.NewInstaller(codexcli.InstallerOptions{ + BaseURL: downloadServer.URL, + GOOS: "linux", + GOARCH: "amd64", + }) + runtimeService := runtimecatalog.NewService( + runtimecatalog.WithCodexInstaller(installer), + runtimecatalog.WithPlatform("linux", "amd64"), + ) + originalNewAgentRuntimeService := NewAgentRuntimeService + NewAgentRuntimeService = func() *runtimecatalog.Service { return runtimeService } + t.Cleanup(func() { NewAgentRuntimeService = originalNewAgentRuntimeService }) + + managerStarted := make(chan struct{}) + EnsureBootstrapManager = func(context.Context, *agent.Service) error { + close(managerStarted) + return nil + } + RunServer = func(opts server.Options) error { + if opts.AgentRuntimes != runtimeService { + return fmt.Errorf("AgentRuntimes = %p, want shared service %p", opts.AgentRuntimes, runtimeService) + } + if opts.OnReady == nil { + return errors.New("OnReady is nil") + } + opts.OnReady(nil, nil) + return nil + } + + configPath := filepath.Join(home, "config.toml") + if err := (config.Config{ + Server: config.ServerConfig{ListenAddr: "127.0.0.1:18080"}, + Sandbox: config.SandboxConfig{Provider: config.DefaultSandboxProvider}, + }).Save(configPath); err != nil { + t.Fatalf("Save(config) error = %v", err) + } + if err := NewServeCmd().Run(context.Background(), testContext(), []string{"--no-codex-auto-install"}, command.GlobalOptions{ + Config: configPath, + Output: "json", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + select { + case <-managerStarted: + case <-time.After(time.Second): + t.Fatal("manager did not start when Codex auto-install was disabled") + } + select { + case <-downloadAttempted: + t.Fatal("Codex download was attempted with --no-codex-auto-install") + default: + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(%q) error = %v, want missing Codex binary", target, err) + } +} + +func TestBackgroundServeArgsForwardsCodexAutoInstallFlag(t *testing.T) { + got := backgroundServeArgs("/tmp/config.toml", "/tmp/server.pid", "debug", serveOptions{ + NoBrowser: true, + NoAuthDetect: true, + NoCodexAutoInstall: true, + }) + want := []string{ + "_serve", "--pid", "/tmp/server.pid", + "--config", "/tmp/config.toml", + "--log-level", "debug", + "--no-browser", + "--no-auth-detect", + "--no-codex-auto-install", + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("backgroundServeArgs() = %v, want %v", got, want) + } +} + +func TestServeForegroundContinuesStartupAfterCodexInstallFailure(t *testing.T) { + restore := stubServeDependencies(t) + defer restore() + + t.Setenv(codexcli.EnvBinaryPath, filepath.Join(t.TempDir(), "missing-codex")) + downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "upstream unavailable", http.StatusBadGateway) + })) + defer downloadServer.Close() + installer := codexcli.NewInstaller(codexcli.InstallerOptions{ + BaseURL: downloadServer.URL, + GOOS: "linux", + GOARCH: "amd64", + }) + originalNewAgentRuntimeService := NewAgentRuntimeService + NewAgentRuntimeService = func() *runtimecatalog.Service { + return runtimecatalog.NewService(runtimecatalog.WithCodexInstaller(installer)) + } + t.Cleanup(func() { NewAgentRuntimeService = originalNewAgentRuntimeService }) + + managerStarted := make(chan struct{}) + EnsureBootstrapManager = func(context.Context, *agent.Service) error { + close(managerStarted) + return nil + } + RunServer = func(opts server.Options) error { + opts.OnReady(nil, nil) + return nil + } + + if err := serveForeground(context.Background(), testContext(), config.Config{Server: config.ServerConfig{ListenAddr: "127.0.0.1:18080"}}, "json"); err != nil { + t.Fatalf("serveForeground() error = %v", err) + } + select { + case <-managerStarted: + case <-time.After(time.Second): + t.Fatal("manager startup did not continue after Codex install failure") + } +} + +func serveTestCodexArchive(t *testing.T, name, body string) []byte { + t.Helper() + var compressed bytes.Buffer + gzipWriter := gzip.NewWriter(&compressed) + tarWriter := tar.NewWriter(gzipWriter) + header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg} + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("WriteHeader() error = %v", err) + } + if _, err := tarWriter.Write([]byte(body)); err != nil { + t.Fatalf("Write() error = %v", err) + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("tar Close() error = %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("gzip Close() error = %v", err) + } + return compressed.Bytes() +} + +func serveTestMachOBinary(arch string) []byte { + data := make([]byte, 32) + copy(data[:4], []byte{0xcf, 0xfa, 0xed, 0xfe}) + cpuType := uint32(0x01000007) + if arch == "arm64" { + cpuType = 0x0100000c + } + binary.LittleEndian.PutUint32(data[4:8], cpuType) + return data +} + func TestServeForegroundPassesConfigPathToServer(t *testing.T) { restore := stubServeDependencies(t) defer restore() @@ -1520,7 +1782,7 @@ func TestNewAgentServiceRegistersCodexRuntime(t *testing.T) { svc, err := newAgentService(config.Config{ Sandbox: config.SandboxConfig{ - Provider: config.DefaultSandboxProvider, + Provider: config.BoxLiteProvider, }, }, nil) if err != nil { diff --git a/docs/cli.md b/docs/cli.md index 3cb0b241..e80d5100 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -114,7 +114,9 @@ csgclaw serve [-d|--daemon] [flags] Flags: - `--daemon`, `-d`: run in background. +- `--no-browser`: do not open the browser after startup. - `--no-auth-detect`: disable startup auth/model auto-detection so the Manager Profile setup flow remains incomplete for manual testing. +- `--no-codex-auto-install`: start without automatically installing Codex CLI. Runtime status and manual installation from the Computer page remain available. - `--log-level string`: log level. Supported values: `debug`, `info`, `warn`, `error`. Default `info`. - `--log string`: daemon log path. Daemon mode only. Default `~/.csgclaw/server.log`. - `--pid string`: daemon PID path. Daemon mode only. Default `~/.csgclaw/server.pid`. @@ -126,6 +128,7 @@ Behavior: - Validates effective model configuration before startup. - For `csghub-lite`, it performs a provider reachability preflight. - With `--no-auth-detect`, startup skips automatic CLI auth import and Manager Profile provider/model detection unless an existing complete Manager Profile is already saved. +- With `--no-codex-auto-install`, startup skips only the Codex CLI installation attempt; the Computer page can still install or retry it manually. - In foreground mode it prints the effective config and IM URL. - In daemon mode it launches the hidden internal `_serve` entrypoint and waits for `/healthz`. @@ -133,6 +136,7 @@ Examples: ```bash csgclaw serve +csgclaw serve --no-codex-auto-install csgclaw serve --no-auth-detect --no-browser csgclaw serve --daemon csgclaw serve --config /path/to/config.toml @@ -372,7 +376,9 @@ csgclaw agent delete --all --force Notes: - `--runtime codex` requires a local `codex` CLI that supports `app-server --listen stdio://`. -- Binary lookup uses `PATH` by default and can be overridden with `CSGCLAW_CODEX_PATH`. On Windows, use `codex.cmd` or `codex.exe` rather than the PowerShell `codex.ps1` shim. +- Binary lookup uses `PATH` by default and can be overridden with `CSGCLAW_CODEX_PATH`. + On Windows, use a native `codex.exe` rather than the PowerShell `codex.ps1` shim. + Legacy `codex.cmd` and `codex.bat` values fall back to a sibling or CSGClaw-managed native executable. ### `csgclaw user` diff --git a/docs/cli.zh.md b/docs/cli.zh.md index 0fc8b126..584ee7d7 100644 --- a/docs/cli.zh.md +++ b/docs/cli.zh.md @@ -114,7 +114,9 @@ csgclaw serve [-d|--daemon] [flags] 参数: - `--daemon`、`-d`:后台运行。 +- `--no-browser`:启动后不自动打开浏览器。 - `--no-auth-detect`:禁用启动时的 auth/model 自动检测,让 Manager Profile 配置流程保持未完成,便于手动测试。 +- `--no-codex-auto-install`:启动时不自动安装 Codex CLI;Computer 页面仍会显示运行时状态,并支持手动安装。 - `--log-level string`:日志级别,支持 `debug`、`info`、`warn`、`error`,默认 `info`。 - `--log string`:后台模式日志路径,仅 daemon 模式有效。默认 `~/.csgclaw/server.log`。 - `--pid string`:后台模式 PID 文件路径,仅 daemon 模式有效。默认 `~/.csgclaw/server.pid`。 @@ -126,6 +128,7 @@ csgclaw serve [-d|--daemon] [flags] - 启动前会校验最终模型配置是否完整。 - 对 `csghub-lite` 会做连通性预检查。 - 使用 `--no-auth-detect` 时,启动会跳过 CLI auth 自动导入和 Manager Profile provider/model 自动检测;已保存的完整 Manager Profile 不会被覆盖。 +- 使用 `--no-codex-auto-install` 时,只会跳过启动阶段的 Codex CLI 自动安装;仍可在 Computer 页面手动安装或重试。 - 前台模式下会打印生效配置和 IM 访问地址。 - 后台模式会拉起隐藏的 `_serve` 内部入口,并等待 `/healthz` 健康检查成功。 @@ -133,6 +136,7 @@ csgclaw serve [-d|--daemon] [flags] ```bash csgclaw serve +csgclaw serve --no-codex-auto-install csgclaw serve --no-auth-detect --no-browser csgclaw serve --daemon csgclaw serve --config /path/to/config.toml diff --git a/docs/config.md b/docs/config.md index eccdaab0..ad815f06 100644 --- a/docs/config.md +++ b/docs/config.md @@ -127,7 +127,9 @@ For complete Codex worker profiles, CSGClaw writes `~/.csgclaw/agents/ maxDownloadSize { + return InstallStatus{}, fmt.Errorf("download Codex CLI: package size %d exceeds limit %d", resp.ContentLength, maxDownloadSize) + } + + temp, err := os.CreateTemp(filepath.Dir(targetPath), ".codex-install-*") + if err != nil { + return InstallStatus{}, fmt.Errorf("create temporary Codex binary: %w", err) + } + tempPath := temp.Name() + defer func() { + _ = temp.Close() + if tempPath != "" { + _ = os.Remove(tempPath) + } + }() + + limited := &io.LimitedReader{R: resp.Body, N: maxDownloadSize + 1} + if i.goos == "windows" { + err = installWindowsBinary(temp, limited) + } else { + err = installUnixArchive(temp, limited, platform.archiveBinary) + } + if err != nil { + return InstallStatus{}, err + } + if err := validatePlatformBinary(temp, i.goos, i.goarch); err != nil { + return InstallStatus{}, err + } + if limited.N <= 0 { + return InstallStatus{}, fmt.Errorf("download Codex CLI: package exceeds limit %d", maxDownloadSize) + } + if err := temp.Chmod(0o755); err != nil { + return InstallStatus{}, fmt.Errorf("make Codex binary executable: %w", err) + } + if err := temp.Sync(); err != nil { + return InstallStatus{}, fmt.Errorf("sync Codex binary: %w", err) + } + if err := temp.Close(); err != nil { + return InstallStatus{}, fmt.Errorf("close Codex binary: %w", err) + } + if err := os.Rename(tempPath, targetPath); err != nil { + return InstallStatus{}, fmt.Errorf("install Codex binary: %w", err) + } + tempPath = "" + + installedPath, err := i.locator.Locate() + if err != nil { + return InstallStatus{}, fmt.Errorf("verify installed Codex binary: %w", err) + } + return InstallStatus{State: InstallStateInstalled, Installed: true, Path: installedPath}, nil +} + +func installWindowsBinary(dst *os.File, src io.Reader) error { + written, err := io.Copy(dst, io.LimitReader(src, maxBinarySize+1)) + if err != nil { + return fmt.Errorf("write Codex binary: %w", err) + } + if written > maxBinarySize { + return fmt.Errorf("write Codex binary: binary size exceeds limit %d", maxBinarySize) + } + return nil +} + +func installUnixArchive(dst *os.File, src io.Reader, expectedBinary string) error { + gzipReader, err := gzip.NewReader(src) + if err != nil { + return fmt.Errorf("open Codex archive: %w", err) + } + defer gzipReader.Close() + expanded := &io.LimitedReader{R: gzipReader, N: maxArchiveExpandedSize + 1} + tarReader := tar.NewReader(expanded) + found := false + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("read Codex archive: %w", err) + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { + return fmt.Errorf("read Codex archive: unexpected non-regular entry %q", header.Name) + } + if strings.TrimSpace(header.Name) != expectedBinary { + return fmt.Errorf("read Codex archive: unexpected entry %q", header.Name) + } + if found { + return errors.New("read Codex archive: package contains multiple Codex binaries") + } + if header.Size <= 0 || header.Size > maxBinarySize { + return fmt.Errorf("read Codex archive: binary size %d is invalid", header.Size) + } + written, err := io.Copy(dst, io.LimitReader(tarReader, maxBinarySize+1)) + if err != nil { + return fmt.Errorf("extract Codex binary: %w", err) + } + if written != header.Size { + return fmt.Errorf("extract Codex binary: wrote %d bytes, expected %d", written, header.Size) + } + found = true + } + if expanded.N <= 0 { + return fmt.Errorf("read Codex archive: expanded package exceeds limit %d", maxArchiveExpandedSize) + } + if !found { + return errors.New("read Codex archive: package does not contain a Codex binary") + } + return nil +} + +func validatePlatformBinary(file *os.File, goos, goarch string) error { + if file == nil { + return errors.New("validate Codex binary: file is not configured") + } + switch strings.ToLower(strings.TrimSpace(goos)) { + case "linux": + header := make([]byte, 20) + if _, err := file.ReadAt(header, 0); err != nil { + return fmt.Errorf("validate Codex ELF binary: %w", err) + } + if !bytes.Equal(header[:4], []byte{0x7f, 'E', 'L', 'F'}) || header[4] != 2 || header[5] != 1 { + return errors.New("validate Codex ELF binary: invalid 64-bit little-endian ELF header") + } + machine := binary.LittleEndian.Uint16(header[18:20]) + wantMachine := uint16(0) + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "amd64": + wantMachine = 62 + case "arm64": + wantMachine = 183 + } + if wantMachine == 0 || machine != wantMachine { + return fmt.Errorf("validate Codex ELF binary: machine %d does not match %s", machine, goarch) + } + case "darwin", "macos": + header := make([]byte, 8) + if _, err := file.ReadAt(header, 0); err != nil { + return fmt.Errorf("validate Codex Mach-O binary: %w", err) + } + if !bytes.Equal(header[:4], []byte{0xcf, 0xfa, 0xed, 0xfe}) { + return errors.New("validate Codex Mach-O binary: invalid 64-bit Mach-O header") + } + cpuType := binary.LittleEndian.Uint32(header[4:8]) + wantCPUType := uint32(0) + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "amd64": + wantCPUType = 0x01000007 + case "arm64": + wantCPUType = 0x0100000c + } + if wantCPUType == 0 || cpuType != wantCPUType { + return fmt.Errorf("validate Codex Mach-O binary: CPU type %#x does not match %s", cpuType, goarch) + } + case "windows": + dosHeader := make([]byte, 64) + if _, err := file.ReadAt(dosHeader, 0); err != nil { + return fmt.Errorf("validate Codex PE binary: %w", err) + } + if string(dosHeader[:2]) != "MZ" { + return errors.New("validate Codex PE binary: missing MZ header") + } + peOffset := int64(binary.LittleEndian.Uint32(dosHeader[0x3c:0x40])) + info, err := file.Stat() + if err != nil { + return fmt.Errorf("validate Codex PE binary: %w", err) + } + if peOffset < 64 || peOffset > info.Size()-6 { + return fmt.Errorf("validate Codex PE binary: invalid PE offset %d", peOffset) + } + peHeader := make([]byte, 6) + if _, err := file.ReadAt(peHeader, peOffset); err != nil { + return fmt.Errorf("validate Codex PE binary: %w", err) + } + if string(peHeader[:4]) != "PE\x00\x00" { + return errors.New("validate Codex PE binary: missing PE signature") + } + machine := binary.LittleEndian.Uint16(peHeader[4:6]) + wantMachine := uint16(0) + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "amd64": + wantMachine = 0x8664 + case "arm64": + wantMachine = 0xaa64 + } + if wantMachine == 0 || machine != wantMachine { + return fmt.Errorf("validate Codex PE binary: machine %#x does not match %s", machine, goarch) + } + default: + return fmt.Errorf("validate Codex binary: unsupported platform %s/%s", goos, goarch) + } + return nil +} + +func (i *Installer) resolvedTargetPath() (string, error) { + if targetPath := strings.TrimSpace(i.targetPath); targetPath != "" { + return targetPath, nil + } + if explicitPath := strings.TrimSpace(i.locator.resolvedExplicitPath()); explicitPath != "" { + if i.locator.isWindowsCommandShim(explicitPath) { + return i.locator.resolvedManagedPath() + } + return explicitPath, nil + } + return DefaultManagedPath(i.goos) +} + +func (i *Installer) downloadURL(platform downloadPlatform) (string, error) { + value, err := url.JoinPath(i.baseURL, platform.os, platform.arch) + if err != nil { + return "", fmt.Errorf("build Codex download URL: %w", err) + } + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("build Codex download URL: %w", err) + } + query := parsed.Query() + query.Set("package", "codex-cli") + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func resolveDownloadPlatform(goos, goarch string) (downloadPlatform, error) { + switch strings.ToLower(strings.TrimSpace(goos)) { + case "linux": + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "amd64": + return downloadPlatform{os: "linux", arch: "amd64", archiveBinary: "codex-x86_64-unknown-linux-musl"}, nil + case "arm64": + return downloadPlatform{os: "linux", arch: "arm64", archiveBinary: "codex-aarch64-unknown-linux-musl"}, nil + } + case "darwin", "macos": + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "amd64": + return downloadPlatform{os: "macos", arch: "x64", archiveBinary: "codex-x86_64-apple-darwin"}, nil + case "arm64": + return downloadPlatform{os: "macos", arch: "arm64", archiveBinary: "codex-aarch64-apple-darwin"}, nil + } + case "windows": + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "amd64", "arm64": + return downloadPlatform{os: "windows", arch: strings.ToLower(strings.TrimSpace(goarch))}, nil + } + } + return downloadPlatform{}, fmt.Errorf("%w: %s/%s", ErrUnsupportedPlatform, goos, goarch) +} + +// SupportedPlatform reports whether the managed download service publishes a Codex CLI build for the platform. +func SupportedPlatform(goos, goarch string) bool { + _, err := resolveDownloadPlatform(goos, goarch) + return err == nil +} diff --git a/internal/codexcli/installer_test.go b/internal/codexcli/installer_test.go new file mode 100644 index 00000000..898b3ba3 --- /dev/null +++ b/internal/codexcli/installer_test.go @@ -0,0 +1,404 @@ +package codexcli + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/binary" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" +) + +func TestResolveDownloadPlatform(t *testing.T) { + tests := []struct { + goos string + goarch string + wantOS string + wantArch string + wantBin string + wantErr bool + }{ + {goos: "linux", goarch: "amd64", wantOS: "linux", wantArch: "amd64", wantBin: "codex-x86_64-unknown-linux-musl"}, + {goos: "linux", goarch: "arm64", wantOS: "linux", wantArch: "arm64", wantBin: "codex-aarch64-unknown-linux-musl"}, + {goos: "darwin", goarch: "amd64", wantOS: "macos", wantArch: "x64", wantBin: "codex-x86_64-apple-darwin"}, + {goos: "darwin", goarch: "arm64", wantOS: "macos", wantArch: "arm64", wantBin: "codex-aarch64-apple-darwin"}, + {goos: "windows", goarch: "amd64", wantOS: "windows", wantArch: "amd64"}, + {goos: "windows", goarch: "arm64", wantOS: "windows", wantArch: "arm64"}, + {goos: "freebsd", goarch: "amd64", wantErr: true}, + {goos: "linux", goarch: "386", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.goos+"_"+tt.goarch, func(t *testing.T) { + got, err := resolveDownloadPlatform(tt.goos, tt.goarch) + if tt.wantErr { + if err == nil { + t.Fatal("resolveDownloadPlatform() error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("resolveDownloadPlatform() error = %v", err) + } + if got.os != tt.wantOS || got.arch != tt.wantArch || got.archiveBinary != tt.wantBin { + t.Fatalf("resolveDownloadPlatform() = %+v, want %s/%s binary %q", got, tt.wantOS, tt.wantArch, tt.wantBin) + } + }) + } +} + +func TestInstallerEnsureSkipsDownloadWhenEnvBinaryExists(t *testing.T) { + target := writeExecutable(t, filepath.Join(t.TempDir(), "codex-existing"), "existing") + t.Setenv(EnvBinaryPath, target) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests.Add(1) + })) + defer server.Close() + + status, err := NewInstaller(InstallerOptions{BaseURL: server.URL}).Ensure(context.Background()) + if err != nil { + t.Fatalf("Ensure() error = %v", err) + } + if !status.Installed || status.Path != target { + t.Fatalf("Ensure() status = %+v, want installed at %q", status, target) + } + if got := requests.Load(); got != 0 { + t.Fatalf("download requests = %d, want 0", got) + } +} + +func TestInstallerEnsureInstallsUnixArchiveAtEnvPath(t *testing.T) { + target := filepath.Join(t.TempDir(), "custom", "codex") + t.Setenv(EnvBinaryPath, target) + binaryPayload := testMachOBinary("arm64") + payload := unixCodexArchive(t, []archiveEntry{{name: "codex-aarch64-apple-darwin", body: string(binaryPayload)}}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/macos/arm64" || r.URL.Query().Get("package") != "codex-cli" { + t.Errorf("request URL = %s, want /macos/arm64?package=codex-cli", r.URL.String()) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer server.Close() + + installer := NewInstaller(InstallerOptions{BaseURL: server.URL, GOOS: "darwin", GOARCH: "arm64"}) + status, err := installer.Ensure(context.Background()) + if err != nil { + t.Fatalf("Ensure() error = %v", err) + } + if !status.Installed || status.Path != target { + t.Fatalf("Ensure() status = %+v, want installed at %q", status, target) + } + assertFileContent(t, target, string(binaryPayload)) + info, err := os.Stat(target) + if err != nil { + t.Fatalf("Stat(%q) error = %v", target, err) + } + if info.Mode()&0o111 == 0 { + t.Fatalf("installed mode = %v, want executable", info.Mode()) + } +} + +func TestInstallerEnsureInstallsWindowsExecutable(t *testing.T) { + target := filepath.Join(t.TempDir(), "codex.exe") + t.Setenv(EnvBinaryPath, target) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/windows/arm64" || r.URL.Query().Get("package") != "codex-cli" { + t.Errorf("request URL = %s, want /windows/arm64?package=codex-cli", r.URL.String()) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(testPEBinary("arm64")) + })) + defer server.Close() + + status, err := NewInstaller(InstallerOptions{BaseURL: server.URL, GOOS: "windows", GOARCH: "arm64"}).Ensure(context.Background()) + if err != nil { + t.Fatalf("Ensure() error = %v", err) + } + if !status.Installed || status.Path != target { + t.Fatalf("Ensure() status = %+v, want installed at %q", status, target) + } + assertFileContent(t, target, string(testPEBinary("arm64"))) +} + +func TestInstallerEnsureUsesManagedExecutableForWindowsCommandShim(t *testing.T) { + dir := t.TempDir() + shimPath := writeExecutable(t, filepath.Join(dir, "npm", "codex.cmd"), "@echo off\n") + managedPath := filepath.Join(dir, "managed", "codex.exe") + t.Setenv(EnvBinaryPath, shimPath) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/windows/amd64" || r.URL.Query().Get("package") != "codex-cli" { + t.Errorf("request URL = %s, want /windows/amd64?package=codex-cli", r.URL.String()) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(testPEBinary("amd64")) + })) + defer server.Close() + + installer := NewInstaller(InstallerOptions{ + Locator: Locator{ + ManagedPath: managedPath, + LookPath: func(string) (string, error) { + return "", os.ErrNotExist + }, + }, + BaseURL: server.URL, + GOOS: "windows", + GOARCH: "amd64", + }) + status, err := installer.Ensure(context.Background()) + if err != nil { + t.Fatalf("Ensure() error = %v", err) + } + if !status.Installed || status.Path != managedPath { + t.Fatalf("Ensure() status = %+v, want installed at %q", status, managedPath) + } + assertFileContent(t, managedPath, string(testPEBinary("amd64"))) + assertFileContent(t, shimPath, "@echo off\n") +} + +func TestInstallerEnsureCoalescesConcurrentDownloads(t *testing.T) { + target := filepath.Join(t.TempDir(), "codex") + t.Setenv(EnvBinaryPath, target) + payload := unixCodexArchive(t, []archiveEntry{{name: "codex-x86_64-unknown-linux-musl", body: string(testELFBinary("amd64"))}}) + started := make(chan struct{}) + release := make(chan struct{}) + var requests atomic.Int32 + var once sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + once.Do(func() { close(started) }) + <-release + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer server.Close() + installer := NewInstaller(InstallerOptions{BaseURL: server.URL, GOOS: "linux", GOARCH: "amd64"}) + + type result struct { + status InstallStatus + err error + } + results := make(chan result, 2) + go func() { + status, err := installer.Ensure(context.Background()) + results <- result{status: status, err: err} + }() + <-started + if status := installer.Status(); status.State != InstallStateInstalling || status.Installed { + t.Fatalf("Status() during download = %+v, want installing", status) + } + go func() { + status, err := installer.Ensure(context.Background()) + results <- result{status: status, err: err} + }() + close(release) + for range 2 { + got := <-results + if got.err != nil || !got.status.Installed { + t.Fatalf("Ensure() = %+v, %v; want installed", got.status, got.err) + } + } + if got := requests.Load(); got != 1 { + t.Fatalf("download requests = %d, want 1", got) + } +} + +func TestInstallerEnsureCanRetryAfterDownloadFailure(t *testing.T) { + target := filepath.Join(t.TempDir(), "codex") + t.Setenv(EnvBinaryPath, target) + payload := unixCodexArchive(t, []archiveEntry{{name: "codex-x86_64-unknown-linux-musl", body: string(testELFBinary("amd64"))}}) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if requests.Add(1) == 1 { + http.Error(w, "temporary failure", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer server.Close() + installer := NewInstaller(InstallerOptions{BaseURL: server.URL, GOOS: "linux", GOARCH: "amd64"}) + + if status, err := installer.Ensure(context.Background()); err == nil || status.State != InstallStateFailed { + t.Fatalf("first Ensure() = %+v, %v; want failed", status, err) + } + if status := installer.Status(); status.State != InstallStateFailed || status.Message == "" { + t.Fatalf("Status() = %+v, want retryable failure", status) + } + status, err := installer.Ensure(context.Background()) + if err != nil || !status.Installed { + t.Fatalf("second Ensure() = %+v, %v; want installed", status, err) + } + if got := requests.Load(); got != 2 { + t.Fatalf("download requests = %d, want 2", got) + } +} + +func TestInstallerEnsureRejectsInvalidPackageWithoutPartialBinary(t *testing.T) { + targetDir := t.TempDir() + target := filepath.Join(targetDir, "codex") + t.Setenv(EnvBinaryPath, target) + payload := unixCodexArchive(t, []archiveEntry{{name: "README.md", body: "not a binary"}}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer server.Close() + + status, err := NewInstaller(InstallerOptions{BaseURL: server.URL, GOOS: "linux", GOARCH: "amd64"}).Ensure(context.Background()) + if err == nil || status.State != InstallStateFailed { + t.Fatalf("Ensure() = %+v, %v; want failed", status, err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("Stat(%q) error = %v, want not exist", target, statErr) + } + matches, globErr := filepath.Glob(filepath.Join(targetDir, ".codex-install-*")) + if globErr != nil { + t.Fatalf("Glob() error = %v", globErr) + } + if len(matches) != 0 { + t.Fatalf("temporary install files = %v, want none", matches) + } +} + +func TestInstallerEnsureRejectsWrongArchitectureWithoutPartialBinary(t *testing.T) { + target := filepath.Join(t.TempDir(), "codex") + t.Setenv(EnvBinaryPath, target) + payload := unixCodexArchive(t, []archiveEntry{{ + name: "codex-x86_64-apple-darwin", + body: string(testMachOBinary("arm64")), + }}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer server.Close() + + status, err := NewInstaller(InstallerOptions{BaseURL: server.URL, GOOS: "darwin", GOARCH: "amd64"}).Ensure(context.Background()) + if err == nil || status.State != InstallStateFailed { + t.Fatalf("Ensure() = %+v, %v; want architecture validation failure", status, err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("Stat(%q) error = %v, want not exist", target, statErr) + } +} + +func TestInstallerEnsureRejectsUnsupportedPlatformBeforeDownload(t *testing.T) { + t.Setenv(EnvBinaryPath, filepath.Join(t.TempDir(), "codex")) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests.Add(1) + })) + defer server.Close() + + _, err := NewInstaller(InstallerOptions{BaseURL: server.URL, GOOS: "freebsd", GOARCH: "amd64"}).Ensure(context.Background()) + if err == nil || !errors.Is(err, ErrUnsupportedPlatform) { + t.Fatalf("Ensure() error = %v, want ErrUnsupportedPlatform", err) + } + if got := requests.Load(); got != 0 { + t.Fatalf("download requests = %d, want 0", got) + } +} + +type archiveEntry struct { + name string + body string + typeflag byte +} + +func unixCodexArchive(t *testing.T, entries []archiveEntry) []byte { + t.Helper() + var compressed bytes.Buffer + gzipWriter := gzip.NewWriter(&compressed) + tarWriter := tar.NewWriter(gzipWriter) + for _, entry := range entries { + typeflag := entry.typeflag + if typeflag == 0 { + typeflag = tar.TypeReg + } + header := &tar.Header{Name: entry.name, Mode: 0o755, Size: int64(len(entry.body)), Typeflag: typeflag} + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("WriteHeader(%q) error = %v", entry.name, err) + } + if typeflag == tar.TypeReg || typeflag == tar.TypeRegA { + if _, err := tarWriter.Write([]byte(entry.body)); err != nil { + t.Fatalf("Write(%q) error = %v", entry.name, err) + } + } + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("tar Close() error = %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("gzip Close() error = %v", err) + } + return compressed.Bytes() +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + if string(data) != want { + t.Fatalf("ReadFile(%q) = %q, want %q", path, data, want) + } +} + +func testELFBinary(arch string) []byte { + data := make([]byte, 64) + copy(data[:4], []byte{0x7f, 'E', 'L', 'F'}) + data[4] = 2 + data[5] = 1 + machine := uint16(62) + if arch == "arm64" { + machine = 183 + } + binary.LittleEndian.PutUint16(data[18:20], machine) + return data +} + +func testMachOBinary(arch string) []byte { + data := make([]byte, 32) + copy(data[:4], []byte{0xcf, 0xfa, 0xed, 0xfe}) + cpuType := uint32(0x01000007) + if arch == "arm64" { + cpuType = 0x0100000c + } + binary.LittleEndian.PutUint32(data[4:8], cpuType) + return data +} + +func testPEBinary(arch string) []byte { + data := make([]byte, 128) + copy(data[:2], "MZ") + binary.LittleEndian.PutUint32(data[0x3c:0x40], 0x40) + copy(data[0x40:0x44], "PE\x00\x00") + machine := uint16(0x8664) + if arch == "arm64" { + machine = 0xaa64 + } + binary.LittleEndian.PutUint16(data[0x44:0x46], machine) + return data +} + +func ExampleInstaller_downloadURL() { + installer := NewInstaller(InstallerOptions{BaseURL: "https://example.test/codex-cli/latest"}) + value, _ := installer.downloadURL(downloadPlatform{os: "linux", arch: "amd64"}) + fmt.Println(value) + // Output: https://example.test/codex-cli/latest/linux/amd64?package=codex-cli +} diff --git a/internal/codexcli/locator.go b/internal/codexcli/locator.go index 34304e63..2c780fdf 100644 --- a/internal/codexcli/locator.go +++ b/internal/codexcli/locator.go @@ -8,6 +8,8 @@ import ( "path/filepath" "runtime" "strings" + + "csgclaw/internal/config" ) const ( @@ -15,6 +17,7 @@ const ( EnvBinaryPath = "CSGCLAW_CODEX_PATH" EnvLegacyACPBinaryPath = "CSGCLAW_CODEX_ACP_PATH" + ManagedBinDirName = "bin" ) type Provider struct { @@ -28,6 +31,7 @@ func (p Provider) Ensure(_ context.Context) (string, error) { type Locator struct { ExplicitPath string GOOS string + ManagedPath string LookPath func(string) (string, error) Stat func(string) (os.FileInfo, error) @@ -36,19 +40,23 @@ type Locator struct { func (l Locator) Locate() (string, error) { explicit := strings.TrimSpace(l.resolvedExplicitPath()) if explicit != "" { - if path, ok, err := l.windowsPowerShellShimTarget(explicit); err != nil { + if path, ok, err := l.windowsShimTarget(explicit); err != nil { return "", err } else if ok { return path, nil } - path, ok, err := l.executablePath(explicit) - if err != nil { - return "", err - } - if ok { - return path, nil + if !l.isWindowsCommandShim(explicit) { + path, ok, err := l.executablePath(explicit) + if err != nil { + return "", err + } + if ok { + return path, nil + } + return "", fmt.Errorf("codex binary %s: %w", explicit, os.ErrNotExist) } - return "", fmt.Errorf("codex binary %s: %w", explicit, os.ErrNotExist) + // Batch shims cannot be passed directly to CreateProcess. Continue to a + // native PATH or managed executable so startup installation can recover. } if lookPath := l.lookPath(); lookPath != nil { for _, name := range l.binaryNames() { @@ -65,6 +73,19 @@ func (l Locator) Locate() (string, error) { } } } + managedPath, err := l.resolvedManagedPath() + if err != nil { + return "", err + } + if managedPath != "" { + path, ok, err := l.executablePath(managedPath) + if err != nil { + return "", err + } + if ok { + return path, nil + } + } return "", fmt.Errorf("codex binary not found; install Codex CLI or set %s: %w", EnvBinaryPath, os.ErrNotExist) } @@ -78,6 +99,25 @@ func (l Locator) resolvedExplicitPath() string { return strings.TrimSpace(os.Getenv(EnvLegacyACPBinaryPath)) } +func (l Locator) resolvedManagedPath() (string, error) { + if path := strings.TrimSpace(l.ManagedPath); path != "" { + return path, nil + } + return DefaultManagedPath(l.resolvedGOOS()) +} + +func DefaultManagedPath(goos string) (string, error) { + dir, err := config.DefaultDomainDir(ManagedBinDirName) + if err != nil { + return "", err + } + name := BinaryName + if strings.EqualFold(strings.TrimSpace(goos), "windows") { + name += ".exe" + } + return filepath.Join(dir, name), nil +} + func (l Locator) executablePath(path string) (string, bool, error) { info, err := l.stat(path) if err != nil { @@ -90,8 +130,8 @@ func (l Locator) executablePath(path string) (string, bool, error) { return "", false, nil } if l.isWindows() { - if strings.EqualFold(filepath.Ext(path), ".ps1") { - return "", false, fmt.Errorf("codex binary %s is a PowerShell shim; set %s to the matching codex.cmd or codex.exe file", path, EnvBinaryPath) + if !strings.EqualFold(filepath.Ext(path), ".exe") { + return "", false, fmt.Errorf("codex binary %s is a script shim; set %s to a native codex.exe file", path, EnvBinaryPath) } return path, true, nil } @@ -101,23 +141,30 @@ func (l Locator) executablePath(path string) (string, bool, error) { return path, true, nil } -func (l Locator) windowsPowerShellShimTarget(path string) (string, bool, error) { - if !l.isWindows() || !strings.EqualFold(filepath.Ext(path), ".ps1") { +func (l Locator) windowsShimTarget(path string) (string, bool, error) { + ext := strings.ToLower(filepath.Ext(path)) + if !l.isWindows() || (ext != ".ps1" && ext != ".cmd" && ext != ".bat") { return "", false, nil } base := strings.TrimSuffix(path, filepath.Ext(path)) - for _, candidate := range []string{base + ".cmd", base + ".exe", base + ".bat"} { - resolved, ok, err := l.executablePath(candidate) - if err != nil { - return "", false, err - } - if ok { - return resolved, true, nil - } + resolved, ok, err := l.executablePath(base + ".exe") + if err != nil { + return "", false, err + } + if ok { + return resolved, true, nil } return "", false, nil } +func (l Locator) isWindowsCommandShim(path string) bool { + if !l.isWindows() { + return false + } + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".cmd" || ext == ".bat" +} + func (l Locator) lookPath() func(string) (string, error) { if l.LookPath != nil { return l.LookPath @@ -134,17 +181,20 @@ func (l Locator) stat(path string) (os.FileInfo, error) { func (l Locator) binaryNames() []string { if l.isWindows() { - return []string{BinaryName + ".cmd", BinaryName + ".exe", BinaryName + ".bat", BinaryName} + return []string{BinaryName + ".exe"} } return []string{BinaryName} } func (l Locator) isWindows() bool { - goos := strings.TrimSpace(l.GOOS) - if goos == "" { - goos = runtime.GOOS + return l.resolvedGOOS() == "windows" +} + +func (l Locator) resolvedGOOS() string { + if goos := strings.TrimSpace(l.GOOS); goos != "" { + return strings.ToLower(goos) } - return goos == "windows" + return runtime.GOOS } func AppServerArgs() []string { diff --git a/internal/codexcli/locator_test.go b/internal/codexcli/locator_test.go index f325cc41..93bcb7bc 100644 --- a/internal/codexcli/locator_test.go +++ b/internal/codexcli/locator_test.go @@ -85,6 +85,39 @@ func TestLocatorLocateUsesPathLookup(t *testing.T) { } } +func TestLocatorLocateUsesManagedFallback(t *testing.T) { + dir := t.TempDir() + managedBinary := writeExecutable(t, filepath.Join(dir, "managed", BinaryName), "#!/bin/sh\n") + + got, err := (Locator{ + ManagedPath: managedBinary, + LookPath: func(string) (string, error) { + return "", os.ErrNotExist + }, + }).Locate() + if err != nil { + t.Fatalf("Locate() error = %v", err) + } + if got != managedBinary { + t.Fatalf("Locate() = %q, want %q", got, managedBinary) + } +} + +func TestDefaultManagedPathUsesCSGClawBinDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + got, err := DefaultManagedPath("windows") + if err != nil { + t.Fatalf("DefaultManagedPath() error = %v", err) + } + want := filepath.Join(home, ".csgclaw", "bin", "codex.exe") + if got != want { + t.Fatalf("DefaultManagedPath() = %q, want %q", got, want) + } +} + func TestLocatorLocateMissingBinary(t *testing.T) { locator := Locator{ LookPath: func(string) (string, error) { @@ -98,9 +131,8 @@ func TestLocatorLocateMissingBinary(t *testing.T) { } } -func TestLocatorWindowsPathLookupPrefersCommandShim(t *testing.T) { +func TestLocatorWindowsPathLookupUsesNativeExecutable(t *testing.T) { dir := t.TempDir() - cmdBinary := writeExecutable(t, filepath.Join(dir, "bin", "codex.cmd"), "@echo off\n") exeBinary := writeExecutable(t, filepath.Join(dir, "bin", "codex.exe"), "") var names []string @@ -108,14 +140,10 @@ func TestLocatorWindowsPathLookupPrefersCommandShim(t *testing.T) { GOOS: "windows", LookPath: func(name string) (string, error) { names = append(names, name) - switch name { - case "codex.cmd": - return cmdBinary, nil - case "codex.exe": + if name == "codex.exe" { return exeBinary, nil - default: - return "", os.ErrNotExist } + return "", os.ErrNotExist }, } @@ -123,16 +151,17 @@ func TestLocatorWindowsPathLookupPrefersCommandShim(t *testing.T) { if err != nil { t.Fatalf("Locate() error = %v", err) } - if got != cmdBinary { - t.Fatalf("Locate() = %q, want %q", got, cmdBinary) + if got != exeBinary { + t.Fatalf("Locate() = %q, want %q", got, exeBinary) } - if len(names) != 1 || names[0] != "codex.cmd" { - t.Fatalf("LookPath names = %+v, want [codex.cmd]", names) + if len(names) != 1 || names[0] != "codex.exe" { + t.Fatalf("LookPath names = %+v, want [codex.exe]", names) } } -func TestLocatorWindowsPathLookupFallsBackToExe(t *testing.T) { +func TestLocatorWindowsPathLookupIgnoresScriptShims(t *testing.T) { dir := t.TempDir() + _ = writeExecutable(t, filepath.Join(dir, "bin", "codex.cmd"), "@echo off\n") exeBinary := writeExecutable(t, filepath.Join(dir, "bin", "codex.exe"), "") var names []string @@ -154,21 +183,21 @@ func TestLocatorWindowsPathLookupFallsBackToExe(t *testing.T) { if got != exeBinary { t.Fatalf("Locate() = %q, want %q", got, exeBinary) } - if len(names) != 2 || names[0] != "codex.cmd" || names[1] != "codex.exe" { - t.Fatalf("LookPath names = %+v, want [codex.cmd codex.exe]", names) + if len(names) != 1 || names[0] != "codex.exe" { + t.Fatalf("LookPath names = %+v, want [codex.exe]", names) } } -func TestLocatorWindowsExplicitPowerShellShimUsesCommandSibling(t *testing.T) { +func TestLocatorWindowsExplicitPowerShellShimUsesNativeSibling(t *testing.T) { dir := t.TempDir() ps1Path := filepath.Join(dir, "codex.ps1") - cmdPath := writeExecutable(t, filepath.Join(dir, "codex.cmd"), "@echo off\n") + exePath := writeExecutable(t, filepath.Join(dir, "codex.exe"), "") locator := Locator{ GOOS: "windows", ExplicitPath: ps1Path, LookPath: func(string) (string, error) { - t.Fatal("LookPath should not be called when explicit PowerShell shim has command sibling") + t.Fatal("LookPath should not be called when explicit PowerShell shim has native sibling") return "", nil }, } @@ -177,8 +206,8 @@ func TestLocatorWindowsExplicitPowerShellShimUsesCommandSibling(t *testing.T) { if err != nil { t.Fatalf("Locate() error = %v", err) } - if got != cmdPath { - t.Fatalf("Locate() = %q, want %q", got, cmdPath) + if got != exePath { + t.Fatalf("Locate() = %q, want %q", got, exePath) } } @@ -195,8 +224,29 @@ func TestLocatorWindowsExplicitPowerShellShimReportsUnsupportedWhenNoSibling(t * if err == nil { t.Fatal("Locate() error = nil, want unsupported PowerShell shim error") } - if !strings.Contains(err.Error(), "PowerShell shim") || !strings.Contains(err.Error(), "codex.cmd or codex.exe") { - t.Fatalf("Locate() error = %v, want PowerShell shim guidance", err) + if !strings.Contains(err.Error(), "script shim") || !strings.Contains(err.Error(), "codex.exe") { + t.Fatalf("Locate() error = %v, want native executable guidance", err) + } +} + +func TestLocatorWindowsExplicitCommandShimFallsBackToManagedNativeExecutable(t *testing.T) { + dir := t.TempDir() + cmdPath := writeExecutable(t, filepath.Join(dir, "codex.cmd"), "@echo off\n") + managedPath := writeExecutable(t, filepath.Join(dir, "managed", "codex.exe"), "") + + got, err := (Locator{ + GOOS: "windows", + ExplicitPath: cmdPath, + ManagedPath: managedPath, + LookPath: func(string) (string, error) { + return "", os.ErrNotExist + }, + }).Locate() + if err != nil { + t.Fatalf("Locate() error = %v", err) + } + if got != managedPath { + t.Fatalf("Locate() = %q, want %q", got, managedPath) } } diff --git a/internal/runtime/codex/runtime.go b/internal/runtime/codex/runtime.go index 9ad6e6ce..4f89139d 100644 --- a/internal/runtime/codex/runtime.go +++ b/internal/runtime/codex/runtime.go @@ -520,12 +520,16 @@ func (r *Runtime) hydratePersistedSession(ctx context.Context, manager *appServe return nil, err } } + binaryPath, err := r.ensureBinary(ctx) + if err != nil { + return nil, fmt.Errorf("resolve codex binary: %w", err) + } spec := SessionSpec{ RuntimeID: runtimeID, AgentID: agentID, AgentName: firstNonEmpty(agentRef.Name, meta.AgentName), - BinaryPath: strings.TrimSpace(meta.BinaryPath), + BinaryPath: binaryPath, RuntimeDir: dirs.Root, WorkspaceDir: workspaceDir, HomeDir: r.hostSessionHomeDir(dirs.Home), diff --git a/internal/runtime/codex/runtime_test.go b/internal/runtime/codex/runtime_test.go index 2764b83d..1720bd0b 100644 --- a/internal/runtime/codex/runtime_test.go +++ b/internal/runtime/codex/runtime_test.go @@ -11,6 +11,7 @@ import ( "time" "csgclaw/internal/agent" + "csgclaw/internal/codexcli" agentruntime "csgclaw/internal/runtime" "csgclaw/internal/sandbox" ) @@ -1020,8 +1021,16 @@ func TestRuntimeSessionManagerHydratesPersistedSession(t *testing.T) { root := t.TempDir() hostHome := t.TempDir() t.Setenv("HOME", hostHome) + initialBinary := filepath.Join(t.TempDir(), "initial-codex") + explicitBinary := filepath.Join(t.TempDir(), "explicit-codex") + for _, path := range []string{initialBinary, explicitBinary} { + if err := os.WriteFile(path, []byte("codex"), 0o755); err != nil { + t.Fatalf("write test codex binary %s: %v", path, err) + } + } + t.Setenv(codexcli.EnvBinaryPath, initialBinary) deps := Dependencies{ - BinaryProvider: fakeBinaryProvider{path: "codex"}, + BinaryProvider: codexcli.Provider{}, AgentHome: func(agentName string) (string, error) { return filepath.Join(root, agentName), nil }, @@ -1076,6 +1085,7 @@ func TestRuntimeSessionManagerHydratesPersistedSession(t *testing.T) { t.Fatalf("write legacy session metadata: %v", err) } + t.Setenv(codexcli.EnvBinaryPath, explicitBinary) reloaded := New(deps) manager := reloaded.SessionManager() session, err := manager.Session(SessionHandle{RuntimeID: "rt-u-alice"}) @@ -1085,6 +1095,9 @@ func TestRuntimeSessionManagerHydratesPersistedSession(t *testing.T) { if session.SessionID != "resumed-thread" { t.Fatalf("hydrated session id = %q, want resumed-thread", session.SessionID) } + if session.BinaryPath != explicitBinary { + t.Fatalf("hydrated binary path = %q, want explicit override %q", session.BinaryPath, explicitBinary) + } if want := filepath.Join(root, "agent-alice", ".codex", "workspace"); session.WorkspaceDir != want { t.Fatalf("hydrated workspace dir = %q, want %q", session.WorkspaceDir, want) } diff --git a/internal/runtimecatalog/service.go b/internal/runtimecatalog/service.go new file mode 100644 index 00000000..eeced680 --- /dev/null +++ b/internal/runtimecatalog/service.go @@ -0,0 +1,162 @@ +package runtimecatalog + +import ( + "context" + "errors" + "fmt" + "runtime" + "strings" + + "csgclaw/internal/codexcli" +) + +const ( + RuntimeCodex = "codex" + RuntimeClaudeCode = "claude_code" + + StatusComingSoon = "coming_soon" + StatusUnsupported = "unsupported" +) + +var ( + ErrRuntimeNotFound = errors.New("agent runtime not found") + ErrInstallUnsupported = errors.New("agent runtime installation is not supported") +) + +type Runtime struct { + Name string `json:"name"` + Label string `json:"label"` + Supported bool `json:"supported"` + Installed bool `json:"installed"` + Installable bool `json:"installable"` + Status string `json:"status"` + Path string `json:"path,omitempty"` + OS string `json:"os"` + Arch string `json:"arch"` + DocsURL string `json:"docs_url,omitempty"` + Message string `json:"message,omitempty"` +} + +type Option func(*Service) + +type Service struct { + codex *codexcli.Installer + goos string + goarch string +} + +func NewService(opts ...Option) *Service { + service := &Service{ + codex: codexcli.NewInstaller(codexcli.InstallerOptions{}), + goos: runtime.GOOS, + goarch: runtime.GOARCH, + } + for _, opt := range opts { + if opt != nil { + opt(service) + } + } + return service +} + +func WithCodexInstaller(installer *codexcli.Installer) Option { + return func(service *Service) { + if installer != nil { + service.codex = installer + } + } +} + +func WithPlatform(goos, goarch string) Option { + return func(service *Service) { + if value := strings.TrimSpace(goos); value != "" { + service.goos = value + } + if value := strings.TrimSpace(goarch); value != "" { + service.goarch = value + } + } +} + +func (s *Service) List() []Runtime { + return []Runtime{s.codexRuntime(), s.claudeCodeRuntime()} +} + +func (s *Service) Install(ctx context.Context, name string) (Runtime, error) { + switch normalizeRuntimeName(name) { + case RuntimeCodex: + return s.EnsureCodex(ctx) + case RuntimeClaudeCode: + return s.claudeCodeRuntime(), fmt.Errorf("%w: %s", ErrInstallUnsupported, RuntimeClaudeCode) + default: + return Runtime{}, fmt.Errorf("%w: %s", ErrRuntimeNotFound, strings.TrimSpace(name)) + } +} + +func (s *Service) EnsureCodex(ctx context.Context) (Runtime, error) { + if s == nil || s.codex == nil { + return Runtime{}, errors.New("Codex runtime installer is not configured") + } + _, err := s.codex.Ensure(ctx) + return s.codexRuntime(), err +} + +func (s *Service) codexRuntime() Runtime { + status := codexcli.InstallStatus{State: codexcli.InstallStateFailed, Message: "Codex runtime installer is not configured"} + if s != nil && s.codex != nil { + status = s.codex.Status() + } + goos := s.resolvedGOOS() + goarch := s.resolvedGOARCH() + platformSupported := codexcli.SupportedPlatform(goos, goarch) + if !status.Installed && !platformSupported { + status.State = codexcli.InstallState(StatusUnsupported) + status.Message = fmt.Sprintf("Codex CLI auto-install is not supported on %s/%s", goos, goarch) + } + return Runtime{ + Name: RuntimeCodex, + Label: "Codex CLI", + Supported: status.Installed || platformSupported, + Installed: status.Installed, + Installable: platformSupported, + Status: string(status.State), + Path: status.Path, + OS: goos, + Arch: goarch, + DocsURL: "https://developers.openai.com/codex", + Message: status.Message, + } +} + +func (s *Service) claudeCodeRuntime() Runtime { + return Runtime{ + Name: RuntimeClaudeCode, + Label: "Claude Code", + Supported: false, + Installed: false, + Installable: false, + Status: StatusComingSoon, + OS: s.resolvedGOOS(), + Arch: s.resolvedGOARCH(), + DocsURL: "https://docs.anthropic.com/en/docs/claude-code/overview", + Message: "Claude Code runtime support is coming soon", + } +} + +func (s *Service) resolvedGOOS() string { + if s != nil && strings.TrimSpace(s.goos) != "" { + return strings.TrimSpace(s.goos) + } + return runtime.GOOS +} + +func (s *Service) resolvedGOARCH() string { + if s != nil && strings.TrimSpace(s.goarch) != "" { + return strings.TrimSpace(s.goarch) + } + return runtime.GOARCH +} + +func normalizeRuntimeName(name string) string { + return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(name)), "-", "_") +} diff --git a/internal/runtimecatalog/service_test.go b/internal/runtimecatalog/service_test.go new file mode 100644 index 00000000..a4d58e28 --- /dev/null +++ b/internal/runtimecatalog/service_test.go @@ -0,0 +1,67 @@ +package runtimecatalog + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "csgclaw/internal/codexcli" +) + +func TestServiceListReportsCodexAndClaudeCode(t *testing.T) { + target := filepath.Join(t.TempDir(), "codex") + if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + t.Setenv(codexcli.EnvBinaryPath, target) + service := NewService(WithPlatform("darwin", "arm64")) + + runtimes := service.List() + if len(runtimes) != 2 { + t.Fatalf("List() length = %d, want 2: %+v", len(runtimes), runtimes) + } + if got := runtimes[0]; got.Name != RuntimeCodex || !got.Supported || !got.Installed || !got.Installable || got.Path != target { + t.Fatalf("Codex runtime = %+v, want installed and installable", got) + } + if got := runtimes[1]; got.Name != RuntimeClaudeCode || got.Supported || got.Installed || got.Installable || got.Status != StatusComingSoon { + t.Fatalf("Claude Code runtime = %+v, want coming soon", got) + } + for _, got := range runtimes { + if got.OS != "darwin" || got.Arch != "arm64" { + t.Fatalf("runtime platform = %s/%s, want darwin/arm64: %+v", got.OS, got.Arch, got) + } + } +} + +func TestServiceListReportsMissingCodexFromEnvOverride(t *testing.T) { + t.Setenv(codexcli.EnvBinaryPath, filepath.Join(t.TempDir(), "missing-codex")) + + got := NewService().List()[0] + if got.Name != RuntimeCodex || got.Installed || got.Status != string(codexcli.InstallStateNotInstalled) { + t.Fatalf("Codex runtime = %+v, want not installed", got) + } +} + +func TestServiceInstallRejectsUnsupportedAndUnknownRuntimes(t *testing.T) { + service := NewService() + if _, err := service.Install(context.Background(), RuntimeClaudeCode); !errors.Is(err, ErrInstallUnsupported) { + t.Fatalf("Install(claude_code) error = %v, want ErrInstallUnsupported", err) + } + if _, err := service.Install(context.Background(), "unknown"); !errors.Is(err, ErrRuntimeNotFound) { + t.Fatalf("Install(unknown) error = %v, want ErrRuntimeNotFound", err) + } +} + +func TestServiceListDisablesInstallOnUnsupportedPlatform(t *testing.T) { + t.Setenv(codexcli.EnvBinaryPath, filepath.Join(t.TempDir(), "missing-codex")) + + got := NewService(WithPlatform("freebsd", "amd64")).List()[0] + if got.Supported || got.Installable || got.Installed || got.Status != StatusUnsupported { + t.Fatalf("Codex runtime = %+v, want unsupported and not installable", got) + } + if got.Message == "" { + t.Fatal("Codex unsupported runtime message is empty") + } +} diff --git a/internal/server/http.go b/internal/server/http.go index e2134724..d3f2711d 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -16,6 +16,7 @@ import ( "csgclaw/internal/im" "csgclaw/internal/llm" "csgclaw/internal/participant" + "csgclaw/internal/runtimecatalog" "csgclaw/internal/scheduledtask" "csgclaw/internal/team" hub "csgclaw/internal/template" @@ -35,6 +36,7 @@ type Options struct { Team *team.Service AgentTask *agenttask.Service ScheduledTask *scheduledtask.Service + AgentRuntimes *runtimecatalog.Service TeamAdapters *team.AdapterRegistry Upgrade *upgrade.Manager ActivityDecider api.ActivityDecider @@ -52,6 +54,7 @@ func newHandler(opts Options) *api.Handler { handler.SetTeamService(opts.Team) handler.SetAgentTaskService(opts.AgentTask) handler.SetScheduledTaskService(opts.ScheduledTask) + handler.SetAgentRuntimeService(opts.AgentRuntimes) if opts.TeamAdapters != nil { handler.SetTeamAdapterRegistry(opts.TeamAdapters) } diff --git a/web/app/src/api/agentRuntimes.ts b/web/app/src/api/agentRuntimes.ts new file mode 100644 index 00000000..30515083 --- /dev/null +++ b/web/app/src/api/agentRuntimes.ts @@ -0,0 +1,11 @@ +import { get, post } from "@/api/client"; +import { ApiEndpoints } from "@/shared/constants/api"; + +export function fetchAgentRuntimes(): Promise { + return get(ApiEndpoints.agentRuntimes, { cache: "no-store" }); +} + +export function installAgentRuntimeRequest(runtimeName: string): Promise { + const name = encodeURIComponent(String(runtimeName ?? "").trim()); + return post(`${ApiEndpoints.agentRuntimes}/${name}/install`); +} diff --git a/web/app/src/components/business/ConversationPane/ConversationThreads.css b/web/app/src/components/business/ConversationPane/ConversationThreads.css index c2a51987..b27385d0 100644 --- a/web/app/src/components/business/ConversationPane/ConversationThreads.css +++ b/web/app/src/components/business/ConversationPane/ConversationThreads.css @@ -29,13 +29,6 @@ grid-template-columns: minmax(0, 1fr) minmax(320px, 29vw); } -.chat-panel.has-agent-detail-panel { - grid-template-columns: minmax(320px, 1fr) minmax( - 520px, - min(var(--agent-detail-panel-width, 760px), calc(100% - 360px)) - ); -} - .chat-panel.has-thread-panel > .chat-header, .chat-panel.has-thread-panel > .messages, .chat-panel.has-thread-panel > .composer { @@ -43,8 +36,7 @@ min-width: 0; } -.chat-panel.has-thread-panel > .thread-panel, -.chat-panel.has-thread-panel > .agent-detail-side-panel { +.chat-panel.has-thread-panel > .thread-panel { grid-column: 2; grid-row: 1 / 4; } @@ -179,68 +171,66 @@ z-index: 3; } -.agent-detail-side-panel { - position: relative; +.agent-detail-drawer-backdrop { + z-index: var(--z-page-overlay); + background: color-mix(in srgb, var(--gray-900) 24%, transparent); + backdrop-filter: blur(2px); +} + +.csg-dialog-content.agent-detail-side-panel { + position: fixed; + z-index: calc(var(--z-page-overlay) + 1); + inset: 12px 12px 12px auto; + width: min(1120px, calc(100vw - 32px)); + max-height: none; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); - border-left: 1px solid var(--line); + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-2xl); background: var(--surface); - box-shadow: -12px 0 28px oklch(0.08 0.01 248 / 0.18); - z-index: 3; -} - -.agent-detail-resize-handle { - position: absolute; - top: 0; - bottom: 0; - left: -6px; - z-index: 5; - width: 12px; - padding: 0; - border: 0; - background: transparent; - cursor: col-resize; - touch-action: none; -} - -.agent-detail-resize-handle::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 5px; - width: 2px; - background: transparent; - transition: - background 140ms ease, - box-shadow 140ms ease; -} - -.agent-detail-resize-handle:hover::after, -.agent-detail-resize-handle:focus-visible::after, -.agent-detail-side-panel.is-resizing .agent-detail-resize-handle::after { - background: var(--primary); - box-shadow: 0 0 0 3px var(--primary-soft); -} - -.agent-detail-resize-handle:focus-visible { - outline: 0; + box-shadow: + -24px 0 64px color-mix(in srgb, var(--gray-900) 20%, transparent), + var(--shadow); + outline: none; + transform: none; } .agent-detail-side-panel-bar { min-width: 0; - min-height: 48px; - padding: 8px 12px 8px 16px; + min-height: 56px; + padding: 8px 16px; border-bottom: 1px solid var(--line); display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; align-items: center; + background: color-mix(in srgb, var(--surface) 94%, var(--panel-strong) 6%); +} + +.agent-detail-side-panel-close { + width: 40px; + min-width: 40px; + height: 40px; + min-height: 40px; +} + +.agent-detail-side-panel-title { + min-width: 0; + overflow: hidden; + color: var(--text); + font-size: 14px; + font-weight: 700; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; } .agent-detail-side-panel-body { + container: agent-detail-drawer / inline-size; + min-width: 0; min-height: 0; overflow: hidden; } @@ -265,6 +255,24 @@ justify-content: flex-start; } +@container agent-detail-drawer (max-width: 1040px) { + .agent-detail-side-panel .agent-detail-pane .profile-runtime-grid, + .agent-detail-side-panel .agent-detail-pane .profile-grid-compact, + .agent-detail-side-panel .agent-detail-pane .profile-api-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container agent-detail-drawer (max-width: 680px) { + .agent-detail-side-panel .agent-detail-pane .profile-runtime-grid, + .agent-detail-side-panel .agent-detail-pane .profile-grid-compact, + .agent-detail-side-panel .agent-detail-pane .profile-api-grid, + .agent-detail-side-panel .agent-detail-pane .profile-advanced-grid, + .agent-detail-side-panel .agent-detail-pane .agent-workspace-panels { + grid-template-columns: minmax(0, 1fr); + } +} + .thread-panel-header { min-width: 0; padding: 16px; @@ -468,42 +476,34 @@ button.thread-message-avatar:focus-visible { background: rgba(15, 23, 42, 0.3); } -@media (max-width: 1180px) { - .chat-panel.has-agent-detail-panel { - grid-template-columns: minmax(0, 1fr); - } - - .chat-panel.has-agent-detail-panel > .agent-detail-side-panel { - position: absolute; - inset: 0 0 0 auto; - width: min(var(--agent-detail-panel-width, 760px), 100%); - grid-column: auto; - grid-row: auto; - } -} - @media (max-width: 960px) { .chat-panel.has-thread-panel { grid-template-columns: minmax(0, 1fr); } - .chat-panel.has-thread-panel > .thread-panel, - .chat-panel.has-thread-panel > .agent-detail-side-panel { + .chat-panel.has-thread-panel > .thread-panel { position: absolute; inset: 0 0 0 auto; width: min(420px, 100%); grid-column: auto; grid-row: auto; } - - .agent-detail-side-panel .agent-detail-pane .entity-header { - grid-template-columns: 48px minmax(0, 1fr); - } } @media (max-width: 720px) { - .chat-panel.has-thread-panel > .thread-panel, - .chat-panel.has-thread-panel > .agent-detail-side-panel { + .chat-panel.has-thread-panel > .thread-panel { + width: 100%; + } + + .csg-dialog-content.agent-detail-side-panel { + inset: 0; width: 100%; + border-width: 0; + border-radius: 0; + } + + .agent-detail-side-panel .agent-detail-pane { + padding-right: 12px; + padding-left: 12px; } } diff --git a/web/app/src/components/business/ProfileControls/APIKeyField.tsx b/web/app/src/components/business/ProfileControls/APIKeyField.tsx index 099559db..a1581797 100644 --- a/web/app/src/components/business/ProfileControls/APIKeyField.tsx +++ b/web/app/src/components/business/ProfileControls/APIKeyField.tsx @@ -61,7 +61,9 @@ export function APIKeyField({ spellCheck={false} /> {showStoredMask ? ( - + ) : null} {stored && unchangedHint ? {unchangedHint} : null} diff --git a/web/app/src/components/business/RoomAvatar/RoomAvatar.tsx b/web/app/src/components/business/RoomAvatar/RoomAvatar.tsx index f2aad93e..8b6c485b 100644 --- a/web/app/src/components/business/RoomAvatar/RoomAvatar.tsx +++ b/web/app/src/components/business/RoomAvatar/RoomAvatar.tsx @@ -1,10 +1,8 @@ import { AgentAvatarContent } from "@/components/business/AgentAvatar"; import { avatarFallbackText } from "@/shared/avatar"; -import { localIdentitiesMatch, resolveUserByLocalIdentity } from "@/models/conversations"; -import type { IMConversation, IMUser, UsersById } from "@/models/conversations"; +import type { RoomAvatarMember } from "./roomAvatarMembers"; import "./RoomAvatar.css"; -type RoomAvatarMember = Pick; type RoomAvatarSlot = RoomAvatarMember & { placeholder?: boolean }; type RoomAvatarProps = { @@ -53,23 +51,6 @@ function buildAvatarTiles(memberCount: number): Array<{ sizeClass: string; varia ]; } -export function resolveRoomAvatarMembers( - conversation: IMConversation | null | undefined, - usersById: UsersById, - currentUserID?: string | null, -): RoomAvatarMember[] { - return (conversation?.members || []) - .filter((memberID) => !localIdentitiesMatch(memberID, currentUserID)) - .map((memberID) => resolveUserByLocalIdentity(memberID, usersById)) - .filter((member): member is IMUser => Boolean(member)) - .map((member) => ({ - id: member.id, - name: member.name || member.id, - avatar: member.avatar, - accent_hex: member.accent_hex, - })); -} - export function RoomAvatar({ ariaLabel, count, members, size = 32, showCountBadge = true }: RoomAvatarProps) { const totalCount = count ?? members.length; const visibleMembers = members.slice(0, 4); diff --git a/web/app/src/components/business/RoomAvatar/index.ts b/web/app/src/components/business/RoomAvatar/index.ts index e5ce9e82..cb1c700d 100644 --- a/web/app/src/components/business/RoomAvatar/index.ts +++ b/web/app/src/components/business/RoomAvatar/index.ts @@ -1 +1,2 @@ export * from "./RoomAvatar"; +export * from "./roomAvatarMembers"; diff --git a/web/app/src/components/business/RoomAvatar/roomAvatarMembers.ts b/web/app/src/components/business/RoomAvatar/roomAvatarMembers.ts new file mode 100644 index 00000000..2eb89f54 --- /dev/null +++ b/web/app/src/components/business/RoomAvatar/roomAvatarMembers.ts @@ -0,0 +1,21 @@ +import { localIdentitiesMatch, resolveUserByLocalIdentity } from "@/models/conversations"; +import type { IMConversation, IMUser, UsersById } from "@/models/conversations"; + +export type RoomAvatarMember = Pick; + +export function resolveRoomAvatarMembers( + conversation: IMConversation | null | undefined, + usersById: UsersById, + currentUserID?: string | null, +): RoomAvatarMember[] { + return (conversation?.members || []) + .filter((memberID) => !localIdentitiesMatch(memberID, currentUserID)) + .map((memberID) => resolveUserByLocalIdentity(memberID, usersById)) + .filter((member): member is IMUser => Boolean(member)) + .map((member) => ({ + id: member.id, + name: member.name || member.id, + avatar: member.avatar, + accent_hex: member.accent_hex, + })); +} diff --git a/web/app/src/hooks/workspace/WorkspaceControllerContext.tsx b/web/app/src/hooks/workspace/WorkspaceControllerContext.tsx index 6c38d9e6..627233a4 100644 --- a/web/app/src/hooks/workspace/WorkspaceControllerContext.tsx +++ b/web/app/src/hooks/workspace/WorkspaceControllerContext.tsx @@ -1,10 +1,8 @@ -import { createContext, useContext } from "react"; import type { ReactNode } from "react"; -import type { useWorkspaceController } from "./useWorkspaceController"; +import { WorkspaceControllerContext } from "./workspaceControllerContextValue"; +import type { WorkspaceController } from "./workspaceControllerContextValue"; -export type WorkspaceController = ReturnType; - -const WorkspaceControllerContext = createContext(null); +export type { WorkspaceController } from "./workspaceControllerContextValue"; export function WorkspaceControllerProvider({ controller, @@ -15,11 +13,3 @@ export function WorkspaceControllerProvider({ }) { return {children}; } - -export function useWorkspaceControllerContext(): WorkspaceController { - const controller = useContext(WorkspaceControllerContext); - if (!controller) { - throw new Error("Workspace controller is not available."); - } - return controller; -} diff --git a/web/app/src/hooks/workspace/index.ts b/web/app/src/hooks/workspace/index.ts index 0380cc49..219d579a 100644 --- a/web/app/src/hooks/workspace/index.ts +++ b/web/app/src/hooks/workspace/index.ts @@ -7,6 +7,7 @@ export * from "./useProfilePreviewController"; export * from "./useProfileModelOptions"; export * from "./useUpgradeController"; export * from "./useWorkspaceController"; +export * from "./useWorkspaceControllerContext"; export * from "./useWorkspaceData"; export * from "./useWorkspaceHubController"; export * from "./useWorkspaceHubSelection"; diff --git a/web/app/src/hooks/workspace/types.ts b/web/app/src/hooks/workspace/types.ts index e5285904..c51f0037 100644 --- a/web/app/src/hooks/workspace/types.ts +++ b/web/app/src/hooks/workspace/types.ts @@ -267,6 +267,4 @@ export type UseAgentControllerArgs = { export type AgentDetailSidePanelProps = AgentDetailPaneProps & { onClose: () => void; - onResize?: (width: number) => void; - width?: number; }; diff --git a/web/app/src/hooks/workspace/useAgentController.ts b/web/app/src/hooks/workspace/useAgentController.ts index 8ccf368d..fac798a7 100644 --- a/web/app/src/hooks/workspace/useAgentController.ts +++ b/web/app/src/hooks/workspace/useAgentController.ts @@ -78,6 +78,7 @@ import { partitionWorkspaceAgentItems, pickDefaultAgentTemplate, resolveAgentAvatarSource, + resolveAgentAvatarUserID, normalizeRuntimeKind, profileSelectorFromDraft, providerNeedsAuth, @@ -147,6 +148,8 @@ const FEISHU_REGISTRATION_DEFAULT_POLL_SECONDS = 3; const FEISHU_REGISTRATION_MIN_POLL_SECONDS = 1; const FEISHU_REGISTRATION_MAX_POLL_SECONDS = 30; const AGENT_CREATE_NAME_RETRY_LIMIT = 20; +const noopRefreshWorkspaceModelProviders = async (): Promise => null; +const noopSelectModelProvider = (): void => undefined; function feishuActionKey(agentID: string, action: FeishuActionKind): string { return `${agentID}:${FEISHU_CHANNEL_ACTION}:${action}`; @@ -414,13 +417,13 @@ export function useAgentController({ refreshWorkspaceBootstrap, refreshWorkspaceBootstrapConfig, refreshWorkspaceManagerProfile, - refreshWorkspaceModelProviders = async () => null, + refreshWorkspaceModelProviders = noopRefreshWorkspaceModelProviders, rooms, selectAgent, selectComputer, selectConversation, selectHub, - selectModelProvider = () => {}, + selectModelProvider = noopSelectModelProvider, setAgentsData, setBootstrapData, setSelectedHubTemplateId, @@ -504,7 +507,7 @@ export function useAgentController({ item: AgentLike | null | undefined, avatar: string | null | undefined, ): Promise { - const userID = resolveAgentChannelUserID(item); + const userID = resolveAgentAvatarUserID(item, usersById); const nextAvatar = String(avatar || "").trim(); if (!userID || !nextAvatar) { return; @@ -573,6 +576,8 @@ export function useAgentController({ enable_fast_mode: selectedAgentForPage.enable_fast_mode ?? profile?.enable_fast_mode ?? false, }); }, [selectedAgentForPage]); + const selectedAgentForPageRef = useRef(selectedAgentForPage); + selectedAgentForPageRef.current = selectedAgentForPage; const selectedFeishuPendingRegistration = useMemo(() => { const agentID = String(selectedAgentForPage?.id || "").trim(); if (!agentID) { @@ -662,6 +667,66 @@ export function useAgentController({ const resetAgentPageModels = useCallback(() => { void refreshWorkspaceModelProviders(); }, [refreshWorkspaceModelProviders]); + const fetchAgentWithProfile = useCallback(async (item: AgentLike | null | undefined): Promise => { + const id = String(item?.id ?? "").trim(); + if (!id) { + return { agent: item || {}, profile: item?.agent_profile }; + } + let agent: AgentLike = item || {}; + const [fetchedAgent, fetchedProfile] = await Promise.all([ + Promise.resolve(fetchAgent(id)).catch(() => null), + Promise.resolve(fetchAgentProfile(id)).catch(() => null), + ]); + if (fetchedAgent) { + agent = { ...agent, ...fetchedAgent }; + } + const profile = fetchedProfile ?? agent?.agent_profile; + return { agent, profile }; + }, []); + const agentDraftFromItem = useCallback( + async (item: AgentLike): Promise => { + if (isNotificationBotAgent(item)) { + return ensureNotifierPullSubscriptionDraft(agentToDraft(item)); + } + const { agent, profile } = await fetchAgentWithProfile(item); + const base = agentToDraft({ ...agent, agent_profile: profile }); + const runtimeKind = normalizeRuntimeKind(agent?.runtime_kind || item?.runtime_kind || base.runtime_kind); + return ensureNotifierPullSubscriptionDraft({ + ...base, + runtime_kind: runtimeKind || base.runtime_kind, + bot_type: BOT_TYPE_NORMAL, + }); + }, + [fetchAgentWithProfile], + ); + const loadAgentPageDraft = useCallback( + async (item: AgentLike | null | undefined, loadSeq: number): Promise => { + if (!item?.id) { + return; + } + const requestID = agentPageDraftRequestRef.current + 1; + agentPageDraftRequestRef.current = requestID; + setAgentPageError(""); + resetAgentPageModels(); + const fallbackDraft = ensureNotifierPullSubscriptionDraft(agentToDraft(item)); + setAgentPageDraft(fallbackDraft); + setAgentPageSavedDraft(fallbackDraft); + try { + const draft = await agentDraftFromItem(item); + if (agentPageDraftLoadSeqRef.current !== loadSeq || agentPageDraftRequestRef.current !== requestID) { + return; + } + setAgentPageDraft(draft); + setAgentPageSavedDraft(draft); + } catch (err) { + if (agentPageDraftLoadSeqRef.current !== loadSeq || agentPageDraftRequestRef.current !== requestID) { + return; + } + setAgentPageError(errorMessage(err, t("agentActionFailed"))); + } + }, + [agentDraftFromItem, resetAgentPageModels, t], + ); const { cliproxyAuthStatuses, setCLIProxyAuthStatus } = useCLIProxyAuthStatuses( [ managerProfile?.provider, @@ -793,7 +858,8 @@ export function useAgentController({ }, [skillsAgentID]); useEffect(() => { - if (!selectedAgentForPage) { + const selectedAgent = selectedAgentForPageRef.current; + if (!selectedAgent) { agentPageDraftLoadSeqRef.current += 1; setAgentPageDraft(null); setAgentPageSavedDraft(null); @@ -806,11 +872,11 @@ export function useAgentController({ } const loadSeq = agentPageDraftLoadSeqRef.current + 1; agentPageDraftLoadSeqRef.current = loadSeq; - const draft = ensureNotifierPullSubscriptionDraft(agentToDraft(selectedAgentForPage)); + const draft = ensureNotifierPullSubscriptionDraft(agentToDraft(selectedAgent)); setAgentPageDraft(draft); setAgentPageSavedDraft(draft); - void loadAgentPageDraft(selectedAgentForPage, loadSeq); - }, [agentPageHasUnsavedChanges, selectedAgentForPage?.id, selectedAgentForPageDraftSignature]); + void loadAgentPageDraft(selectedAgent, loadSeq); + }, [agentPageHasUnsavedChanges, loadAgentPageDraft, selectedAgentForPage?.id, selectedAgentForPageDraftSignature]); async function refreshManagerProfile(): Promise { await refreshWorkspaceManagerProfile(); @@ -1074,37 +1140,6 @@ export function useAgentController({ await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.agentSkills(id) }); } - async function fetchAgentWithProfile(item: AgentLike | null | undefined): Promise { - const id = String(item?.id ?? "").trim(); - if (!id) { - return { agent: item || {}, profile: item?.agent_profile }; - } - let agent: AgentLike = item || {}; - const [fetchedAgent, fetchedProfile] = await Promise.all([ - Promise.resolve(fetchAgent(id)).catch(() => null), - Promise.resolve(fetchAgentProfile(id)).catch(() => null), - ]); - if (fetchedAgent) { - agent = { ...agent, ...fetchedAgent }; - } - const profile = fetchedProfile ?? agent?.agent_profile; - return { agent, profile }; - } - - async function agentDraftFromItem(item: AgentLike): Promise { - if (isNotificationBotAgent(item)) { - return ensureNotifierPullSubscriptionDraft(agentToDraft(item)); - } - const { agent, profile } = await fetchAgentWithProfile(item); - const base = agentToDraft({ ...agent, agent_profile: profile }); - const runtimeKind = normalizeRuntimeKind(agent?.runtime_kind || item?.runtime_kind || base.runtime_kind); - return ensureNotifierPullSubscriptionDraft({ - ...base, - runtime_kind: runtimeKind || base.runtime_kind, - bot_type: BOT_TYPE_NORMAL, - }); - } - async function openCreateNotificationParticipantModal(): Promise { setAgentModalMode("create"); setAgentCreateBotKind(BOT_CREATE_KIND_NOTIFICATION); @@ -1246,32 +1281,6 @@ export function useAgentController({ } } - async function loadAgentPageDraft(item: AgentLike | null | undefined, loadSeq: number): Promise { - if (!item?.id) { - return; - } - const requestID = agentPageDraftRequestRef.current + 1; - agentPageDraftRequestRef.current = requestID; - setAgentPageError(""); - resetAgentPageModels(); - const fallbackDraft = ensureNotifierPullSubscriptionDraft(agentToDraft(item)); - setAgentPageDraft(fallbackDraft); - setAgentPageSavedDraft(fallbackDraft); - try { - const draft = await agentDraftFromItem(item); - if (agentPageDraftLoadSeqRef.current !== loadSeq || agentPageDraftRequestRef.current !== requestID) { - return; - } - setAgentPageDraft(draft); - setAgentPageSavedDraft(draft); - } catch (err) { - if (agentPageDraftLoadSeqRef.current !== loadSeq || agentPageDraftRequestRef.current !== requestID) { - return; - } - setAgentPageError(errorMessage(err, t("agentActionFailed"))); - } - } - function normalizeDraftForCompare(draft: AgentDraft | null | undefined): AgentDraft | null { if (!draft) { return null; @@ -1366,10 +1375,11 @@ export function useAgentController({ payload.runtime_options = runtimeOptions; } const saved = await patchNotificationBotRequest(selectedAgentForPage.id, payload); + await saveLinkedAgentUserAvatar(selectedAgentForPage, draft.avatar); await refreshAgents(); await refreshWorkspaceBootstrap(); await refreshAgentSkills(saved.id || selectedAgentForPage.id); - const savedDraft = agentToDraft(saved); + const savedDraft = agentToDraft({ ...saved, avatar: draft.avatar }); setAgentPageDraft(savedDraft); setAgentPageSavedDraft(savedDraft); return; @@ -1404,7 +1414,7 @@ export function useAgentController({ await refreshManagerProfile(); } await refreshAgentSkills(savedMetaOnly.id || selectedAgentForPage.id); - const nextDraft = await agentDraftFromItem(savedMetaOnly); + const nextDraft = await agentDraftFromItem({ ...savedMetaOnly, avatar: draft.avatar }); setAgentPageDraft(nextDraft); setAgentPageSavedDraft(nextDraft); return; @@ -1420,8 +1430,9 @@ export function useAgentController({ const saved = await updateAgentRequest(selectedAgentForPage.id, payload); await saveLinkedAgentUserAvatar(selectedAgentForPage, draft.avatar); if (canApplyAgentPageProfileSaveImmediately(saved, profileChanged, runtimeOptionsChanged)) { - applyAgentListUpdate(saved); - const savedDraft = agentToDraft(saved); + const savedWithAvatar = { ...saved, avatar: draft.avatar }; + applyAgentListUpdate(savedWithAvatar); + const savedDraft = agentToDraft(savedWithAvatar); setAgentPageDraft(savedDraft); setAgentPageSavedDraft(savedDraft); return; @@ -1435,7 +1446,7 @@ export function useAgentController({ await refreshManagerProfile(); } await refreshAgentSkills(saved.id || selectedAgentForPage.id); - const savedDraft = await agentDraftFromItem(saved); + const savedDraft = await agentDraftFromItem({ ...saved, avatar: draft.avatar }); setAgentPageDraft(savedDraft); setAgentPageSavedDraft(savedDraft); if ( diff --git a/web/app/src/hooks/workspace/useAuthController.ts b/web/app/src/hooks/workspace/useAuthController.ts index 0c171e2a..b5eb1f09 100644 --- a/web/app/src/hooks/workspace/useAuthController.ts +++ b/web/app/src/hooks/workspace/useAuthController.ts @@ -181,7 +181,9 @@ export function useAuthController(t: TranslateFn): AuthController { avatar: status.avatar, avatarFallback: avatarFallbackText(status.user_id, status.user_uuid, t("csghubSignedIn")), title: t("csghubSignedIn"), - message: environment ? t("csghubLoginEnvironmentCompleted", { user, environment }) : t("csghubLoginCompleted", { user }), + message: environment + ? t("csghubLoginEnvironmentCompleted", { user, environment }) + : t("csghubLoginCompleted", { user }), type: "login", tone: "success", }); diff --git a/web/app/src/hooks/workspace/useWorkspaceController.ts b/web/app/src/hooks/workspace/useWorkspaceController.ts index 628661c9..f6e9e1f2 100644 --- a/web/app/src/hooks/workspace/useWorkspaceController.ts +++ b/web/app/src/hooks/workspace/useWorkspaceController.ts @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { errorMessage } from "@/api/client"; import { checkModelProvider, createModelProvider, type ModelProviderPayload } from "@/api/modelProviders"; @@ -34,17 +33,6 @@ import type { HubTemplate } from "@/models/hubWorkspace"; import type { IMConversation, IMData, IMUser } from "@/models/conversations"; import type { SkillSummary } from "@/models/skillhub"; -const DEFAULT_AGENT_DETAIL_PANEL_WIDTH = 760; -const MIN_AGENT_DETAIL_PANEL_WIDTH = 520; -const MAX_AGENT_DETAIL_PANEL_WIDTH = 1120; - -function clampAgentDetailPanelWidth(width: number): number { - if (!Number.isFinite(width)) { - return DEFAULT_AGENT_DETAIL_PANEL_WIDTH; - } - return Math.min(MAX_AGENT_DETAIL_PANEL_WIDTH, Math.max(MIN_AGENT_DETAIL_PANEL_WIDTH, Math.round(width))); -} - function isBootstrapAdminUser(user: IMUser | null | undefined) { return user?.id === "u-admin" || String(user?.name ?? "").toLowerCase() === "admin"; } @@ -194,9 +182,7 @@ export function useWorkspaceController() { const activePane = useMemo(() => paneFromLocation(location.pathname), [location.pathname]); const rooms = useMemo(() => displayData?.rooms ?? [], [displayData]); const [conversationProfileDetailAgentID, setConversationProfileDetailAgentID] = useState(""); - const [conversationAgentDetailPanelWidth, setConversationAgentDetailPanelWidth] = useState( - DEFAULT_AGENT_DETAIL_PANEL_WIDTH, - ); + const conversationProfileDetailTriggerRef = useRef(null); const loadingError = bootstrapQuery.isError ? t("loadingFailed") : ""; const { navigatePane, @@ -293,16 +279,28 @@ export function useWorkspaceController() { setSelectedHubTemplateId, t, }); - const closeConversationAgentDetail = useCallback(() => { - if (!conversationProfileDetailAgentID) { + const closeConversationAgentDetail = useCallback( + (restoreFocus = true) => { + if (!conversationProfileDetailAgentID) { + return true; + } + if (agent.agentViewProps.hasUnsavedChanges && !window.confirm(t("agentUnsavedChangesWarning"))) { + return false; + } + const trigger = conversationProfileDetailTriggerRef.current; + conversationProfileDetailTriggerRef.current = null; + setConversationProfileDetailAgentID(""); + if (restoreFocus && trigger) { + window.setTimeout(() => { + if (trigger.isConnected) { + trigger.focus(); + } + }, 0); + } return true; - } - if (agent.agentViewProps.hasUnsavedChanges && !window.confirm(t("agentUnsavedChangesWarning"))) { - return false; - } - setConversationProfileDetailAgentID(""); - return true; - }, [agent.agentViewProps.hasUnsavedChanges, conversationProfileDetailAgentID, t]); + }, + [agent.agentViewProps.hasUnsavedChanges, conversationProfileDetailAgentID, t], + ); const managerDirectConversation = useMemo( () => resolveManagerDirectConversation(rooms, displayData?.current_user_id ?? "", agent.managerAgent), [agent.managerAgent, displayData?.current_user_id, rooms], @@ -390,18 +388,20 @@ export function useWorkspaceController() { }); const closeThreadPanel = conversation.conversationViewProps.onCloseThread; const openConversationAgentDetail = useCallback( - (item: AgentLike | null | undefined) => { + (item: AgentLike | null | undefined, trigger?: HTMLElement | null) => { const agentID = String(item?.id || "").trim(); if (!agentID) { return false; } if (conversationProfileDetailAgentID && conversationProfileDetailAgentID !== agentID) { - const closed = closeConversationAgentDetail(); + const closed = closeConversationAgentDetail(false); if (!closed) { return false; } } closeThreadPanel(); + conversationProfileDetailTriggerRef.current = + trigger ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null); setConversationProfileDetailAgentID(agentID); return true; }, @@ -431,8 +431,8 @@ export function useWorkspaceController() { }); const closeProfilePreview = profilePreview.closeProfilePreview; const openConversationAgentDetailFromAvatar = useCallback( - (item: AgentLike | null | undefined) => { - const opened = openConversationAgentDetail(item); + (item: AgentLike | null | undefined, trigger: HTMLElement) => { + const opened = openConversationAgentDetail(item, trigger); if (!opened) { return; } @@ -596,25 +596,31 @@ export function useWorkspaceController() { } : null; - function selectHubTemplate(item: HubTemplate | null | undefined) { - if (!item?.id) { - selectHub(); - return; - } - setSelectedHubResourceType("template"); - setSelectedHubTemplateId(item.id); - navigatePane({ type: WorkspacePaneTypes.hub, id: item.id, resourceType: "template" }, rooms); - } + const selectHubTemplate = useCallback( + (item: HubTemplate | null | undefined) => { + if (!item?.id) { + selectHub(); + return; + } + setSelectedHubResourceType("template"); + setSelectedHubTemplateId(item.id); + navigatePane({ type: WorkspacePaneTypes.hub, id: item.id, resourceType: "template" }, rooms); + }, + [navigatePane, rooms, selectHub, setSelectedHubResourceType, setSelectedHubTemplateId], + ); - function selectHubSkill(item: SkillSummary | null | undefined) { - if (!item?.name) { - selectHub(); - return; - } - setSelectedHubResourceType("skill"); - setSelectedHubSkillName(item.name); - navigatePane({ type: WorkspacePaneTypes.hub, id: item.name, resourceType: "skill" }, rooms); - } + const selectHubSkill = useCallback( + (item: SkillSummary | null | undefined) => { + if (!item?.name) { + selectHub(); + return; + } + setSelectedHubResourceType("skill"); + setSelectedHubSkillName(item.name); + navigatePane({ type: WorkspacePaneTypes.hub, id: item.name, resourceType: "skill" }, rooms); + }, + [navigatePane, rooms, selectHub, setSelectedHubResourceType, setSelectedHubSkillName], + ); function openCreateModelProviderModal() { setCreateModelProviderError(""); @@ -680,9 +686,7 @@ export function useWorkspaceController() { ready: false, loadingText: loadingError || t("loading"), activePane, - mainPanelHasAgentDetail: false, mainPanelHasThread: false, - mainPanelStyle: undefined, modelProviders, modelProvidersLoaded, refreshWorkspaceModelProviders, @@ -697,28 +701,16 @@ export function useWorkspaceController() { activeRoom: conversation.activeChannel, item: agent.agentViewProps.item, onClose: closeConversationAgentDetail, - onResize: (width: number) => setConversationAgentDetailPanelWidth(clampAgentDetailPanelWidth(width)), - width: conversationAgentDetailPanelWidth, } : null; - const mainPanelHasAgentDetail = Boolean(conversationAgentDetailPanelProps && conversation.selectedConversation); - const mainPanelHasSidePanel = Boolean( - (conversation.activeThreadRootID || conversationAgentDetailPanelProps) && conversation.selectedConversation, - ); - const mainPanelStyle: CSSProperties | undefined = mainPanelHasAgentDetail - ? ({ - "--agent-detail-panel-width": `${conversationAgentDetailPanelWidth}px`, - } as CSSProperties) - : undefined; + const mainPanelHasThread = Boolean(conversation.activeThreadRootID && conversation.selectedConversation); return { ready: true, loadingText: "", t, shellClassName: shell.shellClassName, - mainPanelHasThread: mainPanelHasSidePanel, - mainPanelHasAgentDetail, - mainPanelStyle, + mainPanelHasThread, activePane, modelProviders, modelProvidersLoaded, diff --git a/web/app/src/hooks/workspace/useWorkspaceControllerContext.ts b/web/app/src/hooks/workspace/useWorkspaceControllerContext.ts new file mode 100644 index 00000000..85c6e0c9 --- /dev/null +++ b/web/app/src/hooks/workspace/useWorkspaceControllerContext.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { WorkspaceControllerContext } from "./workspaceControllerContextValue"; +import type { WorkspaceController } from "./workspaceControllerContextValue"; + +export function useWorkspaceControllerContext(): WorkspaceController { + const controller = useContext(WorkspaceControllerContext); + if (!controller) { + throw new Error("Workspace controller is not available."); + } + return controller; +} diff --git a/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts b/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts index 1567b9d7..2f233e36 100644 --- a/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts +++ b/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts @@ -234,8 +234,12 @@ export function useWorkspaceHubSelection({ remoteSkillsEnabled && officialSkillsQuery.error ? errorMessage(officialSkillsQuery.error, t("resourcesSkillRemoteSkillsLoadFailed")) : ""; - const skillTreeError = skillTreeQuery.error ? errorMessage(skillTreeQuery.error, t("resourcesSkillFilesLoadFailed")) : ""; - const skillFileError = skillFileQuery.error ? errorMessage(skillFileQuery.error, t("resourcesSkillFileLoadFailed")) : ""; + const skillTreeError = skillTreeQuery.error + ? errorMessage(skillTreeQuery.error, t("resourcesSkillFilesLoadFailed")) + : ""; + const skillFileError = skillFileQuery.error + ? errorMessage(skillFileQuery.error, t("resourcesSkillFileLoadFailed")) + : ""; const retry = useCallback(async () => { if (refreshTemplates) { diff --git a/web/app/src/hooks/workspace/workspaceControllerContextValue.ts b/web/app/src/hooks/workspace/workspaceControllerContextValue.ts new file mode 100644 index 00000000..f79f53c0 --- /dev/null +++ b/web/app/src/hooks/workspace/workspaceControllerContextValue.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import type { useWorkspaceController } from "./useWorkspaceController"; + +export type WorkspaceController = ReturnType; + +export const WorkspaceControllerContext = createContext(null); diff --git a/web/app/src/hooks/workspace/workspaceQueries.ts b/web/app/src/hooks/workspace/workspaceQueries.ts index d88097a2..7404d489 100644 --- a/web/app/src/hooks/workspace/workspaceQueries.ts +++ b/web/app/src/hooks/workspace/workspaceQueries.ts @@ -32,6 +32,7 @@ const WORKSPACE_QUERY_SCOPE = "workspace"; export const workspaceQueryKeys = { bootstrap: () => [WORKSPACE_QUERY_SCOPE, "bootstrap"] as const, bootstrapConfig: () => [WORKSPACE_QUERY_SCOPE, "bootstrap-config"] as const, + agentRuntimes: () => [WORKSPACE_QUERY_SCOPE, "agent-runtimes"] as const, managerProfile: () => [WORKSPACE_QUERY_SCOPE, "manager-profile"] as const, agents: () => [WORKSPACE_QUERY_SCOPE, "agents"] as const, teams: () => [WORKSPACE_QUERY_SCOPE, "teams"] as const, diff --git a/web/app/src/models/agentRuntimes.ts b/web/app/src/models/agentRuntimes.ts new file mode 100644 index 00000000..2b64c120 --- /dev/null +++ b/web/app/src/models/agentRuntimes.ts @@ -0,0 +1,131 @@ +export const AgentRuntimeStatuses = { + installed: "installed", + notInstalled: "not_installed", + installing: "installing", + failed: "failed", + comingSoon: "coming_soon", + unsupported: "unsupported", +} as const; + +export type AgentRuntimeStatus = (typeof AgentRuntimeStatuses)[keyof typeof AgentRuntimeStatuses]; + +export type AgentRuntime = { + name: string; + label: string; + supported: boolean; + installed: boolean; + installable: boolean; + status: AgentRuntimeStatus; + path?: string; + os?: string; + arch?: string; + docsURL?: string; + message?: string; +}; + +const runtimeOrder = new Map([ + ["codex", 0], + ["claude_code", 1], +]); + +const knownStatuses = new Set(Object.values(AgentRuntimeStatuses)); + +export function normalizeAgentRuntimeName(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replaceAll("-", "_"); +} + +export function normalizeAgentRuntime(value: unknown): AgentRuntime | null { + const record = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; + const name = normalizeAgentRuntimeName(record.name); + if (!name) { + return null; + } + + const installed = Boolean(record.installed); + const supported = typeof record.supported === "boolean" ? record.supported : name === "codex"; + const rawStatus = String(record.status ?? "").trim() as AgentRuntimeStatus; + const status = installed + ? AgentRuntimeStatuses.installed + : knownStatuses.has(rawStatus) + ? rawStatus + : !supported + ? AgentRuntimeStatuses.comingSoon + : AgentRuntimeStatuses.notInstalled; + + return { + name, + label: stringValue(record.label) || fallbackRuntimeLabel(name), + supported, + installed: installed || status === AgentRuntimeStatuses.installed, + installable: Boolean(record.installable), + status, + path: stringValue(record.path) || undefined, + os: stringValue(record.os) || undefined, + arch: stringValue(record.arch) || undefined, + docsURL: stringValue(record.docs_url) || undefined, + message: stringValue(record.message) || undefined, + }; +} + +export function normalizeAgentRuntimeList(value: unknown): AgentRuntime[] { + if (!Array.isArray(value)) { + return []; + } + const runtimes = new Map(); + for (const item of value) { + const runtime = normalizeAgentRuntime(item); + if (runtime) { + runtimes.set(runtime.name, runtime); + } + } + return sortAgentRuntimes([...runtimes.values()]); +} + +export function upsertAgentRuntime(runtimes: readonly AgentRuntime[], value: unknown): AgentRuntime[] { + const runtime = normalizeAgentRuntime(value); + if (!runtime) { + return [...runtimes]; + } + return sortAgentRuntimes([...runtimes.filter((item) => item.name !== runtime.name), runtime]); +} + +export function agentRuntimeByName( + runtimes: readonly AgentRuntime[] | null | undefined, + name: string, +): AgentRuntime | null { + const normalizedName = normalizeAgentRuntimeName(name); + return runtimes?.find((runtime) => runtime.name === normalizedName) ?? null; +} + +export function shouldPollAgentRuntimeInstallation(runtimes: readonly AgentRuntime[] | null | undefined): boolean { + const codex = agentRuntimeByName(runtimes, "codex"); + return Boolean( + codex && (codex.status === AgentRuntimeStatuses.notInstalled || codex.status === AgentRuntimeStatuses.installing), + ); +} + +function sortAgentRuntimes(runtimes: AgentRuntime[]): AgentRuntime[] { + return runtimes.sort((left, right) => { + const leftRank = runtimeOrder.get(left.name) ?? Number.MAX_SAFE_INTEGER; + const rightRank = runtimeOrder.get(right.name) ?? Number.MAX_SAFE_INTEGER; + return leftRank - rightRank || left.label.localeCompare(right.label) || left.name.localeCompare(right.name); + }); +} + +function fallbackRuntimeLabel(name: string): string { + switch (name) { + case "codex": + return "Codex CLI"; + case "claude_code": + return "Claude Code"; + default: + return name; + } +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/web/app/src/models/agents.ts b/web/app/src/models/agents.ts index 0cdb9ab9..dae72731 100644 --- a/web/app/src/models/agents.ts +++ b/web/app/src/models/agents.ts @@ -559,17 +559,46 @@ function agentAvatarUserIDs(item: AgentLike | null | undefined): string[] { } out.push(id); }; - push(item?.user_id); + const pushWithCanonicalUser = (value: unknown) => { + push(value); + let suffix = String(value ?? "").trim(); + if (!suffix) { + return; + } + while (true) { + const next = suffix.replace(/^(?:user-|agent-|pt-|u-)/, ""); + if (next === suffix) { + break; + } + suffix = next; + } + if (suffix) { + push(`user-${suffix}`); + } + }; + pushWithCanonicalUser(item?.user_id); const participant = item?.participants?.find( (candidate) => String(candidate?.channel || "").trim() === "csgclaw" && String(candidate?.id || "").trim(), ); - push(participant?.user_id); - push(participant?.channel_user_ref); - push(resolveAgentChannelUserID(item)); - push(item?.id); + pushWithCanonicalUser(participant?.user_id); + pushWithCanonicalUser(participant?.channel_user_ref); + pushWithCanonicalUser(resolveAgentChannelUserID(item)); + pushWithCanonicalUser(item?.id); return out; } +export function resolveAgentAvatarUserID( + agent: AgentLike | null | undefined, + usersById?: Map | null, +): string { + for (const userID of agentAvatarUserIDs(agent)) { + if (usersById?.has(userID)) { + return userID; + } + } + return resolveAgentChannelUserID(agent); +} + export function feishuAgentParticipant( item: AgentLike | null | undefined, ): NonNullable[number] | null { diff --git a/web/app/src/models/conversations.ts b/web/app/src/models/conversations.ts index 8be33147..9e86ebc4 100644 --- a/web/app/src/models/conversations.ts +++ b/web/app/src/models/conversations.ts @@ -4,6 +4,7 @@ import { openClawDeliveryKind, parseAgentActivity, parseLegacyToolActivityCommand, + parseMessageActivityCommand, parsePlainAgentCommand, } from "@/models/agentActivity"; import { renderSlashCommandPreviewText } from "@/models/slashCommands"; @@ -297,7 +298,7 @@ export function isToolCallMessage(messageOrContent: IMMessage | unknown): boolea if (openClawDeliveryKind(messageOrContent)) { return false; } - return isNonMessageActivityContent(messageOrContent.content); + return Boolean(parseMessageActivityCommand(messageOrContent)); } return isNonMessageActivityContent(messageOrContent); } diff --git a/web/app/src/models/scheduledTasks.ts b/web/app/src/models/scheduledTasks.ts index 8c18aa04..29d6f7b9 100644 --- a/web/app/src/models/scheduledTasks.ts +++ b/web/app/src/models/scheduledTasks.ts @@ -31,7 +31,10 @@ export function normalizeScheduledTaskList(input: unknown): WorkspaceScheduledTa return input .map(normalizeScheduledTask) .filter((item): item is WorkspaceScheduledTask => Boolean(item)) - .sort((left, right) => left.next_run_at.localeCompare(right.next_run_at) || left.created_at.localeCompare(right.created_at)); + .sort( + (left, right) => + left.next_run_at.localeCompare(right.next_run_at) || left.created_at.localeCompare(right.created_at), + ); } export function normalizeScheduledTaskRunList(input: unknown): WorkspaceScheduledTaskRun[] { diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css index 16550f3d..886e52f3 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css @@ -529,6 +529,8 @@ } .agent-activity-list { + --agent-activity-type-column-width: 128px; + display: block; overflow: hidden; border: 1px solid var(--line); @@ -558,7 +560,9 @@ .agent-activity-row-main { display: grid; - grid-template-columns: 104px minmax(0, 1fr) minmax(80px, 0.14fr) max-content max-content; + grid-template-columns: + var(--agent-activity-type-column-width) minmax(0, 1fr) minmax(80px, 0.14fr) + max-content max-content; align-items: center; gap: 12px; min-height: 42px; @@ -567,8 +571,8 @@ .agent-activity-type-badge { display: inline-flex; - width: 104px; - max-width: 104px; + width: var(--agent-activity-type-column-width); + max-width: var(--agent-activity-type-column-width); align-items: center; justify-content: center; overflow: hidden; @@ -665,7 +669,7 @@ } .agent-activity-row-detail { - margin: 0 12px 12px 128px; + margin: 0 12px 12px calc(var(--agent-activity-type-column-width) + 24px); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--gray-50); @@ -728,6 +732,10 @@ border-bottom-color: color-mix(in srgb, var(--gray-300) 18%, transparent); } +:root[data-theme="dark"] .agent-activity-row:hover { + background: color-mix(in srgb, var(--gray-800) 72%, var(--panel)); +} + :root[data-theme="dark"] .agent-activity-row.selected { background: color-mix(in oklab, var(--brand-600) 14%, var(--panel)); } diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index 0e1da077..e6168571 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -1184,7 +1184,7 @@ function AgentChannelsSection({

{t("agentChannelsDescription")}

diff --git a/web/app/src/pages/AgentPage/components/AgentView/AgentView.css b/web/app/src/pages/AgentPage/components/AgentView/AgentView.css index 651a3ba0..e6a191ed 100644 --- a/web/app/src/pages/AgentPage/components/AgentView/AgentView.css +++ b/web/app/src/pages/AgentPage/components/AgentView/AgentView.css @@ -156,8 +156,14 @@ color: var(--text); } -:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray:hover:not(:disabled), -:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray:focus-visible { +:root[data-theme="dark"] + .csg-dialog-content.agent-delete-dialog + .agent-delete-actions + .btn-secondary-gray:hover:not(:disabled), +:root[data-theme="dark"] + .csg-dialog-content.agent-delete-dialog + .agent-delete-actions + .btn-secondary-gray:focus-visible { border-color: color-mix(in oklab, var(--line) 80%, transparent); background: color-mix(in oklab, var(--panel) 80%, #020617 20%); color: var(--text); diff --git a/web/app/src/pages/ComputerPage/ComputerPage.tsx b/web/app/src/pages/ComputerPage/ComputerPage.tsx index a412744a..14bfecdd 100644 --- a/web/app/src/pages/ComputerPage/ComputerPage.tsx +++ b/web/app/src/pages/ComputerPage/ComputerPage.tsx @@ -1,12 +1,29 @@ import { useWorkspaceControllerContext } from "@/hooks/workspace"; import { ComputerView } from "./components"; +import { useAgentRuntimes } from "./useAgentRuntimes"; export function ComputerPage() { const controller = useWorkspaceControllerContext(); + const agentRuntimes = useAgentRuntimes(controller.t); if (!controller.ready) { return null; } - return ; + return ( + + ); } diff --git a/web/app/src/pages/ComputerPage/components/AgentRuntimeSection/AgentRuntimeSection.module.css b/web/app/src/pages/ComputerPage/components/AgentRuntimeSection/AgentRuntimeSection.module.css new file mode 100644 index 00000000..c1c7c9bc --- /dev/null +++ b/web/app/src/pages/ComputerPage/components/AgentRuntimeSection/AgentRuntimeSection.module.css @@ -0,0 +1,372 @@ +.panel { + width: var(--computer-page-content-width); + min-width: 0; + display: grid; + gap: 16px; + padding: 20px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); + box-shadow: var(--shadow-xs); +} + +.header { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.header h2 { + margin: 0; + color: var(--text); + font-size: 16px; + font-weight: 720; + line-height: 22px; +} + +.header p { + margin: 4px 0 0; + max-width: 52rem; + color: var(--muted); + font-size: 13px; + line-height: 19px; +} + +.refreshing { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 28px; + padding: 4px 9px; + border-radius: 999px; + background: var(--panel-soft); + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.grid { + min-width: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.card { + position: relative; + min-width: 0; + min-height: 208px; + display: grid; + grid-template-rows: auto minmax(32px, 1fr) auto; + gap: 15px; + padding: 18px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: color-mix(in oklab, var(--surface) 84%, var(--panel-soft)); +} + +.card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 3px; + opacity: 0.78; +} + +.codexCard::before { + background: linear-gradient(90deg, var(--brand-400), var(--violet-400)); +} + +.claudeCard::before { + background: linear-gradient(90deg, var(--warning-400), var(--orange-400)); +} + +.cardHeader { + min-width: 0; + display: grid; + grid-template-columns: 46px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; +} + +.logo { + width: 46px; + height: 46px; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid color-mix(in oklab, var(--line) 80%, transparent); + border-radius: 12px; + background: var(--white); + color: var(--muted); + box-shadow: var(--shadow-xs); +} + +.logo img { + width: 29px; + height: 29px; + display: block; + object-fit: contain; +} + +.identity { + min-width: 0; +} + +.identity h3 { + margin: 0; + color: var(--text); + font-size: 15px; + font-weight: 720; + line-height: 20px; +} + +.identity p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 17px; +} + +.status { + min-height: 27px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: var(--field); + font-size: 12px; + font-weight: 650; + line-height: 17px; + white-space: nowrap; +} + +.status.success { + border-color: color-mix(in oklab, var(--success-700) 28%, var(--line)); + background: var(--success-50); + color: var(--success-700); +} + +.status.progress { + border-color: color-mix(in oklab, var(--brand-600) 28%, var(--line)); + background: color-mix(in oklab, var(--brand-600) 9%, var(--surface)); + color: var(--brand-700); +} + +.status.danger { + border-color: color-mix(in oklab, var(--error-600) 28%, var(--line)); + background: var(--error-50); + color: var(--error-700); +} + +.cardBody { + min-width: 0; + display: grid; + align-content: start; + gap: 10px; +} + +.pathRow { + min-width: 0; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--field); + color: var(--muted); + font-size: 11px; + line-height: 16px; +} + +.pathRow code { + min-width: 0; + overflow: hidden; + color: var(--text); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtimeError { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 10px; + border: 1px solid color-mix(in oklab, var(--error-600) 26%, var(--line)); + border-radius: 9px; + background: var(--error-50); + color: var(--error-700); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; +} + +.runtimeError svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.cardFooter { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + line-height: 17px; +} + +.installButton { + flex: 0 0 auto; + border-radius: 8px; + gap: 7px; +} + +.loadingState, +.emptyState { + min-height: 138px; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + border: 1px dashed var(--line); + border-radius: 12px; + background: var(--field); + color: var(--muted); + font-size: 13px; +} + +.loadError { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 10px 12px; + border: 1px solid color-mix(in oklab, var(--error-600) 26%, var(--line)); + border-radius: 10px; + background: var(--error-50); + color: var(--error-700); + font-size: 12px; + line-height: 17px; +} + +.loadErrorMessage { + min-width: 0; + display: inline-flex; + align-items: flex-start; + gap: 8px; + overflow-wrap: anywhere; +} + +.loadErrorMessage svg { + flex: 0 0 auto; +} + +.spinner { + animation: runtimeSpinner 720ms linear infinite; +} + +@keyframes runtimeSpinner { + to { + transform: rotate(360deg); + } +} + +:global(:root[data-theme="dark"]) .panel { + background: var(--panel); + box-shadow: none; +} + +:global(:root[data-theme="dark"]) .card, +:global(:root[data-theme="dark"]) .logo, +:global(:root[data-theme="dark"]) .loadingState, +:global(:root[data-theme="dark"]) .emptyState { + background: var(--panel-strong); + box-shadow: none; +} + +:global(:root[data-theme="dark"]) .status.success { + border-color: color-mix(in oklab, var(--success-500) 34%, var(--line)); + background: color-mix(in oklab, var(--success-500) 14%, transparent); + color: color-mix(in oklab, var(--success-500) 86%, var(--text)); +} + +:global(:root[data-theme="dark"]) .status.progress { + border-color: color-mix(in oklab, var(--brand-400) 36%, var(--line)); + background: color-mix(in oklab, var(--brand-400) 14%, transparent); + color: color-mix(in oklab, var(--brand-300) 88%, var(--text)); +} + +:global(:root[data-theme="dark"]) .status.danger, +:global(:root[data-theme="dark"]) .runtimeError, +:global(:root[data-theme="dark"]) .loadError { + border-color: color-mix(in oklab, var(--error-500) 34%, var(--line)); + background: color-mix(in oklab, var(--error-500) 13%, transparent); + color: color-mix(in oklab, var(--error-400) 88%, var(--text)); +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } + + .cardHeader { + grid-template-columns: 42px minmax(0, 1fr); + } + + .logo { + width: 42px; + height: 42px; + } + + .status { + grid-column: 1 / -1; + justify-self: start; + } +} + +@media (max-width: 640px) { + .panel { + padding: 16px; + } + + .header, + .loadError, + .cardFooter { + align-items: stretch; + flex-direction: column; + } + + .refreshing { + align-self: flex-start; + } + + .card { + min-height: 0; + padding: 16px; + } + + .installButton { + width: 100%; + } +} diff --git a/web/app/src/pages/ComputerPage/components/AgentRuntimeSection/AgentRuntimeSection.tsx b/web/app/src/pages/ComputerPage/components/AgentRuntimeSection/AgentRuntimeSection.tsx new file mode 100644 index 00000000..de72f261 --- /dev/null +++ b/web/app/src/pages/ComputerPage/components/AgentRuntimeSection/AgentRuntimeSection.tsx @@ -0,0 +1,272 @@ +import type { ReactNode } from "react"; +import { + AlertCircle, + CheckCircle2, + CircleDashed, + Clock3, + Download, + LoaderCircle, + RefreshCw, + SquareTerminal, +} from "lucide-react"; +import { Button } from "@/components/ui"; +import { AgentRuntimeStatuses } from "@/models/agentRuntimes"; +import type { AgentRuntime, AgentRuntimeStatus } from "@/models/agentRuntimes"; +import type { TranslateFn } from "@/models/conversations"; +import { classNames } from "@/shared/lib/classNames"; +import styles from "./AgentRuntimeSection.module.css"; + +type VoidOrPromise = void | Promise; + +export type AgentRuntimeSectionProps = { + busyRuntimeName?: string; + error?: string; + installError?: string; + loading?: boolean; + onInstall?: (runtimeName: string) => VoidOrPromise; + onRetryLoad?: () => VoidOrPromise; + refreshing?: boolean; + runtimes?: AgentRuntime[]; + t?: TranslateFn; +}; + +const runtimeLogos: Record = { + codex: "model-providers/codex.svg", + claude_code: "model-providers/claude-code.svg", +}; + +export function AgentRuntimeSection({ + busyRuntimeName = "", + error = "", + installError = "", + loading = false, + onInstall = () => {}, + onRetryLoad = () => {}, + refreshing = false, + runtimes = [], + t = (key) => key, +}: AgentRuntimeSectionProps) { + return ( +
+
+
+

{t("computerRuntimesTitle")}

+

{t("computerRuntimesSubtitle")}

+
+ {refreshing && runtimes.length ? ( + + + ) : null} +
+ + {loading && !runtimes.length ? ( +
+
+ ) : ( + <> + {error ? ( +
+ + + +
+ ) : null} + + {runtimes.length ? ( +
    + {runtimes.map((runtime) => ( + + ))} +
+ ) : error ? null : ( +
{t("computerRuntimesEmpty")}
+ )} + + )} +
+ ); +} + +function RuntimeCard({ + runtime, + busy, + installError, + onInstall, + t, +}: { + busy: boolean; + installError: string; + onInstall: (runtimeName: string) => VoidOrPromise; + runtime: AgentRuntime; + t: TranslateFn; +}) { + const status = runtimeStatus(runtime); + const statusMeta = runtimeStatusMeta(status, t); + const installing = busy || status === AgentRuntimeStatuses.installing; + const failed = status === AgentRuntimeStatuses.failed || Boolean(installError); + const visibleError = installError || (status === AgentRuntimeStatuses.failed ? runtime.message || "" : ""); + const canInstall = + runtime.name === "codex" && + runtime.installable && + !runtime.installed && + status !== AgentRuntimeStatuses.unsupported; + const logo = runtimeLogos[runtime.name]; + + return ( +
  • +
    + +
    +

    {runtime.label}

    +

    {runtimeDescription(runtime.name, t)}

    +
    + + {statusMeta.icon} + {statusMeta.label} + +
    + +
    + {runtime.installed && runtime.path ? ( +
    +
    + ) : null} + {visibleError ? ( +
    +
    + ) : null} +
    + +
    + {runtimeHint(status, t)} + {canInstall ? ( + + ) : null} +
    +
  • + ); +} + +function runtimeStatus(runtime: AgentRuntime): AgentRuntimeStatus { + if (runtime.installed) { + return AgentRuntimeStatuses.installed; + } + return runtime.status; +} + +function runtimeStatusMeta( + status: AgentRuntimeStatus, + t: TranslateFn, +): { icon: ReactNode; label: string; tone: "success" | "neutral" | "progress" | "danger" } { + switch (status) { + case AgentRuntimeStatuses.installed: + return { + icon:
    + {runtimeSectionProps ? : null}
    {t("computerAgentsSection")}
    diff --git a/web/app/src/pages/ComputerPage/components/index.ts b/web/app/src/pages/ComputerPage/components/index.ts index cb4ec1c5..cdb78d42 100644 --- a/web/app/src/pages/ComputerPage/components/index.ts +++ b/web/app/src/pages/ComputerPage/components/index.ts @@ -1,2 +1,3 @@ +export * from "./AgentRuntimeSection"; export * from "./ComputerDetailPane"; export * from "./ComputerView"; diff --git a/web/app/src/pages/ComputerPage/useAgentRuntimes.ts b/web/app/src/pages/ComputerPage/useAgentRuntimes.ts new file mode 100644 index 00000000..b6070df2 --- /dev/null +++ b/web/app/src/pages/ComputerPage/useAgentRuntimes.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { errorMessage } from "@/api/client"; +import { fetchAgentRuntimes, installAgentRuntimeRequest } from "@/api/agentRuntimes"; +import { + agentRuntimeByName, + normalizeAgentRuntime, + normalizeAgentRuntimeList, + normalizeAgentRuntimeName, + shouldPollAgentRuntimeInstallation, + upsertAgentRuntime, +} from "@/models/agentRuntimes"; +import type { AgentRuntime } from "@/models/agentRuntimes"; +import type { TranslateFn } from "@/models/conversations"; +import { workspaceQueryKeys } from "@/hooks/workspace/workspaceQueries"; + +const INSTALL_STATUS_POLL_INTERVAL_MS = 1500; + +export type AgentRuntimesController = { + busyRuntimeName: string; + error: string; + installError: string; + installRuntime: (runtimeName: string) => Promise; + loading: boolean; + refresh: () => Promise; + refreshing: boolean; + runtimes: AgentRuntime[]; +}; + +export function useAgentRuntimes(t: TranslateFn): AgentRuntimesController { + const queryClient = useQueryClient(); + const installInFlightRef = useRef(""); + const bootstrapSyncedForInstalledCodexRef = useRef(false); + const [busyRuntimeName, setBusyRuntimeName] = useState(""); + const [installError, setInstallError] = useState(""); + + const runtimesQuery = useQuery({ + queryKey: workspaceQueryKeys.agentRuntimes(), + queryFn: fetchNormalizedAgentRuntimes, + retry: 0, + refetchInterval: (query) => + shouldPollAgentRuntimeInstallation(query.state.data) ? INSTALL_STATUS_POLL_INTERVAL_MS : false, + }); + + const runtimes = useMemo(() => runtimesQuery.data ?? [], [runtimesQuery.data]); + const codexInstalled = Boolean(agentRuntimeByName(runtimes, "codex")?.installed); + + useEffect(() => { + if (!codexInstalled) { + bootstrapSyncedForInstalledCodexRef.current = false; + return; + } + if (bootstrapSyncedForInstalledCodexRef.current) { + return; + } + bootstrapSyncedForInstalledCodexRef.current = true; + setInstallError(""); + void queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.bootstrapConfig() }); + }, [codexInstalled, queryClient]); + + const refresh = useCallback(async () => { + setInstallError(""); + try { + await queryClient.fetchQuery({ + queryKey: workspaceQueryKeys.agentRuntimes(), + queryFn: fetchNormalizedAgentRuntimes, + retry: 0, + }); + } catch (_) { + // The query exposes the localized load error to the page. + } + }, [queryClient]); + + const installRuntime = useCallback( + async (runtimeName: string) => { + const name = normalizeAgentRuntimeName(runtimeName); + if (!name || installInFlightRef.current) { + return; + } + installInFlightRef.current = name; + setBusyRuntimeName(name); + setInstallError(""); + try { + const runtime = normalizeAgentRuntime(await installAgentRuntimeRequest(name)); + if (!runtime) { + throw new Error(t("computerRuntimeInstallFailed")); + } + queryClient.setQueryData(workspaceQueryKeys.agentRuntimes(), (current = []) => + upsertAgentRuntime(current, runtime), + ); + } catch (error) { + setInstallError(errorMessage(error, t("computerRuntimeInstallFailed"))); + try { + await queryClient.fetchQuery({ + queryKey: workspaceQueryKeys.agentRuntimes(), + queryFn: fetchNormalizedAgentRuntimes, + retry: 0, + }); + } catch (_) { + // Preserve the install error when refreshing the authoritative status also fails. + } + } finally { + installInFlightRef.current = ""; + setBusyRuntimeName(""); + } + }, + [queryClient, t], + ); + + return { + busyRuntimeName, + error: runtimesQuery.isError ? errorMessage(runtimesQuery.error, t("computerRuntimesLoadFailed")) : "", + installError, + installRuntime, + loading: runtimesQuery.isPending, + refresh, + refreshing: runtimesQuery.isFetching && !runtimesQuery.isPending, + runtimes, + }; +} + +async function fetchNormalizedAgentRuntimes(): Promise { + return normalizeAgentRuntimeList(await fetchAgentRuntimes()); +} diff --git a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx index adf73a5a..ce34a48c 100644 --- a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx +++ b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationPane.tsx @@ -1,6 +1,4 @@ -import { X } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useState } from "react"; import { fetchAgentLogsRequest } from "@/api/agents"; import { errorMessage } from "@/api/client"; import { @@ -9,164 +7,33 @@ import { useConversationDraftEditorSync, } from "@/components/business/ConversationPane"; import { AgentView } from "@/pages/AgentPage/components"; -import { IconButton } from "@/components/ui"; +import { DialogCloseButton, DialogContent, DialogRoot, DialogTitle } from "@/components/ui"; import { normalizeAuthProviderName } from "@/models/agents"; import { getConversationDescription, isDirectConversation } from "@/models/conversations"; import type { AgentDetailSidePanelProps } from "@/hooks/workspace/types"; -const AGENT_DETAIL_PANEL_DEFAULT_WIDTH = 760; -const AGENT_DETAIL_PANEL_MIN_WIDTH = 520; -const AGENT_DETAIL_PANEL_MAX_WIDTH = 1120; -const AGENT_DETAIL_PANEL_MIN_MAIN_WIDTH = 360; -const AGENT_DETAIL_PANEL_KEYBOARD_STEP = 24; -const AGENT_DETAIL_PANEL_KEYBOARD_LARGE_STEP = 80; - -function clampAgentDetailPanelWidth(width: number, containerWidth = 0): number { - const maxByContainer = - containerWidth > 0 - ? Math.max(AGENT_DETAIL_PANEL_MIN_WIDTH, containerWidth - AGENT_DETAIL_PANEL_MIN_MAIN_WIDTH) - : AGENT_DETAIL_PANEL_MAX_WIDTH; - const maxWidth = Math.min(AGENT_DETAIL_PANEL_MAX_WIDTH, maxByContainer); - if (!Number.isFinite(width)) { - return AGENT_DETAIL_PANEL_DEFAULT_WIDTH; - } - return Math.min(maxWidth, Math.max(AGENT_DETAIL_PANEL_MIN_WIDTH, Math.round(width))); -} - -function AgentDetailSidePanel({ - onClose, - onResize, - width = AGENT_DETAIL_PANEL_DEFAULT_WIDTH, - ...props -}: AgentDetailSidePanelProps) { - const panelRef = useRef(null); - const dragRef = useRef<{ containerWidth: number; panelRight: number; pointerID: number } | null>(null); - const [resizing, setResizing] = useState(false); - - const resolveContainerWidth = useCallback(() => { - const panel = panelRef.current; - const parent = panel?.closest(".chat-panel"); - const parentWidth = parent instanceof HTMLElement ? parent.getBoundingClientRect().width : 0; - return parentWidth > 0 ? parentWidth : window.innerWidth; - }, []); - - const resizeTo = useCallback( - (nextWidth: number, containerWidth = resolveContainerWidth()) => { - onResize?.(clampAgentDetailPanelWidth(nextWidth, containerWidth)); - }, - [onResize, resolveContainerWidth], - ); - - useEffect(() => { - if (!resizing) { - return undefined; - } - const previousCursor = document.body.style.cursor; - const previousUserSelect = document.body.style.userSelect; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - return () => { - document.body.style.cursor = previousCursor; - document.body.style.userSelect = previousUserSelect; - }; - }, [resizing]); - - function handleResizePointerDown(event: ReactPointerEvent) { - const panel = panelRef.current; - if (!panel) { - return; - } - const panelRect = panel.getBoundingClientRect(); - const containerWidth = resolveContainerWidth(); - dragRef.current = { - containerWidth, - panelRight: panelRect.right, - pointerID: event.pointerId, - }; - event.currentTarget.setPointerCapture(event.pointerId); - event.preventDefault(); - setResizing(true); - } - - function handleResizePointerMove(event: ReactPointerEvent) { - const drag = dragRef.current; - if (!drag || drag.pointerID !== event.pointerId) { - return; - } - resizeTo(drag.panelRight - event.clientX, drag.containerWidth); - } - - function finishResize(event: ReactPointerEvent) { - const drag = dragRef.current; - if (!drag || drag.pointerID !== event.pointerId) { - return; - } - dragRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - setResizing(false); - } - - function handleResizeKeyDown(event: ReactKeyboardEvent) { - const step = event.shiftKey ? AGENT_DETAIL_PANEL_KEYBOARD_LARGE_STEP : AGENT_DETAIL_PANEL_KEYBOARD_STEP; - if (event.key === "ArrowLeft") { - event.preventDefault(); - resizeTo(width + step); - return; - } - if (event.key === "ArrowRight") { - event.preventDefault(); - resizeTo(width - step); - return; - } - if (event.key === "Home") { - event.preventDefault(); - resizeTo(AGENT_DETAIL_PANEL_MIN_WIDTH); - return; - } - if (event.key === "End") { - event.preventDefault(); - resizeTo(AGENT_DETAIL_PANEL_MAX_WIDTH); - } - } - +function AgentDetailSidePanel({ onClose, ...props }: AgentDetailSidePanelProps) { return ( - + (!open ? onClose() : undefined)}> + +
    + + {props.t("agentDetailPanel")} +
    +
    + +
    +
    +
    ); } diff --git a/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx b/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx index a311cec8..a87ab54e 100644 --- a/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx +++ b/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx @@ -281,9 +281,7 @@ export function HubDetailPane({
    {t("resourcesRuntimeLabel")} - {selectedTemplate.runtime_kind - ? formatRuntimeKindLabel(selectedTemplate.runtime_kind, t) - : "-"} + {selectedTemplate.runtime_kind ? formatRuntimeKindLabel(selectedTemplate.runtime_kind, t) : "-"}
    diff --git a/web/app/src/pages/SettingsPage/SettingsPage.module.css b/web/app/src/pages/SettingsPage/SettingsPage.module.css index 9779eb06..658aaee0 100644 --- a/web/app/src/pages/SettingsPage/SettingsPage.module.css +++ b/web/app/src/pages/SettingsPage/SettingsPage.module.css @@ -1,7 +1,7 @@ .page { width: 100%; height: 100%; - min-height: 100dvh; + min-height: var(--app-ui-viewport-height); overflow: auto; padding: 32px 32px 48px; background: var(--gray-50); diff --git a/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx b/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx index e91b641d..efea21dd 100644 --- a/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx +++ b/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx @@ -23,23 +23,35 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function profilePreviewStyle(anchorRect: ProfilePreviewAnchorRect | null | undefined, cardHeight = 420) { +function rootZoomScale(): number { + const zoom = Number.parseFloat(window.getComputedStyle(document.documentElement).zoom); + return Number.isFinite(zoom) && zoom > 0 ? zoom : 1; +} + +function profilePreviewStyle(anchorRect: ProfilePreviewAnchorRect | null | undefined, cardHeight?: number | null) { + const scale = rootZoomScale(); const offset = 12; const viewportPadding = 16; - const width = Math.min(360, window.innerWidth - viewportPadding * 2); - const maxLeft = Math.max(viewportPadding, window.innerWidth - viewportPadding - width); - const visibleHeight = Math.min(cardHeight, window.innerHeight - viewportPadding * 2); + const width = Math.min(360, (window.innerWidth - viewportPadding * 2) / scale); + const visualWidth = width * scale; + const maxLeft = Math.max(viewportPadding, window.innerWidth - viewportPadding - visualWidth); + const visualCardHeight = cardHeight ?? 420 * scale; + const visibleHeight = Math.min(visualCardHeight, window.innerHeight - viewportPadding * 2); const maxTop = Math.max(viewportPadding, window.innerHeight - viewportPadding - visibleHeight); if (!anchorRect) { - return { top: `${viewportPadding}px`, left: `${viewportPadding}px`, width: `${width}px` }; + return { + top: `${viewportPadding / scale}px`, + left: `${viewportPadding / scale}px`, + width: `${width}px`, + }; } - const hasRoomRight = anchorRect.right + offset + width <= window.innerWidth - viewportPadding; - const preferredLeft = hasRoomRight ? anchorRect.right + offset : anchorRect.left - width - offset; + const hasRoomRight = anchorRect.right + offset + visualWidth <= window.innerWidth - viewportPadding; + const preferredLeft = hasRoomRight ? anchorRect.right + offset : anchorRect.left - visualWidth - offset; const left = clamp(preferredLeft, viewportPadding, maxLeft); const top = clamp(anchorRect.top - 12, viewportPadding, maxTop); - return { top: `${top}px`, left: `${left}px`, width: `${width}px` }; + return { top: `${top / scale}px`, left: `${left / scale}px`, width: `${width}px` }; } export function ProfilePreviewPopover({ @@ -54,7 +66,7 @@ export function ProfilePreviewPopover({ onOpenAgent, onOpenDM, }: ProfilePreviewPopoverProps) { - const [cardHeight, setCardHeight] = useState(420); + const [cardHeight, setCardHeight] = useState(null); useLayoutEffect(() => { const preview = previewRef?.current; diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceComponents.css b/web/app/src/pages/WorkspacePage/components/WorkspaceComponents.css index 489ecadd..80c40034 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceComponents.css +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceComponents.css @@ -1,8 +1,8 @@ .app-shell { display: grid; grid-template-columns: minmax(0, var(--sidebar-slot-width)) minmax(0, 1fr); - min-height: 100dvh; - height: 100dvh; + min-height: var(--app-ui-viewport-height); + height: var(--app-ui-viewport-height); overflow: hidden; } @@ -3206,9 +3206,9 @@ select, :root[data-theme="dark"] .profile-section .selection-item.compact-toggle-row.agent-sandbox-toggle.readonly, :root[data-theme="dark"] .profile-section .selection-item.compact-toggle-row.agent-sandbox-toggle.readonly:hover, :root[data-theme="dark"] .profile-section .selection-item.compact-toggle-row.agent-sandbox-toggle.readonly:focus-within, -:root[data-theme="dark"] .profile-section .selection-item.compact-toggle-row.agent-sandbox-toggle.readonly:has( - input:checked - ) { +:root[data-theme="dark"] + .profile-section + .selection-item.compact-toggle-row.agent-sandbox-toggle.readonly:has(input:checked) { border-color: var(--line); background: var(--surface); color: var(--text); @@ -3666,6 +3666,7 @@ select, .computer-detail-pane { --computer-page-content-width: min(100%, 1520px); + min-width: 0; min-height: 100%; gap: 16px; justify-items: center; @@ -4133,6 +4134,10 @@ select, } @media (max-width: 900px) { + .computer-detail-pane { + padding: 18px; + } + .computer-summary-strip { grid-template-columns: repeat(2, minmax(0, 170px)); } @@ -4145,6 +4150,7 @@ select, } @media (max-width: 640px) { + .computer-detail-pane, .human-detail-pane { padding: 18px; } diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceLayout/WorkspaceLayout.module.css b/web/app/src/pages/WorkspacePage/components/WorkspaceLayout/WorkspaceLayout.module.css index 85dca3fc..d28605cd 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceLayout/WorkspaceLayout.module.css +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceLayout/WorkspaceLayout.module.css @@ -26,7 +26,7 @@ grid-column: 1; grid-row: 1; width: 100%; - height: 100dvh; + height: var(--app-ui-viewport-height); min-width: 0; min-height: 0; overflow: visible; @@ -54,7 +54,7 @@ grid-column: 1; justify-self: end; width: 24px; - height: 100dvh; + height: var(--app-ui-viewport-height); margin-right: -12px; cursor: col-resize; touch-action: none; @@ -153,7 +153,7 @@ :global(.app-shell:not(.sidebar-collapsed)) .sidebarShell { grid-column: 1; grid-row: 1; - height: 100dvh; + height: var(--app-ui-viewport-height); } :global(.app-shell:not(.sidebar-collapsed)) :global(.chat-panel) { diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceMainPanel/WorkspaceMainPanel.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceMainPanel/WorkspaceMainPanel.tsx index a1f6c9f2..ebbee492 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceMainPanel/WorkspaceMainPanel.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceMainPanel/WorkspaceMainPanel.tsx @@ -6,14 +6,7 @@ export function WorkspaceMainPanel() { const controller = useWorkspaceControllerContext(); return ( -
    +
    ); diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx index 17ccb6ef..a9796d29 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx @@ -1,5 +1,5 @@ import { BOT_TYPE_NORMAL, DEFAULT_RUNTIME_KIND } from "@/shared/constants/agents"; -import { useEffect, useRef, useState, type SetStateAction } from "react"; +import { useEffect, useMemo, useRef, useState, type SetStateAction } from "react"; import { AgentCreateProgress, type AgentCreateProgressProps, @@ -144,8 +144,12 @@ export function AgentProfileModal({ const selectedProvider = providerOptions.find((option) => option.id === selectedProviderID) ?? null; const selectedProviderModels = selectedProvider?.models ?? []; const selectedModelValue = agentDraft.model_id || ""; - const workerTemplates = workerSelectableTemplates(hubTemplates).filter( - (item) => normalizeRuntimeKind(item.runtime_kind) !== "picoclaw_sandbox", + const workerTemplates = useMemo( + () => + workerSelectableTemplates(hubTemplates).filter( + (item) => normalizeRuntimeKind(item.runtime_kind) !== "picoclaw_sandbox", + ), + [hubTemplates], ); const selectedWorkerTemplate = workerTemplates.find((item) => item.id === agentDraft.from_template) ?? null; const sandboxEnabled = Boolean(agentDraft.sandbox_enabled); @@ -312,11 +316,10 @@ export function AgentProfileModal({ agentDraft.from_template, bootstrapConfig, defaultSandboxRuntimeKind, - hubTemplates, isTemplateCreate, managerAgent?.image, onAgentDraftChange, - workerTemplates.length, + workerTemplates, ]); return ( diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx index 5f72c333..392fbbde 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx @@ -272,7 +272,9 @@ export function CreateModelProviderModal({ onInput={(event) => setDisplayName(event.currentTarget.value)} placeholder={presetMeta.defaultDisplayName} /> - {duplicateName ? {t("modelProviderDuplicateDisplayName")} : null} + {duplicateName ? ( + {t("modelProviderDuplicateDisplayName")} + ) : null}
    @@ -312,11 +314,7 @@ export function CreateModelProviderModal({ aria-pressed={apiKeyVisible} onClick={() => setAPIKeyVisible((current) => !current)} > - + {t("modelProviderAPIKeyHint")} @@ -364,7 +362,13 @@ export function CreateModelProviderModal({ - diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx index 0c283ce7..3f1e3c30 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx @@ -63,15 +63,7 @@ export type WorkspaceGroupProps = { title: string; }; -function WorkspaceAddAction({ - icon, - label, - onAdd, -}: { - icon: ReactNode; - label: string; - onAdd: () => void; -}) { +function WorkspaceAddAction({ icon, label, onAdd }: { icon: ReactNode; label: string; onAdd: () => void }) { const triggerRef = useRef(null); const tooltipId = useId(); const [open, setOpen] = useState(false); @@ -94,9 +86,7 @@ function WorkspaceAddAction({ const tooltipHeight = 32; const margin = 12; const fitsRight = rect.right + gap + tooltipWidth <= window.innerWidth - margin; - const left = fitsRight - ? rect.right + gap - : Math.max(margin, rect.left - gap - tooltipWidth); + const left = fitsRight ? rect.right + gap : Math.max(margin, rect.left - gap - tooltipWidth); const top = Math.min( window.innerHeight - tooltipHeight - margin, Math.max(margin, rect.top + rect.height / 2 - tooltipHeight / 2), diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx index 36097b6f..9385b25f 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx @@ -567,7 +567,12 @@ export function WorkspaceTabPanels({ onClick={() => onSelectModelProvider(provider)} > - + diff --git a/web/app/src/shared/constants/api.ts b/web/app/src/shared/constants/api.ts index f58ea961..6fe4f708 100644 --- a/web/app/src/shared/constants/api.ts +++ b/web/app/src/shared/constants/api.ts @@ -5,6 +5,7 @@ export const ApiEndpoints = { version: `${API_BASE_PATH}/version`, upgradeStatus: `${API_BASE_PATH}/upgrade/status`, upgradeApply: `${API_BASE_PATH}/upgrade/apply`, + agentRuntimes: `${API_BASE_PATH}/agent-runtimes`, serverConfig: `${API_BASE_PATH}/server/config`, serverRestart: `${API_BASE_PATH}/server/restart`, serverRestartStatus: `${API_BASE_PATH}/server/restart/status`, diff --git a/web/app/src/shared/i18n/messages.ts b/web/app/src/shared/i18n/messages.ts index 53dfd65c..8e7d34ab 100644 --- a/web/app/src/shared/i18n/messages.ts +++ b/web/app/src/shared/i18n/messages.ts @@ -157,6 +157,31 @@ export const messages = { computersSection: "电脑", localComputer: "本机", computerOverview: "电脑概览", + computerRuntimesTitle: "Agent Runtime", + computerRuntimesSubtitle: "管理本机上供 Agent 使用的命令行运行时。", + computerRuntimesLoading: "正在读取 Agent Runtime...", + computerRuntimesRefreshing: "正在更新状态", + computerRuntimesLoadFailed: "读取 Agent Runtime 失败,请重试。", + computerRuntimesEmpty: "当前没有可用的 Agent Runtime。", + computerRuntimeCodexDescription: "OpenAI 的本地编程 Agent Runtime", + computerRuntimeClaudeDescription: "Anthropic 的本地编程 Agent Runtime", + computerRuntimeDescription: "供本地 Agent 使用的命令行运行时", + computerRuntimeInstalled: "已安装", + computerRuntimeNotInstalled: "未安装", + computerRuntimeInstalling: "正在安装...", + computerRuntimeFailed: "安装失败", + computerRuntimeComingSoon: "即将支持", + computerRuntimeUnsupported: "当前平台不支持", + computerRuntimeInstall: "安装", + computerRuntimeRetry: "重试", + computerRuntimeExecutable: "可执行文件", + computerRuntimeReadyHint: "已可供本机 Agent 使用。", + computerRuntimeInstallHint: "一键安装适配当前系统的版本。", + computerRuntimeInstallingHint: "正在后台下载并安装,请稍候。", + computerRuntimeRetryHint: "检查错误后可手动重试。", + computerRuntimeComingSoonHint: "安装支持将在后续版本提供。", + computerRuntimeUnsupportedHint: "当前系统或架构暂无可用安装包。", + computerRuntimeInstallFailed: "Codex CLI 安装失败,请重试。", agentOverview: "智能体概览", tasksOverview: "任务总览", resourcesOverview: "资源概览", @@ -1142,6 +1167,31 @@ export const messages = { computersSection: "Computers", localComputer: "Local computer", computerOverview: "Computer overview", + computerRuntimesTitle: "Agent runtimes", + computerRuntimesSubtitle: "Manage the command-line runtimes available to agents on this computer.", + computerRuntimesLoading: "Loading agent runtimes...", + computerRuntimesRefreshing: "Updating status", + computerRuntimesLoadFailed: "Failed to load agent runtimes. Please retry.", + computerRuntimesEmpty: "No agent runtimes are available yet.", + computerRuntimeCodexDescription: "OpenAI's local coding agent runtime", + computerRuntimeClaudeDescription: "Anthropic's local coding agent runtime", + computerRuntimeDescription: "A command-line runtime for local agents", + computerRuntimeInstalled: "Installed", + computerRuntimeNotInstalled: "Not installed", + computerRuntimeInstalling: "Installing...", + computerRuntimeFailed: "Install failed", + computerRuntimeComingSoon: "Coming soon", + computerRuntimeUnsupported: "Unsupported platform", + computerRuntimeInstall: "Install", + computerRuntimeRetry: "Retry", + computerRuntimeExecutable: "Executable", + computerRuntimeReadyHint: "Ready for agents on this computer.", + computerRuntimeInstallHint: "Install the build for this system with one click.", + computerRuntimeInstallingHint: "Downloading and installing in the background.", + computerRuntimeRetryHint: "Review the error, then retry manually.", + computerRuntimeComingSoonHint: "Installation support will arrive in a future release.", + computerRuntimeUnsupportedHint: "No install package is available for this operating system or architecture.", + computerRuntimeInstallFailed: "Failed to install Codex CLI. Please retry.", agentOverview: "Agent overview", tasksOverview: "Task overview", resourcesOverview: "Resources overview", diff --git a/web/app/src/shared/styles/globals.css b/web/app/src/shared/styles/globals.css index 96bfe757..cda64e16 100644 --- a/web/app/src/shared/styles/globals.css +++ b/web/app/src/shared/styles/globals.css @@ -9,6 +9,29 @@ box-sizing: border-box; } +:root { + --app-ui-viewport-height: 100dvh; +} + +@supports (zoom: 80%) { + :root { + --app-ui-viewport-height: 125dvh; + --text-xs-size: 13px; + --text-sm-size: 15px; + --text-md-size: 17px; + --text-lg-size: 19px; + --text-xl-size: 21px; + --text-2xl-size: 25px; + --text-3xl-size: 31px; + --text-4xl-size: 37px; + --text-5xl-size: 49px; + --text-6xl-size: 61px; + --text-7xl-size: 73px; + font-size-adjust: 0.65; + zoom: 80%; + } +} + html, body, #root { @@ -26,8 +49,9 @@ html[data-theme="light"] { body { margin: 0; - min-height: 100dvh; + min-height: var(--app-ui-viewport-height); color: var(--text); + font-size: var(--text-md-size); font-family: var(--font-sans); overflow: hidden; background: var(--bg); @@ -37,7 +61,7 @@ body { } #root { - min-height: 100%; + min-height: var(--app-ui-viewport-height); } .truncate { diff --git a/web/app/tests/api/agentRuntimes.test.ts b/web/app/tests/api/agentRuntimes.test.ts new file mode 100644 index 00000000..23857325 --- /dev/null +++ b/web/app/tests/api/agentRuntimes.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import { fetchAgentRuntimes, installAgentRuntimeRequest } from "@/api/agentRuntimes"; + +function mockFetch(payload: unknown): Mock { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("agent runtimes API", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("loads agent runtimes without browser caching", async () => { + const fetchMock = mockFetch([]); + + await fetchAgentRuntimes(); + + expect(fetchMock).toHaveBeenCalledWith("api/v1/agent-runtimes", expect.objectContaining({ cache: "no-store" })); + }); + + it("posts install requests to the encoded runtime resource", async () => { + const fetchMock = mockFetch({ name: "codex", status: "installed" }); + + await installAgentRuntimeRequest("codex"); + + expect(fetchMock).toHaveBeenCalledWith( + "api/v1/agent-runtimes/codex/install", + expect.objectContaining({ method: "POST" }), + ); + }); +}); diff --git a/web/app/tests/components/AgentActions.test.tsx b/web/app/tests/components/AgentActions.test.tsx index 00924bf9..6e69123b 100644 --- a/web/app/tests/components/AgentActions.test.tsx +++ b/web/app/tests/components/AgentActions.test.tsx @@ -344,7 +344,7 @@ describe("agent action visibility", () => { ); await user.click(screen.getByRole("button", { name: "Channels" })); - expect(screen.getByRole("heading", { name: "Channels" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Channels" })).toBeInTheDocument(); expect(screen.getByText("Manage external channels.")).toBeInTheDocument(); expect(document.querySelector(".agent-channel-icon img")).toHaveAttribute("src", "icons/feishu.png"); expect(screen.getByText("Disconnected")).toBeInTheDocument(); @@ -912,7 +912,7 @@ describe("agent action visibility", () => { />, ); - await user.click(screen.getByRole("button", { name: "skills" })); + await user.click(screen.getByRole("button", { name: /^skills/ })); await user.click(screen.getByRole("button", { name: "Add skill" })); expect(screen.getByText("Candidates come from global skills.")).toBeInTheDocument(); expect(screen.getByText("Beta candidate")).toBeInTheDocument(); @@ -964,7 +964,7 @@ describe("agent action visibility", () => { />, ); - await user.click(screen.getByRole("button", { name: "skills" })); + await user.click(screen.getByRole("button", { name: /^skills/ })); await user.click(screen.getAllByRole("button", { name: "Delete" })[0]); expect(screen.getByText('Delete skill "alpha" from this agent?')).toBeInTheDocument(); @@ -1181,15 +1181,21 @@ describe("agent action visibility", () => { const navigation = screen.getByRole("navigation", { name: "Profile sections" }); const tabs = within(navigation).getAllByRole("button"); - expect(tabs.map((tab) => tab.textContent)).toEqual(["Profile", "Activity", "Channels", "Instructions", "skills"]); + expect(tabs.map((tab) => tab.firstElementChild?.textContent)).toEqual([ + "Profile", + "Activity", + "Instructions", + "skills", + "Channels", + ]); expect(tabs[0]).toHaveAttribute("aria-current", "location"); expect(screen.getByText("Request options")).toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Channels" })).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Channels" })).not.toBeInTheDocument(); await user.click(within(navigation).getByRole("button", { name: "Channels" })); expect(within(navigation).getByRole("button", { name: "Channels" })).toHaveAttribute("aria-current", "location"); - expect(screen.getByRole("heading", { name: "Channels" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Channels" })).toBeInTheDocument(); expect(screen.queryByText("Request options")).not.toBeInTheDocument(); }); @@ -1222,6 +1228,6 @@ describe("agent action visibility", () => { render(); expect(screen.getByRole("button", { name: "Channels" })).toHaveAttribute("aria-current", "location"); - expect(screen.getByRole("heading", { name: "Channels" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Channels" })).toBeInTheDocument(); }); }); diff --git a/web/app/tests/components/AgentActivityPanel.test.tsx b/web/app/tests/components/AgentActivityPanel.test.tsx index a8fc72cf..090923b5 100644 --- a/web/app/tests/components/AgentActivityPanel.test.tsx +++ b/web/app/tests/components/AgentActivityPanel.test.tsx @@ -193,18 +193,21 @@ describe("AgentActivityPanel", () => { sender_id: "openclaw-weather", created_at: "2026-07-01T10:00:00Z", content: '🔎 Web Search: for "上海天气" (top 5)', + metadata: { openclaw: { delivery_kind: "tool", request_id: "msg-user" } }, }, { id: "search-result", sender_id: "openclaw-weather", created_at: "2026-07-01T10:00:01Z", content: '🔎 Web Search: for "上海天气" (top 5) {"query":"上海天气","count":5,"status":"ok"}', + metadata: { openclaw: { delivery_kind: "tool", request_id: "msg-user" } }, }, { id: "reply-1", sender_id: "openclaw-weather", created_at: "2026-07-01T10:00:02Z", content: "上海这周多云。", + metadata: { openclaw: { delivery_kind: "final", request_id: "msg-user" } }, }, ]), { headers: { "Content-Type": "application/json" }, status: 200 }, @@ -468,18 +471,21 @@ describe("AgentActivityPanel", () => { sender_id: "glab-opencsg", created_at: "2026-07-01T10:00:00Z", content: "📖 Read: from ~/.openclaw/workspace/skills/gitlab/SKILL.md", + metadata: { openclaw: { delivery_kind: "tool", request_id: "msg-user" } }, }, { id: "memory-1", sender_id: "glab-opencsg", created_at: "2026-07-01T10:00:01Z", content: '🧠 Memory Search: GitLab issues user context {"results":[]}', + metadata: { openclaw: { delivery_kind: "tool", request_id: "msg-user" } }, }, { id: "run-1", sender_id: "glab-opencsg", created_at: "2026-07-01T10:00:02Z", content: "🛠️ run cd → run python3 scripts/ensure_gitlab_auth.py", + metadata: { openclaw: { delivery_kind: "tool", request_id: "msg-user" } }, }, { id: "metadata-tool-1", diff --git a/web/app/tests/components/AgentRuntimeSection.test.tsx b/web/app/tests/components/AgentRuntimeSection.test.tsx new file mode 100644 index 00000000..4f9444da --- /dev/null +++ b/web/app/tests/components/AgentRuntimeSection.test.tsx @@ -0,0 +1,186 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AgentRuntimeSection } from "@/pages/ComputerPage/components"; +import { ComputerDetailPane } from "@/pages/ComputerPage/components"; +import type { AgentRuntime } from "@/models/agentRuntimes"; +import type { TranslateFn } from "@/models/conversations"; + +const labels: Record = { + activeNow: "Active now", + channelsSection: "Rooms", + computerAgentsSection: "Agents", + computerOverview: "Computer overview", + computerRuntimesEmpty: "No agent runtimes are available yet.", + computerRuntimesLoading: "Loading agent runtimes...", + computerRuntimesRefreshing: "Updating status", + computerRuntimesSubtitle: "Manage command-line runtimes.", + computerRuntimesTitle: "Agent runtimes", + computerRuntimeClaudeDescription: "Anthropic runtime", + computerRuntimeCodexDescription: "OpenAI runtime", + computerRuntimeComingSoon: "Coming soon", + computerRuntimeComingSoonHint: "Installation support will arrive later.", + computerRuntimeExecutable: "Executable", + computerRuntimeFailed: "Install failed", + computerRuntimeInstalled: "Installed", + computerRuntimeInstalling: "Installing...", + computerRuntimeInstallingHint: "Downloading in the background.", + computerRuntimeInstall: "Install", + computerRuntimeInstallHint: "Install with one click.", + computerRuntimeNotInstalled: "Not installed", + computerRuntimeReadyHint: "Ready for local agents.", + computerRuntimeRetry: "Retry", + computerRuntimeRetryHint: "Review the error and retry.", + computerRuntimeUnsupported: "Unsupported platform", + computerRuntimeUnsupportedHint: "No package is available for this platform.", + createAgent: "Create", + directMessagesSection: "Direct Messages", + localComputer: "Local computer", + noAgents: "No workers yet.", + online: "online", +}; + +const t: TranslateFn = (key) => labels[key] ?? key; + +const missingCodex: AgentRuntime = { + name: "codex", + label: "Codex CLI", + supported: true, + installed: false, + installable: true, + status: "not_installed", + os: "darwin", + arch: "arm64", +}; + +const claudeCode: AgentRuntime = { + name: "claude_code", + label: "Claude Code", + supported: false, + installed: false, + installable: false, + status: "coming_soon", + os: "darwin", + arch: "arm64", +}; + +describe("AgentRuntimeSection", () => { + it("shows installable Codex and a clearly unavailable Claude Code card", async () => { + const user = userEvent.setup(); + const onInstall = vi.fn(); + const { container } = render( + , + ); + + expect(screen.getByRole("heading", { name: "Agent runtimes" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Codex CLI" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Claude Code" })).toBeInTheDocument(); + expect(screen.getByText("Not installed")).toBeInTheDocument(); + expect(screen.getByText("Coming soon")).toBeInTheDocument(); + + const logos = [...container.querySelectorAll("img")]; + expect(logos.map((logo) => logo.getAttribute("src"))).toEqual([ + "model-providers/codex.svg", + "model-providers/claude-code.svg", + ]); + + await user.click(screen.getByRole("button", { name: "Install" })); + expect(onInstall).toHaveBeenCalledWith("codex"); + expect(screen.queryByRole("button", { name: /Claude/ })).not.toBeInTheDocument(); + }); + + it("presents background installation as a disabled busy action", () => { + render( + , + ); + + const installing = screen.getByRole("button", { name: "Installing..." }); + expect(installing).toBeDisabled(); + expect(installing).toHaveAttribute("aria-busy", "true"); + expect(screen.getAllByText("Installing...").length).toBeGreaterThan(0); + }); + + it("shows the detected executable for installed Codex without an install action", () => { + const path = "/Users/test/.csgclaw/bin/codex"; + render( + , + ); + + expect(screen.getByText("Installed")).toBeInTheDocument(); + expect(screen.getByTitle(path)).toHaveTextContent(path); + expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument(); + }); + + it("keeps a failed install message next to a manual retry", async () => { + const user = userEvent.setup(); + const onInstall = vi.fn(); + render( + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent("archive checksum mismatch"); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onInstall).toHaveBeenCalledWith("codex"); + }); + + it("shows unsupported Codex as a terminal state without an install action", () => { + render( + , + ); + + expect(screen.getByText("Unsupported platform")).toBeInTheDocument(); + expect(screen.getByText("No package is available for this platform.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument(); + }); + + it("shows accessible load and retry states", async () => { + const user = userEvent.setup(); + const onRetryLoad = vi.fn(); + const { rerender } = render(); + + expect(screen.getByRole("status")).toHaveTextContent("Loading agent runtimes..."); + + rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("runtime service unavailable"); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetryLoad).toHaveBeenCalledTimes(1); + }); + + it("places agent runtimes between the computer overview and agent list", () => { + const { container } = render( + , + ); + + const overview = container.querySelector(".computer-overview-card"); + const runtimeSection = screen.getByRole("region", { name: "Agent runtimes" }); + const agentPanel = container.querySelector(".computer-agent-panel"); + expect(overview).not.toBeNull(); + expect(agentPanel).not.toBeNull(); + if (!overview || !agentPanel) { + throw new Error("Computer page sections are missing"); + } + expect(overview.compareDocumentPosition(runtimeSection)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(runtimeSection.compareDocumentPosition(agentPanel)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); +}); diff --git a/web/app/tests/components/ConversationPane.test.tsx b/web/app/tests/components/ConversationPane.test.tsx index 1ef68de6..593fee76 100644 --- a/web/app/tests/components/ConversationPane.test.tsx +++ b/web/app/tests/components/ConversationPane.test.tsx @@ -1,5 +1,5 @@ import { createRef, useRef, useState } from "react"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ConversationPane } from "@/pages/ConversationPage/components/ConversationPane"; import { AgentActivityMsgTypes, CSGCLAW_AGENT_ACTIVITY_TYPE } from "@/shared/constants/messages"; @@ -335,44 +335,43 @@ describe("ConversationPane", () => { ); }); - it("resizes the agent detail side panel from the separator", async () => { + it("renders agent details as a modal drawer with keyboard dismissal", async () => { const user = userEvent.setup(); - const onResize = vi.fn(); - const originalInnerWidth = window.innerWidth; - Object.defineProperty(window, "innerWidth", { configurable: true, value: 1400 }); - - try { - renderThreadPane({ - agentDetailPanelProps: { - item: { - id: "u-manager", - name: "manager", - role: "worker", - }, - t, - width: 760, - onClose: vi.fn(), - onDelete: vi.fn(), - onInvite: vi.fn(), - onOpenDM: vi.fn(), - onRecreate: vi.fn(), - onResize, - onStart: vi.fn(), - onStop: vi.fn(), + const onClose = vi.fn(); + const { container } = renderThreadPane({ + agentDetailPanelProps: { + item: { + id: "u-manager", + name: "manager", + role: "worker", }, - }); + t, + onClose, + onDelete: vi.fn(), + onInvite: vi.fn(), + onOpenDM: vi.fn(), + onRecreate: vi.fn(), + onStart: vi.fn(), + onStop: vi.fn(), + }, + }); - const separator = screen.getByRole("separator", { name: "resizeAgentDetailPanel" }); - separator.focus(); + const dialog = screen.getByRole("dialog", { name: "agentDetailPanel" }); + const closeButton = within(dialog).getByRole("button", { name: /close/i }); + const backdrop = document.querySelector(".agent-detail-drawer-backdrop"); - await user.keyboard("{ArrowLeft}"); - expect(onResize).toHaveBeenCalledWith(784); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(container).not.toContainElement(dialog); + expect(backdrop).toBeInTheDocument(); + expect(dialog.querySelector(".agent-detail-side-panel-bar")?.firstElementChild).toBe(closeButton); + await waitFor(() => expect(closeButton).toHaveFocus()); - await user.keyboard("{ArrowRight}"); - expect(onResize).toHaveBeenLastCalledWith(736); - } finally { - Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); - } + await user.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + + onClose.mockClear(); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalledTimes(1); }); it("keeps the caret at the end after slash text is tokenized in the main composer", async () => { diff --git a/web/app/tests/components/CreateModelProviderModal.test.tsx b/web/app/tests/components/CreateModelProviderModal.test.tsx index 855380f3..8fff8f80 100644 --- a/web/app/tests/components/CreateModelProviderModal.test.tsx +++ b/web/app/tests/components/CreateModelProviderModal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { CreateModelProviderModal } from "@/pages/WorkspacePage/components/WorkspaceModals"; import { normalizeModelProviderCatalog } from "@/models/modelProviders"; @@ -10,6 +10,7 @@ const labels: Record = { modelProviderAPIKeyHint: "Stored locally.", modelProviderAvatar: "Avatar", modelProviderBaseURLRequired: "Base URL is required.", + modelProviderCheck: "Check", modelProviderCreateAction: "Create", modelProviderCreateConnectionDescription: "Save the endpoint, key, and model list.", modelProviderCreateConnectionTitle: "Connection", @@ -26,6 +27,7 @@ const labels: Record = { modelProviderModelsHint: "Optional models.", modelProviderModelSearch: "Search models", modelProviderNoModels: "No models", + modelProviderNotChecked: "Not checked", modelProviderPreset: "Provider preset", modelProviderPresetCustom: "Custom", modelProviderPresetDeepSeek: "DeepSeek", @@ -45,12 +47,13 @@ describe("CreateModelProviderModal", () => { expect(screen.getByLabelText(/Display name/)).toHaveValue(""); expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); - expect(screen.getByLabelText(/Provider preset/)).toHaveValue("openai"); + expect(screen.getByRole("combobox", { name: "Provider preset" })).toHaveTextContent("OpenAI"); expect(screen.getByLabelText(/Base URL/)).toHaveValue("https://api.openai.com/v1"); expect(screen.getByLabelText(/API Key/)).toHaveValue(""); - expect(screen.getByLabelText(/API Key/)).toHaveAttribute("type", "text"); - expect(screen.getByRole("list", { name: "Models" })).toBeInTheDocument(); - expect(screen.getByText("No models")).toBeInTheDocument(); + expect(screen.getByLabelText(/API Key/)).toHaveAttribute("type", "password"); + const modelList = screen.getByRole("list", { name: "Models" }); + expect(modelList).toBeInTheDocument(); + expect(within(modelList).getByText("Not checked")).toBeInTheDocument(); expect(screen.queryByRole("textbox", { name: "Models" })).not.toBeInTheDocument(); }); @@ -78,7 +81,8 @@ describe("CreateModelProviderModal", () => { await user.clear(screen.getByLabelText(/Base URL/)); await user.type(screen.getByLabelText(/Base URL/), "http://127.0.0.1:4000/v1"); await user.type(screen.getByLabelText(/API Key/), "sk-test"); - await waitFor(() => expect(screen.getByText("gpt-4.1-mini")).toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "Check" })); + expect(await screen.findByText("gpt-4.1-mini")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Create" })); expect(onCreate).toHaveBeenCalledWith({ @@ -106,7 +110,7 @@ describe("CreateModelProviderModal", () => { expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); }); - it("auto-loads models after entering endpoint and API key", async () => { + it("loads models after an explicit connection check", async () => { const user = userEvent.setup(); const onCheckAccess = vi.fn().mockResolvedValue({ id: "openai-draft", @@ -130,7 +134,8 @@ describe("CreateModelProviderModal", () => { await user.type(screen.getByLabelText(/Base URL/), "http://127.0.0.1:4000/v1"); await user.type(screen.getByLabelText(/API Key/), "sk-test"); - await waitFor(() => expect(screen.getByText("gpt-4.1-mini")).toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "Check" })); + expect(await screen.findByText("gpt-4.1-mini")).toBeInTheDocument(); expect(screen.getByRole("list", { name: "Models" })).toBeInTheDocument(); expect(screen.queryByRole("textbox", { name: "Models" })).not.toBeInTheDocument(); expect(onCheckAccess).toHaveBeenCalledWith({ @@ -147,17 +152,18 @@ describe("CreateModelProviderModal", () => { render(); - await user.selectOptions(screen.getByLabelText(/Provider preset/), "zhipu"); + await user.click(screen.getByRole("combobox", { name: "Provider preset" })); + await user.click(screen.getByRole("option", { name: "Zhipu" })); expect(screen.getByLabelText(/Base URL/)).toHaveValue("https://open.bigmodel.cn/api/paas/v4"); expect(screen.getByLabelText(/Display name/)).toHaveAttribute("placeholder", "Zhipu API"); }); - it("renders failed provider checks as red form errors", async () => { + it("renders failed provider checks as inline warnings", async () => { const user = userEvent.setup(); const onCheckAccess = vi.fn().mockRejectedValue(new Error("status 401 Unauthorized")); - render( + const { container } = render( { ); await user.type(screen.getByLabelText(/API Key/), "bad-key"); + await user.click(screen.getByRole("button", { name: "Check" })); - const error = await screen.findByText("status 401 Unauthorized"); - expect(error).toHaveClass("form-error"); - expect(error).toHaveClass("create-model-provider-check-error"); - expect(error).not.toHaveClass("form-warning"); + await screen.findAllByText("status 401 Unauthorized"); + const warning = container.querySelector(".create-model-provider-check-status.warning"); + expect(warning).toHaveTextContent("status 401 Unauthorized"); + expect(warning).not.toHaveClass("form-error"); }); - it("keeps failed provider checks visible below the modal title", async () => { + it("keeps failed provider checks visible in the connection section", async () => { const user = userEvent.setup(); const onCheckAccess = vi.fn().mockRejectedValue(new Error("status 401 Unauthorized")); @@ -192,13 +199,15 @@ describe("CreateModelProviderModal", () => { ); await user.type(screen.getByLabelText(/API Key/), "bad-key"); + await user.click(screen.getByRole("button", { name: "Check" })); - const error = await screen.findByText("status 401 Unauthorized"); + await screen.findAllByText("status 401 Unauthorized"); + const warning = container.querySelector(".create-model-provider-check-status.warning"); const header = container.querySelector(".create-model-provider-modal .modal-header"); const body = container.querySelector(".create-model-provider-body"); - expect(header).toContainElement(error); - expect(body).not.toContainElement(error); - expect(error.previousElementSibling).toHaveClass("create-model-provider-subtitle"); + expect(header).not.toContainElement(warning as HTMLElement); + expect(body).toContainElement(warning as HTMLElement); + expect(warning?.closest(".create-model-provider-check-row")).toBeInTheDocument(); }); }); diff --git a/web/app/tests/components/MessageContent.test.tsx b/web/app/tests/components/MessageContent.test.tsx index b6f9877d..2c2b5351 100644 --- a/web/app/tests/components/MessageContent.test.tsx +++ b/web/app/tests/components/MessageContent.test.tsx @@ -19,7 +19,9 @@ function longMessageT(key: string): string { } function mockLongMessageLayout() { - const scrollHeightSpy = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (this: HTMLElement) { + const scrollHeightSpy = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function ( + this: HTMLElement, + ) { const text = (this.textContent || "").replace(/\s+/g, " ").trim(); if (!text) { return 0; @@ -114,7 +116,11 @@ describe("MessageContent", () => { const code = Array.from({ length: 24 }, (_, index) => `const item${index} = "line ${index}";`).join("\n"); const { container } = render( - , + , ); expect(await screen.findByRole("button", { name: "展开全文" })).toBeInTheDocument(); diff --git a/web/app/tests/components/ProfileControls.test.tsx b/web/app/tests/components/ProfileControls.test.tsx index 41db863f..b92563fc 100644 --- a/web/app/tests/components/ProfileControls.test.tsx +++ b/web/app/tests/components/ProfileControls.test.tsx @@ -40,7 +40,7 @@ describe("ProfileControls", () => { expect(screen.getByLabelText("API key")).toHaveValue(""); expect(screen.getByLabelText("API key")).not.toHaveAttribute("placeholder", "Enter API key"); - expect(container.querySelector(".api-key-mask-prefix")).toHaveTextContent("sk-test"); + expect(container.querySelector(".api-key-mask")).toHaveTextContent("sk-test..."); await user.type(screen.getByLabelText("API key"), "new-secret"); diff --git a/web/app/tests/components/ProfilePreviewPopover.test.tsx b/web/app/tests/components/ProfilePreviewPopover.test.tsx index 5f9a8271..b6136540 100644 --- a/web/app/tests/components/ProfilePreviewPopover.test.tsx +++ b/web/app/tests/components/ProfilePreviewPopover.test.tsx @@ -34,6 +34,39 @@ function t(key: string): string { } describe("ProfilePreviewPopover", () => { + it("keeps the preview outside its anchor when the page is zoomed", () => { + const previousZoom = document.documentElement.style.zoom; + document.documentElement.style.zoom = "0.8"; + const anchorRect = { top: 120, right: 502, bottom: 152, left: 470 }; + + const view = render( + ()} + agent={{ id: "agent-1", name: "Builder", role: "worker", status: "running" }} + user={{ id: "u-builder" }} + anchorRect={anchorRect} + t={t} + onClose={vi.fn()} + onOpenAgent={vi.fn()} + onOpenDM={vi.fn()} + />, + ); + + try { + const preview = screen.getByRole("dialog", { name: "Profile preview" }); + const scale = 0.8; + const visualLeft = Number.parseFloat(preview.style.left) * scale; + const visualTop = Number.parseFloat(preview.style.top) * scale; + + expect(visualLeft).toBe(anchorRect.right + 12); + expect(visualTop).toBe(anchorRect.top - 12); + expect(visualLeft).toBeGreaterThan(anchorRect.right); + } finally { + view.unmount(); + document.documentElement.style.zoom = previousZoom; + } + }); + it("shows compact agent runtime/provider/model fields with reasoning in model", () => { render( { fetchAgentProfileModels: vi.fn(), fetchAgentSkills: vi.fn(), fetchAgentSkillsFile: vi.fn(), + patchNotificationBotRequest: vi.fn(), fetchAgentWorkspace: vi.fn(), deleteFeishuParticipantRequest: vi.fn(), finalizeFeishuRegistrationRequest: vi.fn(), @@ -199,7 +201,7 @@ function useAgentControllerHarness( const selectAgent = selectAgentRef.current; const selectConversationRef = useRef(vi.fn()); const selectConversation = selectConversationRef.current; - const data = options.data ?? null; + const [data, setData] = useState(() => options.data ?? null); useEffect(() => { if (options.agents) { @@ -242,7 +244,9 @@ function useAgentControllerHarness( setAgentsData: (value: AgentLike[] | ((current: AgentLike[]) => AgentLike[])) => { setAgents((current) => (typeof value === "function" ? value(current) : value)); }, - setBootstrapData: vi.fn(), + setBootstrapData: (value) => { + setData((current) => (typeof value === "function" ? value(current) : value)); + }, setSelectedHubTemplateId: vi.fn(), t: options.t ?? t, }); @@ -274,6 +278,7 @@ describe("useAgentController", () => { vi.mocked(createUserRequest).mockReset(); vi.mocked(fetchAgentSkills).mockReset(); vi.mocked(fetchAgentSkillsFile).mockReset(); + vi.mocked(patchNotificationBotRequest).mockReset(); vi.mocked(fetchSkills).mockReset(); vi.mocked(deleteFeishuParticipantRequest).mockReset(); vi.mocked(finalizeFeishuRegistrationRequest).mockReset(); @@ -318,6 +323,13 @@ describe("useAgentController", () => { vi.mocked(createUserRequest).mockResolvedValue({ id: "u-worker", name: "worker" }); vi.mocked(fetchAgentSkills).mockResolvedValue({ entries: [] }); vi.mocked(fetchAgentSkillsFile).mockResolvedValue({ content: "", path: "SKILL.md", size: 0 }); + vi.mocked(patchNotificationBotRequest).mockImplementation(async (_agentID, payload) => ({ + bot_type: "notification", + id: "pt-notifier", + name: String(payload.name || "notifier"), + type: "notification", + user_id: "user-notifier", + })); vi.mocked(fetchSkills).mockResolvedValue([ { name: "alpha", description: "Alpha skill" }, { name: "beta", description: "Beta skill" }, @@ -1002,6 +1014,12 @@ describe("useAgentController", () => { }); it("clears expired Feishu pending registration from localStorage on load", async () => { + const workerAgent: AgentLike = { + ...oldAgent, + id: "u-dev", + name: "dev", + role: "worker", + }; window.localStorage.setItem( feishuRegistrationStorageKey, JSON.stringify({ @@ -1019,7 +1037,7 @@ describe("useAgentController", () => { () => useAgentControllerHarness({ activePane: { type: WorkspacePaneTypes.agent, id: "u-dev" }, - agents: [{ ...oldAgent, id: "u-dev", name: "dev", role: "worker" }], + agents: [workerAgent], }).controller, { wrapper: createWrapper() }, ); @@ -1457,6 +1475,69 @@ describe("useAgentController", () => { expect(patchCsgclawUserRequest).toHaveBeenCalledWith("user-worker", { avatar: selectedAvatar }); }); + it("keeps a migrated worker avatar after saving through its canonical IM user", async () => { + const originalAvatar = "avatar/3D-3.png"; + const nextAvatar = "avatar/cartoon-2.png"; + const workerAgent: AgentLike = { + agent_profile: profile, + id: "agent-zoyz2k", + image: oldImage, + name: "dev", + participants: [ + { + agent_id: "agent-zoyz2k", + channel: "feishu", + channel_user_kind: "app_id", + id: "pt-zoyz2k-5905c292", + type: "agent", + }, + ], + profile_complete: true, + role: "worker", + runtime_kind: "codex", + status: "running", + }; + vi.mocked(fetchAgent).mockReset(); + vi.mocked(fetchAgent).mockResolvedValue(workerAgent); + vi.mocked(updateAgentRequest).mockResolvedValue(workerAgent); + vi.mocked(patchCsgclawUserRequest).mockResolvedValue({ + avatar: nextAvatar, + id: "user-zoyz2k", + name: "dev", + }); + + const { result } = renderHook( + () => + useAgentControllerHarness({ + activePane: { type: WorkspacePaneTypes.agent, id: "agent-zoyz2k" }, + agents: [workerAgent], + data: { + current_user_id: "user-admin", + rooms: [], + users: [{ avatar: originalAvatar, id: "user-zoyz2k", name: "dev" }], + }, + }).controller, + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.agentViewProps.draft?.avatar).toBe(originalAvatar)); + + act(() => { + result.current.agentViewProps.onDraftChange?.({ + ...result.current.agentViewProps.draft!, + avatar: nextAvatar, + }); + }); + + await act(async () => { + await result.current.agentViewProps.onSave?.(); + }); + + expect(patchCsgclawUserRequest).toHaveBeenCalledWith("user-zoyz2k", { avatar: nextAvatar }); + expect(result.current.agentViewProps.draft?.avatar).toBe(nextAvatar); + expect(result.current.agentItems.find((agent) => agent.id === workerAgent.id)?.avatar).toBe(nextAvatar); + }); + it("retries template worker creation with a suffixed name when the template name already exists", async () => { const duplicateError: ApiError = { status: 409, @@ -1603,6 +1684,60 @@ describe("useAgentController", () => { expect(patchCsgclawUserRequest).toHaveBeenCalledWith("user-notifier", { avatar: selectedAvatar }); }); + it("saves an existing notification avatar through its linked CSGClaw user", async () => { + const originalAvatar = "avatar/3D-4.png"; + const nextAvatar = "avatar/cartoon-4.png"; + const notificationAgent: AgentLike = { + bot_type: "notification", + id: "pt-notifier", + name: "notifier", + role: "worker", + runtime_kind: "notifier", + runtime_options: { + notifier: { + delivery_mode: "webhook", + webhook_token: "configured", + }, + }, + type: "notification", + user_id: "user-notifier", + }; + + const { result } = renderHook( + () => + useAgentControllerHarness({ + activePane: { type: WorkspacePaneTypes.agent, id: "pt-notifier" }, + agents: [notificationAgent], + data: { + current_user_id: "user-admin", + rooms: [], + users: [{ avatar: originalAvatar, id: "user-notifier", name: "notifier" }], + }, + }).controller, + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.agentViewProps.draft?.avatar).toBe(originalAvatar)); + + act(() => { + result.current.agentViewProps.onDraftChange?.({ + ...result.current.agentViewProps.draft!, + avatar: nextAvatar, + }); + }); + + await act(async () => { + await result.current.agentViewProps.onSave?.(); + }); + + expect(patchNotificationBotRequest).toHaveBeenCalledWith( + "pt-notifier", + expect.objectContaining({ name: "notifier" }), + ); + expect(patchCsgclawUserRequest).toHaveBeenCalledWith("user-notifier", { avatar: nextAvatar }); + expect(result.current.agentViewProps.draft?.avatar).toBe(nextAvatar); + }); + it("initializes create agent drafts from the first available model provider when defaults are empty", async () => { vi.mocked(fetchAgentProfileDefaults).mockResolvedValueOnce({}); const modelProviders = normalizeModelProviderCatalog({ diff --git a/web/app/tests/hooks/useAgentRuntimes.test.tsx b/web/app/tests/hooks/useAgentRuntimes.test.tsx new file mode 100644 index 00000000..9e1fb342 --- /dev/null +++ b/web/app/tests/hooks/useAgentRuntimes.test.tsx @@ -0,0 +1,181 @@ +import type { ReactNode } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fetchAgentRuntimes, installAgentRuntimeRequest } from "@/api/agentRuntimes"; +import { useAgentRuntimes } from "@/pages/ComputerPage/useAgentRuntimes"; +import { workspaceQueryKeys } from "@/hooks/workspace/workspaceQueries"; +import type { TranslateFn } from "@/models/conversations"; + +vi.mock("@/api/agentRuntimes", async () => { + const actual = await vi.importActual("@/api/agentRuntimes"); + return { + ...actual, + fetchAgentRuntimes: vi.fn(), + installAgentRuntimeRequest: vi.fn(), + }; +}); + +const t: TranslateFn = (key) => { + if (key === "computerRuntimeInstallFailed") { + return "Failed to install Codex CLI. Please retry."; + } + if (key === "computerRuntimesLoadFailed") { + return "Failed to load agent runtimes. Please retry."; + } + return key; +}; + +const missingCodex = { + name: "codex", + label: "Codex CLI", + supported: true, + installed: false, + installable: true, + status: "not_installed", +}; + +const claudeCode = { + name: "claude_code", + label: "Claude Code", + supported: false, + installed: false, + installable: false, + status: "coming_soon", +}; + +function createHarness() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + retry: false, + }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { queryClient, wrapper }; +} + +describe("useAgentRuntimes", () => { + beforeEach(() => { + vi.mocked(fetchAgentRuntimes).mockReset(); + vi.mocked(installAgentRuntimeRequest).mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("updates the runtime cache and invalidates bootstrap readiness after install", async () => { + vi.mocked(fetchAgentRuntimes).mockResolvedValue([missingCodex, claudeCode]); + vi.mocked(installAgentRuntimeRequest).mockResolvedValue({ + ...missingCodex, + installed: true, + status: "installed", + path: "/opt/csgclaw/codex", + }); + const { queryClient, wrapper } = createHarness(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const { result } = renderHook(() => useAgentRuntimes(t), { wrapper }); + + await waitFor(() => expect(result.current.runtimes).toHaveLength(2)); + await act(async () => { + await result.current.installRuntime("codex"); + }); + + await waitFor(() => + expect(result.current.runtimes.find((runtime) => runtime.name === "codex")).toMatchObject({ + installed: true, + path: "/opt/csgclaw/codex", + }), + ); + await waitFor(() => expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKeys.bootstrapConfig() })); + expect(installAgentRuntimeRequest).toHaveBeenCalledWith("codex"); + expect(result.current.installError).toBe(""); + }); + + it("surfaces a failed install and lets the same manual action retry", async () => { + vi.mocked(fetchAgentRuntimes) + .mockResolvedValueOnce([missingCodex, claudeCode]) + .mockResolvedValueOnce([ + { ...missingCodex, status: "failed", message: "download service unavailable" }, + claudeCode, + ]); + vi.mocked(installAgentRuntimeRequest) + .mockRejectedValueOnce({ status: 502, message: "download service unavailable" }) + .mockResolvedValueOnce({ + ...missingCodex, + installed: true, + status: "installed", + path: "/opt/csgclaw/codex", + }); + const { wrapper } = createHarness(); + const { result } = renderHook(() => useAgentRuntimes(t), { wrapper }); + + await waitFor(() => expect(result.current.runtimes).toHaveLength(2)); + await act(async () => { + await result.current.installRuntime("codex"); + }); + + expect(result.current.installError).toBe("download service unavailable"); + expect(result.current.runtimes.find((runtime) => runtime.name === "codex")?.status).toBe("failed"); + + await act(async () => { + await result.current.installRuntime("codex"); + }); + + expect(installAgentRuntimeRequest).toHaveBeenCalledTimes(2); + expect(result.current.installError).toBe(""); + expect(result.current.runtimes.find((runtime) => runtime.name === "codex")?.installed).toBe(true); + }); + + it("polls a first not-installed response until the serve-time install becomes visible", async () => { + vi.mocked(fetchAgentRuntimes) + .mockResolvedValueOnce([missingCodex, claudeCode]) + .mockResolvedValueOnce([ + { + ...missingCodex, + installed: true, + status: "installed", + path: "/opt/csgclaw/codex", + }, + claudeCode, + ]); + const { wrapper } = createHarness(); + const { result } = renderHook(() => useAgentRuntimes(t), { wrapper }); + + await waitFor(() => + expect(result.current.runtimes.find((runtime) => runtime.name === "codex")?.installed).toBe(false), + ); + await waitFor( + () => expect(result.current.runtimes.find((runtime) => runtime.name === "codex")?.installed).toBe(true), + { timeout: 3000 }, + ); + expect(fetchAgentRuntimes).toHaveBeenCalledTimes(2); + }); + + it("deduplicates rapid install clicks while the first request is active", async () => { + vi.mocked(fetchAgentRuntimes).mockResolvedValue([missingCodex, claudeCode]); + let resolveInstall: ((value: unknown) => void) | undefined; + vi.mocked(installAgentRuntimeRequest).mockImplementation( + () => + new Promise((resolve) => { + resolveInstall = resolve; + }), + ); + const { wrapper } = createHarness(); + const { result } = renderHook(() => useAgentRuntimes(t), { wrapper }); + + await waitFor(() => expect(result.current.runtimes).toHaveLength(2)); + await act(async () => { + const first = result.current.installRuntime("codex"); + const duplicate = result.current.installRuntime("codex"); + resolveInstall?.({ ...missingCodex, installed: true, status: "installed" }); + await Promise.all([first, duplicate]); + }); + + expect(installAgentRuntimeRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/app/tests/hooks/useConversationController.test.tsx b/web/app/tests/hooks/useConversationController.test.tsx index e1952996..ec05c6d9 100644 --- a/web/app/tests/hooks/useConversationController.test.tsx +++ b/web/app/tests/hooks/useConversationController.test.tsx @@ -191,6 +191,7 @@ describe("useConversationController", () => { id: "msg-tool", content: '📄 Web Fetch: from https://example.com {"url":"https://example.com"}', created_at: "2026-06-16T10:00:10Z", + metadata: { openclaw: { delivery_kind: "tool", request_id: "msg-user" } }, sender_id: "u-demo", }, }); @@ -259,6 +260,7 @@ describe("useConversationController", () => { id: "msg-tool", content: '📄 Web Fetch: from https://example.com {"url":"https://example.com"}', created_at: new Date().toISOString(), + metadata: { openclaw: { delivery_kind: "tool", request_id: "msg-user" } }, sender_id: "u-demo", }, ]), diff --git a/web/app/tests/legacy-contract.test.ts b/web/app/tests/legacy-contract.test.ts index 3c148292..1919fea6 100644 --- a/web/app/tests/legacy-contract.test.ts +++ b/web/app/tests/legacy-contract.test.ts @@ -134,6 +134,20 @@ describe("legacy UI contract", () => { expect(styles).toContain("width: 6px;"); }); + it("keeps the full exec_command activity label visible", () => { + const activityListRule = styleRule(".agent-activity-list"); + const activityRowRule = styleRule(".agent-activity-row-main"); + const activityBadgeRule = styleRule(".agent-activity-type-badge"); + + expect(activityListRule).toContain("--agent-activity-type-column-width: 128px;"); + expect(activityRowRule).toContain("var(--agent-activity-type-column-width)"); + expect(activityBadgeRule).toContain("width: var(--agent-activity-type-column-width);"); + expect(activityBadgeRule).toContain("max-width: var(--agent-activity-type-column-width);"); + expect(styles).toMatch( + /:root\[data-theme="dark"\] \.agent-activity-row:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--gray-800\) 72%, var\(--panel\)\);/, + ); + }); + it("keeps the mention picker scrollbar slim", () => { expect(styles).toMatch(/\.mention-picker\s*\{[\s\S]*scrollbar-width:\s*thin;/); expect(styles).toMatch(/\.mention-picker\s*\{[\s\S]*z-index:\s*var\(--z-portal-popover\);/); @@ -152,11 +166,13 @@ describe("legacy UI contract", () => { }); it("keeps the model provider page scrollable inside the fixed workspace panel", () => { + const panelRule = styleRule(".chat-panel:has(> .model-provider-page)"); const pageRule = styleRule(".model-provider-page"); const modelListRule = styleRule(".model-provider-model-list"); - expect(pageRule).toMatch(/(?:^|[;\s])height:\s*100%;/); - expect(pageRule).toContain("min-height: 0;"); + expect(panelRule).toContain("grid-template-rows: minmax(0, 1fr);"); + expect(pageRule).toContain("height: auto;"); + expect(pageRule).toContain("min-height: 100%;"); expect(pageRule).toContain("overflow-x: hidden;"); expect(pageRule).toContain("overflow-y: auto;"); expect(modelListRule).toContain("scrollbar-width: thin;"); @@ -168,8 +184,8 @@ describe("legacy UI contract", () => { expect(source).toContain( "const directAgent = isDirect && displayUser ? agents.find((item) => agentMatchesUser(item, displayUser)) : null;", ); - expect(source).toContain('className={`workspace-status-dot ${directAgentRunning ? "online" : ""}`}'); - expect(source).toMatch(/directMessages\.map[\s\S]*agents=\{agentItems\}/); + expect(source).toContain("className={classNames(styles.statusDot, directAgentRunning && styles.online)}"); + expect(source).toMatch(/visibleDirectMessages\.map[\s\S]*agents=\{agentItems\}/); expect(source).toContain("const messageAgent = resolveMessageAgent(agents, user, message.sender_id);"); expect(source).toContain("return agentMatchesUser(item, { ...user, id: senderIdentity, user_id: user.id });"); expect(source).toContain('className={`message-avatar-status ${messageAgentRunning ? "online" : ""}`}'); diff --git a/web/app/tests/models/agentRuntimes.test.ts b/web/app/tests/models/agentRuntimes.test.ts new file mode 100644 index 00000000..827a8cb9 --- /dev/null +++ b/web/app/tests/models/agentRuntimes.test.ts @@ -0,0 +1,100 @@ +import { + AgentRuntimeStatuses, + agentRuntimeByName, + normalizeAgentRuntime, + normalizeAgentRuntimeList, + shouldPollAgentRuntimeInstallation, + upsertAgentRuntime, +} from "@/models/agentRuntimes"; + +describe("agent runtimes", () => { + it("normalizes the API list and keeps Codex before Claude Code", () => { + const runtimes = normalizeAgentRuntimeList([ + { + name: "claude-code", + label: "Claude Code", + supported: false, + installed: false, + installable: false, + status: "coming_soon", + docs_url: "https://example.com/claude", + }, + { name: "", label: "Invalid" }, + { + name: "codex", + label: "Codex CLI", + supported: true, + installed: true, + installable: true, + status: "installed", + path: "/tmp/codex", + os: "darwin", + arch: "arm64", + }, + ]); + + expect(runtimes.map((runtime) => runtime.name)).toEqual(["codex", "claude_code"]); + expect(runtimes[0]).toMatchObject({ + installed: true, + path: "/tmp/codex", + status: AgentRuntimeStatuses.installed, + }); + expect(runtimes[1]).toMatchObject({ + docsURL: "https://example.com/claude", + status: AgentRuntimeStatuses.comingSoon, + }); + }); + + it("derives safe fallback states for incomplete records", () => { + expect(normalizeAgentRuntime({ name: "codex", status: "unexpected" })).toMatchObject({ + label: "Codex CLI", + status: AgentRuntimeStatuses.notInstalled, + supported: true, + }); + expect(normalizeAgentRuntime({ name: "claude_code", status: "unexpected" })).toMatchObject({ + label: "Claude Code", + status: AgentRuntimeStatuses.comingSoon, + supported: false, + }); + expect(normalizeAgentRuntime({ name: "codex", status: "unsupported", installable: true })).toMatchObject({ + status: AgentRuntimeStatuses.unsupported, + installed: false, + }); + expect(normalizeAgentRuntime(null)).toBeNull(); + }); + + it("upserts install results without disturbing runtime order", () => { + const initial = normalizeAgentRuntimeList([ + { name: "codex", status: "not_installed", installed: false, installable: true, supported: true }, + { name: "claude_code", status: "coming_soon", installed: false, supported: false }, + ]); + + const updated = upsertAgentRuntime(initial, { + name: "codex", + status: "installed", + installed: true, + installable: true, + supported: true, + path: "/opt/csgclaw/codex", + }); + + expect(updated.map((runtime) => runtime.name)).toEqual(["codex", "claude_code"]); + expect(agentRuntimeByName(updated, "codex")).toMatchObject({ + installed: true, + path: "/opt/csgclaw/codex", + }); + }); + + it("polls during the serve-time install race and stops at terminal states", () => { + const runtime = (status: string) => + normalizeAgentRuntimeList([ + { name: "codex", status, installed: status === "installed", supported: true, installable: true }, + ]); + + expect(shouldPollAgentRuntimeInstallation(runtime("not_installed"))).toBe(true); + expect(shouldPollAgentRuntimeInstallation(runtime("installing"))).toBe(true); + expect(shouldPollAgentRuntimeInstallation(runtime("installed"))).toBe(false); + expect(shouldPollAgentRuntimeInstallation(runtime("failed"))).toBe(false); + expect(shouldPollAgentRuntimeInstallation(runtime("unsupported"))).toBe(false); + }); +}); diff --git a/web/app/tests/models/agents.test.ts b/web/app/tests/models/agents.test.ts index e1415606..01339c33 100644 --- a/web/app/tests/models/agents.test.ts +++ b/web/app/tests/models/agents.test.ts @@ -723,6 +723,30 @@ describe("agent model helpers", () => { ).toBe("avatar/3D-5.png"); }); + it("resolves migrated agent avatars from canonical IM users without a local participant", () => { + const usersById = new Map([["user-zoyz2k", { id: "user-zoyz2k", avatar: "avatar/3D-3.png", name: "dev" }]]); + + expect( + resolveAgentAvatarSource( + { + id: "agent-zoyz2k", + name: "dev", + role: "worker", + participants: [ + { + agent_id: "agent-zoyz2k", + channel: "feishu", + channel_user_kind: "app_id", + id: "pt-zoyz2k-5905c292", + type: "agent", + }, + ], + }, + usersById, + ), + ).toBe("avatar/3D-3.png"); + }); + it("selects a built-in avatar that is not already used", () => { const availableAvatar = AGENT_AVATAR_OPTIONS.at(-1)?.value || ""; const sources = AGENT_AVATAR_OPTIONS.slice(0, -1).map((option) => ({ avatar: option.value })); diff --git a/web/app/tests/models/conversations.test.ts b/web/app/tests/models/conversations.test.ts index e52cc72f..56a84761 100644 --- a/web/app/tests/models/conversations.test.ts +++ b/web/app/tests/models/conversations.test.ts @@ -87,7 +87,12 @@ describe("conversation model helpers", () => { const usersById = new Map([["user-dev", { id: "user-dev", name: "dev" }]]); const message = { content: "Task task-2 assigned to you.\n\nClaim it with: csgclaw-cli task claim --task task-2", - event: { actor_id: "user-manager", key: "task_assigned", target_ids: ["user-dev"], title: "task-2 [查询成都天气]" }, + event: { + actor_id: "user-manager", + key: "task_assigned", + target_ids: ["user-dev"], + title: "task-2 [查询成都天气]", + }, kind: "event", sender_id: "user-manager", }; diff --git a/web/app/tests/shared/appScale.test.ts b/web/app/tests/shared/appScale.test.ts new file mode 100644 index 00000000..5053e30a --- /dev/null +++ b/web/app/tests/shared/appScale.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function readSource(path: string): string { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("default app scale", () => { + it("renders the full UI at 80 percent while preserving the scaled viewport height", () => { + const globals = readSource("src/shared/styles/globals.css"); + const workspace = readSource("src/pages/WorkspacePage/components/WorkspaceComponents.css"); + const workspaceLayout = readSource("src/pages/WorkspacePage/components/WorkspaceLayout/WorkspaceLayout.module.css"); + const settings = readSource("src/pages/SettingsPage/SettingsPage.module.css"); + + expect(globals).toContain("@supports (zoom: 80%)"); + expect(globals).toContain("--app-ui-viewport-height: 125dvh;"); + expect(globals).toContain("--text-xs-size: 13px;"); + expect(globals).toContain("--text-sm-size: 15px;"); + expect(globals).toContain("--text-md-size: 17px;"); + expect(globals).toContain("font-size-adjust: 0.65;"); + expect(globals).toContain("zoom: 80%;"); + expect(globals).toContain("font-size: var(--text-md-size);"); + expect(globals).toContain("font-family: var(--font-sans);"); + expect(workspace).toContain("height: var(--app-ui-viewport-height);"); + expect(workspaceLayout.match(/height: var\(--app-ui-viewport-height\);/g)).toHaveLength(3); + expect(settings).toContain("min-height: var(--app-ui-viewport-height);"); + }); +});