Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,7 @@ export function useAutomationsPageSetupState({
automationYamlHooksByRepoKey,
getAutomationHooksCacheKey,
repos,
setAutomationYamlHooksByRepoKey,
settings
setAutomationYamlHooksByRepoKey
]
)
const getDraftSetupDecisionDefaultSignature = useCallback(
Expand Down
74 changes: 74 additions & 0 deletions src/renderer/src/components/settings/ZcodeCliSetupSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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
}) => (
<div data-testid="setup-terminal">
<output>{command}</output>
<p>{description}</p>
</div>
)
}))
vi.mock('./SearchableSetting', () => ({
SearchableSetting: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))

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(<ZcodeCliSetupSection />)
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(<ZcodeCliSetupSection />)
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(<ZcodeCliSetupSection />)
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')
})
})
122 changes: 122 additions & 0 deletions src/renderer/src/components/settings/ZcodeCliSetupSection.tsx
Original file line number Diff line number Diff line change
@@ -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<SetupChoice | null>(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 (
<SearchableSetting
title={title}
description={description}
keywords={['zcode', 'glm', 'oauth', 'login', 'model', 'api key']}
className="space-y-2"
>
<h4 className="text-xs font-medium">{title}</h4>
<p className="text-xs text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="xs"
disabled={choice !== null}
onClick={() => setChoice('zai')}
>
{translate('auto.components.settings.ZcodeCliSetupSection.zai', 'Z.AI browser sign-in')}
</Button>
<Button
variant="outline"
size="xs"
disabled={choice !== null}
onClick={() => setChoice('bigmodel')}
>
{translate(
'auto.components.settings.ZcodeCliSetupSection.bigmodel',
'BigModel browser sign-in'
)}
</Button>
<Button
variant="outline"
size="xs"
disabled={choice !== null}
onClick={() => setChoice('api-key')}
>
{translate('auto.components.settings.ZcodeCliSetupSection.apiKey', 'ZCode API-key setup')}
</Button>
{choice ? (
<Button variant="ghost" size="xs" onClick={() => setChoice(null)}>
{translate(
'auto.components.settings.ZcodeCliSetupSection.close',
'Close setup terminal'
)}
</Button>
) : null}
</div>
{choice ? (
<Suspense
fallback={
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ZcodeCliSetupSection.loading',
'Loading sign-in terminal…'
)}
</p>
}
>
<OnboardingInlineCommandTerminal
command={command}
title={translate(
'auto.components.settings.ZcodeCliSetupSection.terminalTitle',
'ZCode sign-in'
)}
ariaLabel={translate(
'auto.components.settings.ZcodeCliSetupSection.terminalLabel',
'ZCode sign-in terminal'
)}
description={
choice === 'api-key'
? translate(
'auto.components.settings.ZcodeCliSetupSection.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.'
)
: translate(
'auto.components.settings.ZcodeCliSetupSection.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.'
)
}
autoScrollIntoView={false}
terminalTopMarginPx={8}
worktreeId="zcode-sign-in-terminal"
/>
</Suspense>
) : null}
</SearchableSetting>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe('ZcodePlanAccountsSection', () => {
render(<ZcodePlanAccountsSection />)

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' }))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -330,6 +331,8 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {
</p>
</SearchableSetting>

{credentialEditable ? <ZcodeCliSetupSection /> : null}

{usageWindows.length > 0 ? (
<SearchableSetting
title={translate(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'

export 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
}
}

export function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): 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
}
}
Original file line number Diff line number Diff line change
@@ -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() }))

Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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> = {}): 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 = {
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading
Loading