diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index ae0939085fe3..5ada1d683d28 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -150,8 +150,7 @@ export function useAutomationsPageSetupState({ automationYamlHooksByRepoKey, getAutomationHooksCacheKey, repos, - setAutomationYamlHooksByRepoKey, - settings + setAutomationYamlHooksByRepoKey ] ) const getDraftSetupDecisionDefaultSignature = useCallback( diff --git a/src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx b/src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx new file mode 100644 index 000000000000..b8f37d868839 --- /dev/null +++ b/src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ZcodeCliSetupSection } from './ZcodeCliSetupSection' + +const mocks = vi.hoisted(() => ({ settings: { agentCmdOverrides: { zcode: '' } } })) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => selector(mocks) +})) +vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: ({ + command, + description + }: { + command: string + description: string + }) => ( +
+ {command} +

{description}

+
+ ) +})) +vi.mock('./SearchableSetting', () => ({ + SearchableSetting: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +beforeEach(() => { + mocks.settings.agentCmdOverrides.zcode = '' +}) +afterEach(() => cleanup()) + +describe('ZCode CLI-owned sign-in setup', () => { + it('starts no terminal until the user opens a supported flow', () => { + render() + expect(screen.queryByTestId('setup-terminal')).not.toBeInTheDocument() + expect(screen.getByText(/ZCode’s masked API-key setup/)).toBeInTheDocument() + expect(screen.getByText(/use \/model to choose a GLM model/)).toBeInTheDocument() + }) + + it.each([ + ['Z.AI browser sign-in', 'zcode login zai --no-browser'], + ['BigModel browser sign-in', 'zcode login bigmodel --no-browser'], + ['ZCode API-key setup', 'zcode'] + ])('delegates %s to the supported upstream CLI command', async (label, command) => { + render() + fireEvent.click(screen.getByRole('button', { name: label })) + expect(await screen.findByTestId('setup-terminal')).toHaveTextContent(command) + expect(screen.getByRole('button', { name: 'Z.AI browser sign-in' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'BigModel browser sign-in' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'ZCode API-key setup' })).toBeDisabled() + if (label === 'ZCode API-key setup') { + expect(screen.getByText(/enter \/login.*masked prompt/)).toBeInTheDocument() + } else { + expect(screen.getByText(/authorization URL printed by ZCode/)).toBeInTheDocument() + } + fireEvent.click(screen.getByRole('button', { name: 'Close setup terminal' })) + expect(screen.queryByTestId('setup-terminal')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: label })).toBeEnabled() + }) + + it('preserves the configured CLI executable instead of inventing model flags', async () => { + mocks.settings.agentCmdOverrides.zcode = 'node "C:\\ZCode CLI\\zcode.cjs"' + render() + fireEvent.click(screen.getByRole('button', { name: 'BigModel browser sign-in' })) + expect(await screen.findByTestId('setup-terminal')).toHaveTextContent( + 'node "C:\\ZCode CLI\\zcode.cjs" login bigmodel --no-browser' + ) + expect(screen.getByTestId('setup-terminal')).not.toHaveTextContent('--model') + }) +}) diff --git a/src/renderer/src/components/settings/ZcodeCliSetupSection.tsx b/src/renderer/src/components/settings/ZcodeCliSetupSection.tsx new file mode 100644 index 000000000000..922c926380db --- /dev/null +++ b/src/renderer/src/components/settings/ZcodeCliSetupSection.tsx @@ -0,0 +1,122 @@ +import { lazy, Suspense, useState } from 'react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { Button } from '../ui/button' +import { SearchableSetting } from './SearchableSetting' + +const OnboardingInlineCommandTerminal = lazy(() => + import('../onboarding/OnboardingInlineCommandTerminal').then((module) => ({ + default: module.OnboardingInlineCommandTerminal + })) +) + +type SetupChoice = 'zai' | 'bigmodel' | 'api-key' + +export function ZcodeCliSetupSection(): React.JSX.Element { + const commandOverride = useAppStore((s) => s.settings?.agentCmdOverrides?.zcode) + const [choice, setChoice] = useState(null) + const executable = commandOverride?.trim() || 'zcode' + const command = choice === 'api-key' ? executable : `${executable} login ${choice} --no-browser` + + const title = translate( + 'auto.components.settings.ZcodeCliSetupSection.title', + 'Use GLM with ZCode' + ) + const description = translate( + 'auto.components.settings.ZcodeCliSetupSection.description', + 'Sign in to use GLM as a coding agent. The quota API key above only monitors usage; ZCode owns its own sign-in and model selection.' + ) + + return ( + +

{title}

+

{description}

+

+ {translate( + 'auto.components.settings.ZcodeCliSetupSection.help', + 'Choose browser sign-in for your plan site, or use ZCode’s masked API-key setup. After signing in, start ZCode and use /model to choose a GLM model.' + )} +

+
+ + + + {choice ? ( + + ) : null} +
+ {choice ? ( + + {translate( + 'auto.components.settings.ZcodeCliSetupSection.loading', + 'Loading sign-in terminal…' + )} +

+ } + > + +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx index 32c277b72ced..89f9c1f708e5 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -145,6 +145,7 @@ describe('ZcodePlanAccountsSection', () => { render() const input = await screen.findByPlaceholderText('Paste your GLM Coding Plan API key') + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) fireEvent.change(input, { target: { value: ' glm-secret ' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx index be3d5e1010ef..7efad3c3a170 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -18,6 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { SearchableSetting } from './SearchableSetting' import { collectZcodeUsageWindows, ZcodeUsageWindowView } from './zcode-plan-usage-windows' import { useZcodePlanCredentials } from './use-zcode-plan-credentials' +import { ZcodeCliSetupSection } from './ZcodeCliSetupSection' import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' const SEARCH_KEYWORDS = [ @@ -330,6 +331,8 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {

+ {credentialEditable ? : null} + {usageWindows.length > 0 ? ( = {}): FolderWorkspace { + return { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'hi', + folderPath: '/repo/platform/hi', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts index f1fcb5e6f008..95a01c86f40b 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn() })) @@ -14,39 +13,6 @@ vi.mock('@/lib/worktree-activation', async (importOriginal) => { import { useAppStore } from '@/store' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } -} - // Why: a folder group on an Orca server runs its agents there, so the server's OS decides quoting. describe('submitFolderWorkspaceCreate on an Orca server', () => { afterEach(() => { diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 85d5f2020be0..0964e076f87a 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type * as NewWorkspaceModule from '@/lib/new-workspace' @@ -30,40 +29,6 @@ import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - ...overrides - } -} - describe('submitFolderWorkspaceCreate', () => { it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => { const linkedWorkItem = { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b9378d6efea..1cc1400102e8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12632,6 +12632,20 @@ "terminalsElsewhere_other": "Runs the relay while {{count}} terminals another Orca desktop or session opened on this host are running.", "terminalsElsewhereUncounted": "Runs the relay while terminals another Orca desktop or session opened on this host are running." }, + "ZcodeCliSetupSection": { + "title": "Use GLM with ZCode", + "description": "Sign in to use GLM as a coding agent. The quota API key above only monitors usage; ZCode owns its own sign-in and model selection.", + "help": "Choose browser sign-in for your plan site, or use ZCode’s masked API-key setup. After signing in, start ZCode and use /model to choose a GLM model.", + "zai": "Z.AI browser sign-in", + "bigmodel": "BigModel browser sign-in", + "apiKey": "ZCode API-key setup", + "close": "Close setup terminal", + "loading": "Loading sign-in terminal…", + "terminalTitle": "ZCode sign-in", + "terminalLabel": "ZCode sign-in terminal", + "apiKeyInstructions": "Press Enter to start ZCode, then enter /login and choose the API-key option for your plan site. Enter the key in ZCode’s masked prompt.", + "oauthInstructions": "Press Enter to run the sign-in command, then open the authorization URL printed by ZCode. Sign-in is saved on this terminal’s execution host." + }, "ZcodePlanAccountsSection": { "site": { "bigmodel": "Zhipu · BigModel (open.bigmodel.cn)", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a78155345c8d..ac3cb227baac 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12246,6 +12246,20 @@ "usageLabel": "用量", "usageTitle": "每月套餐用量" }, + "ZcodeCliSetupSection": { + "title": "通过 ZCode 使用 GLM", + "description": "登录后即可将 GLM 用作编程代理。上方的配额 API 密钥仅用于监控用量;ZCode 自行管理登录和模型选择。", + "help": "选择对应套餐站点的浏览器登录,或使用 ZCode 的隐藏输入 API 密钥设置。登录后,启动 ZCode 并使用 /model 选择 GLM 模型。", + "zai": "Z.AI 浏览器登录", + "bigmodel": "BigModel 浏览器登录", + "apiKey": "ZCode API 密钥设置", + "close": "关闭设置终端", + "loading": "正在加载登录终端…", + "terminalTitle": "ZCode 登录", + "terminalLabel": "ZCode 登录终端", + "apiKeyInstructions": "按 Enter 启动 ZCode,然后输入 /login 并选择套餐站点的 API 密钥选项。在 ZCode 的隐藏输入提示中输入密钥。", + "oauthInstructions": "按 Enter 运行登录命令,然后打开 ZCode 输出的授权网址。登录信息保存在此终端的执行主机上。" + }, "ZcodePlanAccountsSection": { "site": { "bigmodel": "智谱 · BigModel(open.bigmodel.cn)", diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index e1d34d722293..edda9b620893 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -340,7 +340,7 @@ export const test = base.extend({ if (!store) { return false } - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const repo = store.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false diff --git a/tests/e2e/zcode-cli-setup.spec.ts b/tests/e2e/zcode-cli-setup.spec.ts new file mode 100644 index 000000000000..7f92e3d956b1 --- /dev/null +++ b/tests/e2e/zcode-cli-setup.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { expectTerminalAccessibilityText } from './helpers/terminal-accessibility-tree' + +test('Accounts prepares each ZCode sign-in command and closes its terminal', async ({ + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-zcode' }) + state?.openSettingsPage() + }) + const section = orcaPage.locator('#accounts-zcode') + await expect(section).toBeVisible({ timeout: 30_000 }) + await section.screenshot({ path: testInfo.outputPath('01-setup-buttons.png') }) + for (const [label, command] of [ + ['Z.AI browser sign-in', 'zcode login zai --no-browser'], + ['BigModel browser sign-in', 'zcode login bigmodel --no-browser'], + ['ZCode API-key setup', 'zcode'] + ] as const) { + await section.getByRole('button', { name: label, exact: true }).click() + const close = section.getByRole('button', { name: 'Close setup terminal', exact: true }) + await expect(close).toBeVisible({ timeout: 15_000 }) + const tabId = await section + .locator('[data-terminal-tab-id]') + .first() + .getAttribute('data-terminal-tab-id') + if (!tabId) { + throw new Error('Setup terminal did not mount') + } + await expectTerminalAccessibilityText(orcaPage, tabId, command) + await expect(section.getByRole('button', { name: label, exact: true })).toBeDisabled() + await section.screenshot({ path: testInfo.outputPath(`${label}.png`) }) + await close.click() + await expect(close).toHaveCount(0) + await expect(section.locator('[data-terminal-tab-id]')).toHaveCount(0) + await expect(section.getByRole('button', { name: label, exact: true })).toBeEnabled() + } +})