diff --git a/cli/serve/serve.go b/cli/serve/serve.go index 84d5cb93..3a5d87f8 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -677,6 +677,7 @@ listen_addr = %q advertise_base_url = %q access_token = %q no_auth = %t +show_upgrade = %t [bootstrap] default_manager_template = %q @@ -684,7 +685,7 @@ default_worker_template = %q [sandbox] provider = %q -`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), cfg.Server.NoAuth, cfg.Bootstrap.ResolvedDefaultManagerTemplate(), cfg.Bootstrap.ResolvedDefaultWorkerTemplate(), cfg.Sandbox.Resolved().Provider) +`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), cfg.Server.NoAuth, cfg.Server.ShowUpgrade, cfg.Bootstrap.ResolvedDefaultManagerTemplate(), cfg.Bootstrap.ResolvedDefaultWorkerTemplate(), cfg.Sandbox.Resolved().Provider) if len(cfg.Sandbox.Resolved().DebianRegistriesOverride) > 0 { content += fmt.Sprintf("debian_registries_override = %s\n", formatModelList(cfg.Sandbox.Resolved().DebianRegistriesOverride)) } else { diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index a613933b..1482d00e 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -558,6 +558,7 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { AdvertiseBaseURL: "http://example.test", AccessToken: "pc-secret", NoAuth: true, + ShowUpgrade: true, }, Model: config.ModelConfig{ Provider: "llm-api", @@ -619,6 +620,7 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { `api_key = "sk*****et"`, `access_token = "pc*****et"`, `no_auth = true`, + `show_upgrade = true`, `[sandbox]`, fmt.Sprintf(`provider = %q`, config.DockerProvider), `debian_registries_override = []`, @@ -1015,6 +1017,7 @@ func TestFormatEffectiveConfigFormatsSectionsWithoutExtraWhitespace(t *testing.T AdvertiseBaseURL: "http://192.168.2.52:18080", AccessToken: "your_access_token", NoAuth: true, + ShowUpgrade: true, }, Models: config.SingleProfileLLM(config.ModelConfig{ BaseURL: "http://127.0.0.1:4000", @@ -1054,6 +1057,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://192.168.2.52:18080" access_token = "yo*************en" no_auth = true +show_upgrade = true [bootstrap] default_manager_template = "builtin/picoclaw-manager" @@ -1111,6 +1115,7 @@ func TestFormatEffectiveConfigIncludesDefaultHubRegistriesWhenOmitted(t *testing ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "your_access_token", + ShowUpgrade: true, }, Bootstrap: config.BootstrapConfig{ DefaultManagerTemplate: "builtin/picoclaw-manager", @@ -1217,6 +1222,7 @@ func csgHubLiteServeConfig(baseURL string) config.Config { Server: config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AccessToken: "pc-secret", + ShowUpgrade: true, }, Models: config.LLMConfig{ Default: "csghub-lite.Qwen/Qwen3-0.6B-GGUF", diff --git a/docs/config.md b/docs/config.md index b8c7a0e6..fb03e73a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -16,6 +16,8 @@ Use `advertise_base_url` when the automatically inferred address is not reachabl `no_auth` controls whether CSGClaw skips the bearer-token check. The default is `false`. Set it to `true` only for trusted local or development environments. +`show_upgrade` controls whether the Web UI shows upgrade actions. The default is `true`; set it to `false` only when the deployment cannot self-upgrade, such as managed Kubernetes environments. + String values in `config.toml` can reference environment variables with `${NAME}` or `$NAME`. CSGClaw expands them when loading the config and keeps the placeholder form when it later rewrites the same value. If an environment variable is not set, it expands to an empty string. ```toml @@ -24,6 +26,7 @@ listen_addr = "0.0.0.0:${PORT}" advertise_base_url = "http://${IP}:${PORT}" access_token = "${ACCESS_TOKEN}" no_auth = false +show_upgrade = true ``` ## Model Provider Examples @@ -36,6 +39,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" no_auth = false +show_upgrade = true [models] default = "csghub-lite.Qwen/Qwen3-0.6B-GGUF" @@ -61,6 +65,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" no_auth = false +show_upgrade = true [models] default = "remote.gpt-5.4" @@ -86,6 +91,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" no_auth = false +show_upgrade = true [bootstrap] manager_image_override = "" diff --git a/docs/config.zh.md b/docs/config.zh.md index 9c3e04f7..e5d6e088 100644 --- a/docs/config.zh.md +++ b/docs/config.zh.md @@ -16,6 +16,8 @@ `no_auth` 控制 CSGClaw 是否跳过 bearer token 检查,默认值是 `false`。仅建议在可信的本地或开发环境中设置为 `true`。 +`show_upgrade` 控制 Web UI 是否展示升级操作。默认值是 `true`;仅在当前部署不能自升级时设置为 `false`,例如托管的 Kubernetes 环境。 + `config.toml` 中的字符串值可以通过 `${NAME}` 或 `$NAME` 引用环境变量。CSGClaw 读取配置时会展开这些变量;后续重写同一个值时,会尽量保留占位符形式。如果环境变量未设置,会展开为空字符串。 ```toml @@ -24,6 +26,7 @@ listen_addr = "0.0.0.0:${PORT}" advertise_base_url = "http://${IP}:${PORT}" access_token = "${ACCESS_TOKEN}" no_auth = false +show_upgrade = true ``` ## Model Provider 配置示例 @@ -36,6 +39,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" no_auth = false +show_upgrade = true [models] default = "csghub-lite.Qwen/Qwen3-0.6B-GGUF" @@ -61,6 +65,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" no_auth = false +show_upgrade = true [models] default = "remote.gpt-5.4" @@ -86,6 +91,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" no_auth = false +show_upgrade = true [bootstrap] manager_image_override = "" diff --git a/docs/sandbox/csghub.md b/docs/sandbox/csghub.md index 4657a1bd..93528807 100644 --- a/docs/sandbox/csghub.md +++ b/docs/sandbox/csghub.md @@ -197,6 +197,8 @@ populate, at minimum: - `CSGHUB_API_BASE_URL`, `CSGHUB_USER_TOKEN` - `CSGCLAW_RESOURCE_ID`, `CSGCLAW_CLUSTER_ID` *(optional but recommended)* - a `config.toml` whose `[sandbox].provider` is `csghub`, whose + `[server].show_upgrade` is `false` for managed deployments that cannot + self-upgrade, whose `[bootstrap].manager_image_override` points at the `csgclaw-agent-sandbox` image when you need to override the built-in default, and whose `[server]` / `[models]` sections are valid for the diff --git a/internal/api/handler.go b/internal/api/handler.go index c6d00dc5..1ec3b525 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -83,6 +83,7 @@ type bootstrapConfigResponse struct { DefaultManagerTemplate string `json:"default_manager_template"` DefaultWorkerTemplate string `json:"default_worker_template"` RuntimeKind string `json:"runtime_kind"` + ShowUpgrade bool `json:"show_upgrade"` EffectiveManagerImage string `json:"effective_manager_image"` AdvertiseBaseURL string `json:"advertise_base_url,omitempty"` SupportedRuntimeKinds []string `json:"supported_runtime_kinds"` @@ -184,6 +185,7 @@ func (h *Handler) loadBootstrapConfig() (config.Config, string, error) { ListenAddr: config.DefaultListenAddr(), AccessToken: config.DefaultAccessToken, NoAuth: false, + ShowUpgrade: true, }, Bootstrap: config.BootstrapConfig{}, Sandbox: config.SandboxConfig{ @@ -203,6 +205,7 @@ func bootstrapConfigView(ctx context.Context, cfg config.Config, hubSvc *hub.Ser resp := bootstrapConfigResponse{ DefaultManagerTemplate: cfg.Bootstrap.ResolvedDefaultManagerTemplate(), DefaultWorkerTemplate: cfg.Bootstrap.ResolvedDefaultWorkerTemplate(), + ShowUpgrade: cfg.Server.ShowUpgrade, AdvertiseBaseURL: config.ResolveAdvertiseBaseURL(cfg.Server), SupportedRuntimeKinds: []string{ agent.RuntimeKindPicoClawSandbox, diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index fd9b8f0e..b285cf55 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -242,6 +242,29 @@ func TestHandleVersionMethodNotAllowed(t *testing.T) { } } +func TestBootstrapConfigViewUsesServerUpgradeVisibility(t *testing.T) { + tests := []struct { + name string + configValue bool + showUpgrade bool + }{ + {name: "shown", configValue: true, showUpgrade: true}, + {name: "hidden", configValue: false, showUpgrade: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := bootstrapConfigView(context.Background(), config.Config{ + Server: config.ServerConfig{ShowUpgrade: tt.configValue}, + }, nil) + + if got.ShowUpgrade != tt.showUpgrade { + t.Fatalf("ShowUpgrade = %t, want %t", got.ShowUpgrade, tt.showUpgrade) + } + }) + } +} + func TestHandleFeishuRoomsMembers(t *testing.T) { feishuSvc := feishu.NewServiceWithCreateChatAndAddMembers( map[string]feishu.AppConfig{ diff --git a/internal/config/config.go b/internal/config/config.go index e43251c4..7e79c17b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,7 @@ type ServerConfig struct { AdvertiseBaseURL string AccessToken string NoAuth bool + ShowUpgrade bool } type ModelConfig struct { @@ -415,6 +416,9 @@ func Load(path string) (Config, error) { modelsCfg := newLLMConfig() cfg := Config{ + Server: ServerConfig{ + ShowUpgrade: true, + }, Models: modelsCfg, LLM: newLLMConfig(), raw: rawConfigValues{ @@ -477,6 +481,12 @@ func Load(path string) (Config, error) { return Config{}, fmt.Errorf("parse server.no_auth: %w", err) } cfg.Server.NoAuth = noAuth + case "show_upgrade": + showUpgrade, err := parseBoolValue(rawValue) + if err != nil { + return Config{}, fmt.Errorf("parse server.show_upgrade: %w", err) + } + cfg.Server.ShowUpgrade = showUpgrade } case section == "models": switch key { @@ -667,11 +677,12 @@ listen_addr = %q advertise_base_url = %q access_token = %q no_auth = %t +show_upgrade = %t [bootstrap] default_manager_template = %q default_worker_template = %q -`, cfg.rawOrResolvedString(cfg.raw.server.ListenAddr, loadedRaw.server.ListenAddr, cfg.Server.ListenAddr), cfg.rawOrResolvedString(cfg.raw.server.AdvertiseBaseURL, loadedRaw.server.AdvertiseBaseURL, cfg.Server.AdvertiseBaseURL), cfg.rawOrResolvedString(cfg.raw.server.AccessToken, loadedRaw.server.AccessToken, cfg.Server.AccessToken), cfg.Server.NoAuth, cfg.rawOrResolvedString(cfg.raw.bootstrap.DefaultManagerTemplate, loadedRaw.bootstrap.DefaultManagerTemplate, cfg.Bootstrap.ResolvedDefaultManagerTemplate()), cfg.rawOrResolvedString(cfg.raw.bootstrap.DefaultWorkerTemplate, loadedRaw.bootstrap.DefaultWorkerTemplate, cfg.Bootstrap.ResolvedDefaultWorkerTemplate())) +`, cfg.rawOrResolvedString(cfg.raw.server.ListenAddr, loadedRaw.server.ListenAddr, cfg.Server.ListenAddr), cfg.rawOrResolvedString(cfg.raw.server.AdvertiseBaseURL, loadedRaw.server.AdvertiseBaseURL, cfg.Server.AdvertiseBaseURL), cfg.rawOrResolvedString(cfg.raw.server.AccessToken, loadedRaw.server.AccessToken, cfg.Server.AccessToken), cfg.Server.NoAuth, cfg.Server.ShowUpgrade, cfg.rawOrResolvedString(cfg.raw.bootstrap.DefaultManagerTemplate, loadedRaw.bootstrap.DefaultManagerTemplate, cfg.Bootstrap.ResolvedDefaultManagerTemplate()), cfg.rawOrResolvedString(cfg.raw.bootstrap.DefaultWorkerTemplate, loadedRaw.bootstrap.DefaultWorkerTemplate, cfg.Bootstrap.ResolvedDefaultWorkerTemplate())) sandboxSection := fmt.Sprintf(` [sandbox] provider = %q diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7c0de069..e8e032fb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -84,6 +84,9 @@ models = ["minimax-m2.7"] if cfg.Server.NoAuth { t.Fatal("cfg.Server.NoAuth = true, want false") } + if !cfg.Server.ShowUpgrade { + t.Fatal("cfg.Server.ShowUpgrade = false, want true") + } if got, want := cfg.Sandbox.Provider, DockerProvider; got != want { t.Fatalf("cfg.Sandbox.Provider = %q, want %q", got, want) } @@ -98,6 +101,34 @@ models = ["minimax-m2.7"] } } +func TestLoadReadsServerShowUpgrade(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := `[server] +listen_addr = "127.0.0.1:18080" +show_upgrade = false + +[models] +default = "default.minimax-m2.7" + +[models.providers.default] +base_url = "http://127.0.0.1:4000" +api_key = "sk" +models = ["minimax-m2.7"] +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Server.ShowUpgrade { + t.Fatal("cfg.Server.ShowUpgrade = true, want false") + } +} + func TestBootstrapValidateUsesDefaultTemplatesWhenUnset(t *testing.T) { cfg := BootstrapConfig{ DefaultManagerTemplate: "", @@ -735,6 +766,7 @@ func TestSaveWritesModelsSection(t *testing.T) { ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", + ShowUpgrade: true, }, Models: models, LLM: models, @@ -764,6 +796,9 @@ func TestSaveWritesModelsSection(t *testing.T) { if !strings.Contains(content, "no_auth = false") { t.Fatalf("saved config missing server no_auth:\n%s", content) } + if !strings.Contains(content, "show_upgrade = true") { + t.Fatalf("saved config missing server show_upgrade:\n%s", content) + } if !strings.Contains(content, "[models]") || !strings.Contains(content, "[models.providers.default]") { t.Fatalf("saved config missing models sections:\n%s", content) } @@ -820,6 +855,7 @@ func TestSaveWritesCSGHubLiteProvider(t *testing.T) { Server: ServerConfig{ ListenAddr: "127.0.0.1:18080", AccessToken: "shared-token", + ShowUpgrade: true, }, Models: models, LLM: models, @@ -866,6 +902,7 @@ func TestSaveFormatsTopLevelSectionsWithoutExtraWhitespace(t *testing.T) { AdvertiseBaseURL: "http://192.168.2.52:18080", AccessToken: "your_access_token", NoAuth: true, + ShowUpgrade: true, }, Models: models, LLM: models, @@ -894,6 +931,7 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://192.168.2.52:18080" access_token = "your_access_token" no_auth = true +show_upgrade = true [bootstrap] default_manager_template = "builtin/picoclaw-manager" @@ -945,6 +983,7 @@ func TestSaveWritesHubConfig(t *testing.T) { ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", + ShowUpgrade: true, }, Hub: HubConfig{ DefaultRegistry: "builtin", @@ -1002,6 +1041,7 @@ func TestSaveWritesEmptySandboxDebianRegistriesOverride(t *testing.T) { ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", + ShowUpgrade: true, }, Sandbox: SandboxConfig{ Provider: BoxLiteProvider, diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go index 6d6e3692..09d12b78 100644 --- a/internal/onboard/onboard.go +++ b/internal/onboard/onboard.go @@ -159,6 +159,7 @@ func defaultConfig() config.Config { ListenAddr: config.DefaultListenAddr(), AccessToken: config.DefaultAccessToken, NoAuth: false, + ShowUpgrade: true, }, Bootstrap: config.BootstrapConfig{}, Sandbox: config.SandboxConfig{}, diff --git a/internal/onboard/onboard_test.go b/internal/onboard/onboard_test.go index fc2250f1..522d3c76 100644 --- a/internal/onboard/onboard_test.go +++ b/internal/onboard/onboard_test.go @@ -78,6 +78,7 @@ func TestEnsureStateCreatesConfigAndBootstrapsManagerState(t *testing.T) { } for _, want := range []string{ `[server]`, + `show_upgrade = true`, `[bootstrap]`, `[sandbox]`, `provider = ""`, @@ -340,6 +341,7 @@ access_token = "your_access_token" content := string(data) for _, want := range []string{ `no_auth = false`, + `show_upgrade = true`, `[bootstrap]`, `default_manager_template = "builtin/picoclaw-manager"`, `default_worker_template = "builtin/picoclaw-worker"`, diff --git a/web/app/src/hooks/workspace/useWorkspaceController.ts b/web/app/src/hooks/workspace/useWorkspaceController.ts index 8464947f..836b2d6e 100644 --- a/web/app/src/hooks/workspace/useWorkspaceController.ts +++ b/web/app/src/hooks/workspace/useWorkspaceController.ts @@ -261,6 +261,7 @@ export function useWorkspaceController() { currentUserID: displayData.current_user_id, usersById: conversation.usersById, collapsedWorkspaceGroups, + showUpgradeControls: bootstrapConfig?.show_upgrade !== false, onToggleWorkspaceGroup: shell.toggleWorkspaceGroup, onCreateRoom: () => conversation.openCreateRoomModal(), onCreateAgent: agent.openCreateAgentModal, diff --git a/web/app/src/models/agents.ts b/web/app/src/models/agents.ts index c5fede80..4c25dba9 100644 --- a/web/app/src/models/agents.ts +++ b/web/app/src/models/agents.ts @@ -137,6 +137,7 @@ export type RuntimeBootstrapConfig = { effective_manager_image?: string | null; runtime_default_images?: unknown; runtime_kind?: string | null; + show_upgrade?: boolean | null; supported_runtime_kinds?: unknown; }; diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SidebarUserButton.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SidebarUserButton.tsx index e47882bd..e3c94feb 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SidebarUserButton.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SidebarUserButton.tsx @@ -18,6 +18,7 @@ type SidebarUserButtonProps = { upgradeBusy?: boolean; upgradePhase?: UpgradePhase; upgradeError?: string; + showUpgradeControls?: boolean; onOpenUpgrade?: () => void; t: TranslateFn; }; @@ -32,32 +33,39 @@ export function SidebarUserButton({ upgradeBusy = false, upgradePhase = "idle", upgradeError = "", + showUpgradeControls = true, onOpenUpgrade, t, }: SidebarUserButtonProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); - const upgradeAttention = hasUpgradeAttention(upgradeStatus, upgradePhase, upgradeBusy); - const upgradeRunning = upgradeBusy || Boolean(upgradeStatus?.upgrading); - const upgradeIssue = upgradeError || upgradeStatus?.last_error || ""; - const latestVersion = upgradeStatus?.latest_version || t("upgradeNoLatest"); - const upgradeMenuStatus = upgradeStatusText({ - phase: upgradePhase, - running: upgradeRunning, - issue: upgradeIssue, - known: Boolean(upgradeStatus), - currentVersion: upgradeStatus?.current_version || appVersion, - manualRestartRequired: Boolean(upgradeStatus?.manual_restart_required), - updateAvailable: Boolean(upgradeStatus?.update_available), - t, - }); - const upgradeActionLabel = upgradeMenuActionText({ - phase: upgradePhase, - running: upgradeRunning, - issue: upgradeIssue, - manualRestartRequired: Boolean(upgradeStatus?.manual_restart_required), - t, - }); + const upgradeAttention = showUpgradeControls && hasUpgradeAttention(upgradeStatus, upgradePhase, upgradeBusy); + const upgradeRunning = showUpgradeControls ? upgradeBusy || Boolean(upgradeStatus?.upgrading) : false; + const upgradeIssue = showUpgradeControls ? upgradeError || upgradeStatus?.last_error || "" : ""; + const upgradeView = showUpgradeControls + ? { + actionLabel: upgradeMenuActionText({ + phase: upgradePhase, + running: upgradeRunning, + issue: upgradeIssue, + manualRestartRequired: Boolean(upgradeStatus?.manual_restart_required), + t, + }), + issue: upgradeIssue, + latestVersion: upgradeStatus?.latest_version || t("upgradeNoLatest"), + running: upgradeRunning, + status: upgradeStatusText({ + phase: upgradePhase, + running: upgradeRunning, + issue: upgradeIssue, + known: Boolean(upgradeStatus), + currentVersion: upgradeStatus?.current_version || appVersion, + manualRestartRequired: Boolean(upgradeStatus?.manual_restart_required), + updateAvailable: Boolean(upgradeStatus?.update_available), + t, + }), + } + : null; function handleOpenUpgrade() { setOpen(false); @@ -154,35 +162,39 @@ export function SidebarUserButton({
- {t("versionSettings")} + {showUpgradeControls ? t("versionSettings") : t("versionInfo")} {upgradeAttention ? : null}
{t("upgradeCurrentVersion")} {formatSidebarVersionLabel(appVersion)}
-
- {t("upgradeLatestVersion")} - {latestVersion} -
-
- {t("upgradeStatus")} - {upgradeMenuStatus} -
- {upgradeIssue ?
{upgradeIssue}
: null} - {upgradeAttention ? ( - + {upgradeView ? ( + <> +
+ {t("upgradeLatestVersion")} + {upgradeView.latestVersion} +
+
+ {t("upgradeStatus")} + {upgradeView.status} +
+ {upgradeView.issue ?
{upgradeView.issue}
: null} + {upgradeAttention ? ( + + ) : null} + ) : null}
diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 069801dc..9cea59f9 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -24,6 +24,7 @@ export function WorkspaceSidebar({ currentUserID, usersById, collapsedWorkspaceGroups, + showUpgradeControls, onToggleWorkspaceGroup, onCreateRoom, onCreateAgent, @@ -72,6 +73,7 @@ export function WorkspaceSidebar({ upgradeBusy={upgradeBusy} upgradePhase={upgradePhase} upgradeError={upgradeError} + showUpgradeControls={showUpgradeControls} onOpenUpgrade={onOpenUpgrade} t={t} /> diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/types.ts b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/types.ts index 5edf1f1c..757c459f 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/types.ts +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/types.ts @@ -25,6 +25,7 @@ export type WorkspaceSidebarProps = { currentUserID: string; currentWorkspaceLabel: string; directMessages: IMConversation[]; + showUpgradeControls: boolean; hub: WorkspaceHubController["hub"]; isSidebarCollapsed: boolean; locale: LocaleCode; diff --git a/web/app/src/shared/i18n/messages.ts b/web/app/src/shared/i18n/messages.ts index f5b7ebe1..b57d4aa0 100644 --- a/web/app/src/shared/i18n/messages.ts +++ b/web/app/src/shared/i18n/messages.ts @@ -4,6 +4,7 @@ export const messages = { localIdentityFallback: "本地用户", settings: "设置", appearanceSettings: "外观与语言", + versionInfo: "版本", versionSettings: "版本与更新", localAgentConsole: "本地 Agent 控制台", loading: "正在加载 IM 工作区...", @@ -328,6 +329,7 @@ export const messages = { localIdentityFallback: "Local user", settings: "Settings", appearanceSettings: "Appearance and language", + versionInfo: "Version", versionSettings: "Version and updates", localAgentConsole: "Local agent console", loading: "Loading IM workspace...", diff --git a/web/app/tests/components/SidebarUserButton.test.tsx b/web/app/tests/components/SidebarUserButton.test.tsx new file mode 100644 index 00000000..ee3066ce --- /dev/null +++ b/web/app/tests/components/SidebarUserButton.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SidebarUserButton } from "@/pages/WorkspacePage/components/WorkspaceSidebar/SidebarUserButton"; +import type { UpgradeStatus } from "@/models/upgradeStatus"; + +const labels: Record = { + appearanceSettings: "Appearance", + languageSwitcher: "Language", + settings: "Settings", + themeDark: "Dark", + themeLight: "Light", + themeSwitcher: "Theme", + upgradeAction: "Update & Restart", + upgradeCurrentVersion: "Current version", + upgradeLatestVersion: "Latest version", + upgradeNoLatest: "Unknown", + upgradeStatus: "Status", + upgradeUpToDate: "Up to date", + versionInfo: "Version", + versionSettings: "Version and updates", +}; + +function t(key: string): string { + return labels[key] ?? key; +} + +const updateAvailableStatus: UpgradeStatus = { + checking: false, + current_version: "v0.3.0", + last_checked_at: "", + last_error: "", + latest_version: "v0.3.1", + manual_restart_required: false, + update_available: true, + upgrading: false, +}; + +describe("SidebarUserButton", () => { + it("keeps the current version visible when upgrade controls are hidden", async () => { + const user = userEvent.setup(); + const onOpenUpgrade = vi.fn(); + + render( + {}} + onThemeChange={() => {}} + t={t} + theme="light" + upgradeStatus={updateAvailableStatus} + />, + ); + + await user.click(screen.getByRole("button", { name: "Settings" })); + + expect(screen.getByText("Current version")).toBeInTheDocument(); + expect(screen.getByText("v0.3.0")).toBeInTheDocument(); + expect(screen.getByText("Version")).toBeInTheDocument(); + expect(screen.queryByText("Version and updates")).not.toBeInTheDocument(); + expect(screen.queryByText("Latest version")).not.toBeInTheDocument(); + expect(screen.queryByText("Status")).not.toBeInTheDocument(); + expect(screen.queryByText("Update & Restart")).not.toBeInTheDocument(); + expect(onOpenUpgrade).not.toHaveBeenCalled(); + }); +});