mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Support GitLab MR unlinking and AI generation in ChecksPanel (#5204)
* feat: support GitLab MR unlinking and AI generation in ChecksPanel Integrate GitLab merge request actions alongside GitHub pull requests in the sidebar checks panel. This includes unlinking GitLab MRs, enabling AI-driven title and body generation for GitLab, and dynamically adapting menu labels (e.g. "More MR actions" vs "More PR actions") depending on the active provider. * fix: handle null base ref in hosted review creation and add tests Co-authored-by: Orca <help@stably.ai> * Extract sub-components and hooks from renderer components To improve component focus and maintainability, extract large inline sub-components, custom hooks, and logic helpers into dedicated files: - Extract `HeroPaired` from `MobileHero` to `MobileHeroPairedDevices`. - Move `ChromePreview` from `ThemeStep` to `theme-chrome-preview`. - Refactor `HostedReviewActions` to use `useHostedReviewActions` hook. - Move MCP config loading from `McpConfigSection` to helper file. * Refactor usage panes to extract shared formatters and tables Extract duplicated formatting utilities to a shared helper module. Move the large, inline recent sessions tables into dedicated sub-components to reduce duplication and simplify the parent pane components. * Support self-hosted GitLab instances for MR creation eligibility * Extract and check the remote host against `glab auth status` to dynamically recognize and authenticate self-hosted GitLab instances without requiring them to be in a hardcoded list. * Refactor stats usage panes by extracting reusable breakdown sections and sessions tables to eliminate duplication. * Suggest Linear prompts only if the launcher can resolve the CLI. * Extract GitLab project ref tests and update usage stats translations - Move GitLab project ref parsing tests into a dedicated test file to keep modules focused and add tests for candidate parsing. - Add missing translations for the usage sessions table and breakdown section across multiple locales. * Optimize GitLab ref parsing, deduplicate formatters, and add locales - Clean up GitLab ref parsing by extracting normalized known hosts. - Fix an escaped newline sequence in the self-hosted GitLab mock test. - Deduplicate stats helper functions into a single shared file. - Translate path status message strings across ES, JA, KO, and ZH. --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
|
||||
@@ -22,96 +21,12 @@ import {
|
||||
getGlabKnownHosts,
|
||||
getProjectRef,
|
||||
getProjectRefForRemote,
|
||||
parseGitLabProjectRef,
|
||||
parseGlabApiResponse,
|
||||
parseGlabAuthStatusHosts,
|
||||
resolveIssueSource
|
||||
} from './gl-utils'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
describe('gitlab project ref parsing', () => {
|
||||
it('parses HTTPS and SSH GitLab.com remotes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'stablyai/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves nested group paths', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'group/subgroup/project'
|
||||
})
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'g1/g2/g3/proj'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-GitLab hosts when host not in knownHosts', () => {
|
||||
expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches self-hosted hosts when included in knownHosts', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('rejects single-segment paths (host root or user-only)', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles missing .git suffix', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips trailing slashes after .git suffixes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves git protocol remote support', () => {
|
||||
expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gitlab project ref resolution', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
@@ -376,6 +291,24 @@ gitlab.example.com:
|
||||
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com')
|
||||
})
|
||||
|
||||
it('extracts hosts from bare auth-status section headers', () => {
|
||||
const out = `
|
||||
gitlab.com
|
||||
✓ Logged in to gitlab.com as user1 (/home/user/.config/glab-cli/config.yml)
|
||||
✓ Token: **************************
|
||||
gitlab.internal
|
||||
✓ Logged in as user2
|
||||
✓ Token: **************************
|
||||
Self-hosted-git
|
||||
✓ Logged in as user3
|
||||
`
|
||||
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.internal',
|
||||
'self-hosted-git'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty list for output with no hosts', () => {
|
||||
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
|
||||
})
|
||||
|
||||
+55
-64
@@ -1,9 +1,16 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
|
||||
import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types'
|
||||
import type { ClassifiedError, IssueSourcePreference } from '../../shared/types'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight'
|
||||
import {
|
||||
DEFAULT_GITLAB_HOSTS,
|
||||
normalizeGitLabHost,
|
||||
parseGitLabProjectRef,
|
||||
parseRemoteProjectRefCandidate,
|
||||
type ProjectRef
|
||||
} from './project-ref-parser'
|
||||
|
||||
// Why: legacy generic execFile wrapper — only used by callers that don't need
|
||||
// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from
|
||||
@@ -98,11 +105,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError {
|
||||
return { type: c.type, message: readMessages[c.type] }
|
||||
}
|
||||
|
||||
// ── Project ref resolution ──────────────────────────────────────────
|
||||
// Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef`
|
||||
// remains the single source of truth while main-side call sites can use
|
||||
// the short local name `ProjectRef`.
|
||||
export type ProjectRef = GitLabProjectRef
|
||||
export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef }
|
||||
export type { ProjectRef }
|
||||
|
||||
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
|
||||
const projectRefCache = new Map<string, ProjectRef | null>()
|
||||
@@ -129,61 +133,6 @@ function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts always treated as GitLab. Self-hosted instances are added at
|
||||
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
|
||||
*/
|
||||
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
|
||||
|
||||
function normalizeHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
knownHosts: readonly string[]
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeHost(host)
|
||||
if (!knownHosts.map(normalizeHost).includes(normalizedHost)) {
|
||||
return null
|
||||
}
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
// Reject paths without at least one group segment — `gitlab.com:foo`
|
||||
// alone is not a project reference.
|
||||
if (!normalizedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRef(
|
||||
remoteUrl: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(url.hostname, url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectRefForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
@@ -224,6 +173,17 @@ async function resolveProjectRefForRemote(
|
||||
rememberProjectRefCacheEntry(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
const remoteCandidate = parseRemoteProjectRefCandidate(stdout)
|
||||
if (
|
||||
remoteCandidate &&
|
||||
(await isGlabConfiguredForRemoteHost(repoPath, remoteCandidate, connectionId))
|
||||
) {
|
||||
// Why: `glab auth status` is process-global and can be stale or formatted
|
||||
// differently across versions; the origin host itself is the durable repo context.
|
||||
rememberGlabKnownHost(remoteCandidate.host)
|
||||
rememberProjectRefCacheEntry(cacheKey, remoteCandidate)
|
||||
return remoteCandidate
|
||||
}
|
||||
} catch {
|
||||
if (connectionId) {
|
||||
// Why: remote SSH failures are often transient tunnel/process errors.
|
||||
@@ -315,6 +275,36 @@ export function glabHostnameArgs(
|
||||
|
||||
let knownHostsCache: readonly string[] | null = null
|
||||
|
||||
function rememberGlabKnownHost(host: string): void {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
|
||||
return
|
||||
}
|
||||
knownHostsCache = [...knownHostsCache, normalizedHost]
|
||||
}
|
||||
|
||||
async function isGlabConfiguredForRemoteHost(
|
||||
repoPath: string,
|
||||
projectRef: Pick<ProjectRef, 'host'>,
|
||||
connectionId?: string | null
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await glabExecFileAsync(
|
||||
['auth', 'status', '--hostname', projectRef.host],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return result !== undefined
|
||||
} catch (error) {
|
||||
const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown }
|
||||
const output =
|
||||
[execLike.stdout, execLike.stderr, execLike.message]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join('\n') || String(error)
|
||||
const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost)
|
||||
return hosts.includes(normalizeGitLabHost(projectRef.host))
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal — exposed for tests only */
|
||||
export function _resetKnownHostsCache(): void {
|
||||
knownHostsCache = null
|
||||
@@ -397,9 +387,10 @@ export function parseGlabAuthStatusHosts(output: string): string[] {
|
||||
hosts.add(m[1].toLowerCase())
|
||||
}
|
||||
for (const line of output.split('\n')) {
|
||||
const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/)
|
||||
if (m) {
|
||||
hosts.add(m[1].toLowerCase())
|
||||
const bareLine = line.trim()
|
||||
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
|
||||
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
|
||||
hosts.add(hostLine.toLowerCase())
|
||||
}
|
||||
}
|
||||
return Array.from(hosts)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser'
|
||||
|
||||
describe('gitlab project ref parsing', () => {
|
||||
it('parses HTTPS and SSH GitLab.com remotes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'stablyai/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves nested group paths', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'group/subgroup/project'
|
||||
})
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'g1/g2/g3/proj'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-GitLab hosts when host not in knownHosts', () => {
|
||||
expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches self-hosted hosts when included in knownHosts', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('rejects single-segment paths (host root or user-only)', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles missing .git suffix', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips trailing slashes after .git suffixes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves git protocol remote support', () => {
|
||||
expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gitlab remote project ref candidates', () => {
|
||||
it('extracts self-hosted candidates before the host is trusted', () => {
|
||||
expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team/orca.git')).toEqual({
|
||||
host: 'gitlab.internal',
|
||||
path: 'team/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-git URLs and single-segment project paths', () => {
|
||||
expect(parseRemoteProjectRefCandidate('file:///tmp/repo')).toBeNull()
|
||||
expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team.git')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { GitLabProjectRef } from '../../shared/types'
|
||||
|
||||
export type ProjectRef = GitLabProjectRef
|
||||
|
||||
/**
|
||||
* Hosts always treated as GitLab. Self-hosted instances are added at
|
||||
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
|
||||
*/
|
||||
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
|
||||
|
||||
export function normalizeGitLabHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
// Reject paths without at least one group segment — `gitlab.com:foo`
|
||||
// alone is not a project reference.
|
||||
if (!normalizedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
knownHosts: readonly string[]
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
|
||||
if (!normalizedKnownHosts.includes(normalizedHost)) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(normalizedHost, path)
|
||||
}
|
||||
|
||||
export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRefForTrustedHost(scpLike[1], scpLike[2])
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRef(
|
||||
remoteUrl: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(url.hostname, url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
glabExecFileAsyncMock,
|
||||
ghExecFileAsyncMock,
|
||||
getAzureDevOpsRepoSlugMock,
|
||||
getBitbucketRepoSlugMock,
|
||||
getGiteaRepoSlugMock,
|
||||
getHostedReviewForBranchMock,
|
||||
getRepoSlugMock,
|
||||
getSshGitProviderMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getAzureDevOpsRepoSlugMock: vi.fn(),
|
||||
getBitbucketRepoSlugMock: vi.fn(),
|
||||
getGiteaRepoSlugMock: vi.fn(),
|
||||
getHostedReviewForBranchMock: vi.fn(),
|
||||
getRepoSlugMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
glabExecFileAsync: glabExecFileAsyncMock,
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
extractExecError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
createGitHubPullRequest: vi.fn(),
|
||||
getRepoSlug: getRepoSlugMock,
|
||||
getPRForBranch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../bitbucket/client', () => ({
|
||||
getBitbucketRepoSlug: getBitbucketRepoSlugMock,
|
||||
getBitbucketPullRequestForBranch: vi.fn(),
|
||||
getBitbucketPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../azure-devops/client', () => ({
|
||||
getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock,
|
||||
getAzureDevOpsPullRequestForBranch: vi.fn(),
|
||||
getAzureDevOpsPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../gitea/client', () => ({
|
||||
getGiteaRepoSlug: getGiteaRepoSlugMock,
|
||||
getGiteaPullRequestForBranch: vi.fn(),
|
||||
getGiteaPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('./hosted-review', () => ({
|
||||
getHostedReviewForBranch: getHostedReviewForBranchMock
|
||||
}))
|
||||
|
||||
import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils'
|
||||
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
|
||||
|
||||
function resetMocks(): void {
|
||||
for (const mock of [
|
||||
gitExecFileAsyncMock,
|
||||
glabExecFileAsyncMock,
|
||||
ghExecFileAsyncMock,
|
||||
getAzureDevOpsRepoSlugMock,
|
||||
getBitbucketRepoSlugMock,
|
||||
getGiteaRepoSlugMock,
|
||||
getHostedReviewForBranchMock,
|
||||
getRepoSlugMock,
|
||||
getSshGitProviderMock
|
||||
]) {
|
||||
mock.mockReset()
|
||||
}
|
||||
_resetKnownHostsCache()
|
||||
_resetProjectRefCache()
|
||||
}
|
||||
|
||||
function mockNonGitLabProviders(): void {
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
describe('GitLab self-hosted hosted review creation eligibility', () => {
|
||||
beforeEach(() => {
|
||||
resetMocks()
|
||||
mockNonGitLabProviders()
|
||||
getHostedReviewForBranchMock.mockResolvedValue(null)
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'git@gitlab.internal:team/orca.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('enables MR creation when glab recognizes the self-hosted origin host', async () => {
|
||||
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) {
|
||||
return {
|
||||
stdout: `gitlab.internal
|
||||
✓ Logged in as user
|
||||
`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
return {
|
||||
stdout: `gitlab.com
|
||||
✓ Logged in to gitlab.com as user
|
||||
`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/self-hosted-mr',
|
||||
base: 'main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'gitlab',
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
head: 'feature/self-hosted-mr'
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
|
||||
cwd: '/repo'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'gitlab.internal'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies known-but-unauthenticated self-hosted GitLab as auth_required', async () => {
|
||||
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) {
|
||||
const error = new Error('invalid token provided') as Error & {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
error.stdout = `gitlab.internal
|
||||
! Invalid token provided
|
||||
`
|
||||
error.stderr = ''
|
||||
throw error
|
||||
}
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
return {
|
||||
stdout: `gitlab.com
|
||||
✓ Logged in to gitlab.com as user
|
||||
gitlab.internal
|
||||
! Invalid token provided
|
||||
`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
const result = await getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/self-hosted-mr',
|
||||
base: 'main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
provider: 'gitlab',
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,15 @@
|
||||
import { ArrowLeft, ArrowRight, Copy, RefreshCw, Smartphone, Trash2 } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons'
|
||||
export { HeroIntro } from './MobileHeroIntro'
|
||||
export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type Platform = 'ios' | 'android'
|
||||
export type StepIndex = 0 | 1
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string
|
||||
name: string
|
||||
pairedAt: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
// Why: header copy needs to refer to the *user's* device by its native name.
|
||||
function getDeviceLabel(): string {
|
||||
const ua = navigator.userAgent
|
||||
@@ -28,81 +22,6 @@ function getDeviceLabel(): string {
|
||||
return 'computer'
|
||||
}
|
||||
|
||||
type HeroPairedProps = {
|
||||
devices: readonly PairedDevice[]
|
||||
onPairAnother: () => void
|
||||
onRevoke: (deviceId: string) => void
|
||||
revokingDeviceIds: readonly string[]
|
||||
}
|
||||
|
||||
export function HeroPaired({
|
||||
devices,
|
||||
onPairAnother,
|
||||
onRevoke,
|
||||
revokingDeviceIds
|
||||
}: HeroPairedProps): React.JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<div className="mp-eyebrow-row">
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mp-h1">
|
||||
{devices.length === 1
|
||||
? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
|
||||
: translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
|
||||
</h1>
|
||||
<p className="mp-lead-sm">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.266c18c105',
|
||||
'Open Orca Mobile to pick up where you left off, or pair another device.'
|
||||
)}
|
||||
</p>
|
||||
<ul className="mp-paired-list">
|
||||
{devices.map((device) => {
|
||||
const revoking = revokingDeviceIds.includes(device.deviceId)
|
||||
return (
|
||||
<li key={device.deviceId} className="mp-paired-row">
|
||||
<div className="mp-paired-icon">
|
||||
<Smartphone className="size-4" />
|
||||
</div>
|
||||
<div className="mp-paired-main">
|
||||
<div className="mp-paired-name">{device.name}</div>
|
||||
<div className="mp-paired-meta">
|
||||
{translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
|
||||
{new Date(device.pairedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mp-paired-revoke"
|
||||
onClick={() => onRevoke(device.deviceId)}
|
||||
disabled={revoking}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.34f878d04f',
|
||||
'Revoke {{value0}}',
|
||||
{ value0: device.name }
|
||||
)}
|
||||
title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="mp-flow-actions">
|
||||
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
|
||||
<Smartphone className="size-3.5" />
|
||||
{translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')}
|
||||
</button>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type HeroFlowProps = {
|
||||
stepIdx: StepIndex
|
||||
platform: Platform
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Smartphone, Trash2 } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string
|
||||
name: string
|
||||
pairedAt: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
type HeroPairedProps = {
|
||||
devices: readonly PairedDevice[]
|
||||
onPairAnother: () => void
|
||||
onRevoke: (deviceId: string) => void
|
||||
revokingDeviceIds: readonly string[]
|
||||
}
|
||||
|
||||
export function HeroPaired({
|
||||
devices,
|
||||
onPairAnother,
|
||||
onRevoke,
|
||||
revokingDeviceIds
|
||||
}: HeroPairedProps): React.JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<div className="mp-eyebrow-row">
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mp-h1">
|
||||
{devices.length === 1
|
||||
? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
|
||||
: translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
|
||||
</h1>
|
||||
<p className="mp-lead-sm">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.266c18c105',
|
||||
'Open Orca Mobile to pick up where you left off, or pair another device.'
|
||||
)}
|
||||
</p>
|
||||
<ul className="mp-paired-list">
|
||||
{devices.map((device) => {
|
||||
const revoking = revokingDeviceIds.includes(device.deviceId)
|
||||
return (
|
||||
<li key={device.deviceId} className="mp-paired-row">
|
||||
<div className="mp-paired-icon">
|
||||
<Smartphone className="size-4" />
|
||||
</div>
|
||||
<div className="mp-paired-main">
|
||||
<div className="mp-paired-name">{device.name}</div>
|
||||
<div className="mp-paired-meta">
|
||||
{translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
|
||||
{new Date(device.pairedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mp-paired-revoke"
|
||||
onClick={() => onRevoke(device.deviceId)}
|
||||
disabled={revoking}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.34f878d04f',
|
||||
'Revoke {{value0}}',
|
||||
{ value0: device.name }
|
||||
)}
|
||||
title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="mp-flow-actions">
|
||||
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
|
||||
<Smartphone className="size-3.5" />
|
||||
{translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')}
|
||||
</button>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
GlobalSettings
|
||||
} from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ChromePreview } from './theme-chrome-preview'
|
||||
|
||||
type ThemeStepProps = {
|
||||
theme: GlobalSettings['theme']
|
||||
@@ -273,78 +274,6 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
|
||||
)
|
||||
}
|
||||
|
||||
function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
|
||||
if (variant === 'system') {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}
|
||||
>
|
||||
<ChromeMock dark />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
|
||||
>
|
||||
<ChromeMock dark={false} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <ChromeMock dark={variant === 'dark'} />
|
||||
}
|
||||
|
||||
function ChromeMock({ dark }: { dark: boolean }) {
|
||||
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
|
||||
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
|
||||
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
|
||||
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
|
||||
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
|
||||
const row = dark ? 'bg-white/10' : 'bg-black/10'
|
||||
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
|
||||
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
|
||||
const accent = 'bg-violet-500/80'
|
||||
return (
|
||||
<div className={cn('flex size-full', bg)}>
|
||||
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
|
||||
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', accent)} />
|
||||
<span className={cn('h-1 flex-1 rounded-sm', row)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', rowDim)} />
|
||||
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', rowDim)} />
|
||||
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-1.5">
|
||||
<div className="flex gap-1">
|
||||
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
|
||||
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex-1 space-y-1">
|
||||
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
|
||||
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
|
||||
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
|
||||
<span className={cn('size-1 rounded-full', accent)} />
|
||||
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function humanFields(diff: Partial<GlobalSettings>): string[] {
|
||||
// Why: chip labels are a friendly summary, not a strict 1:1 of mapper keys.
|
||||
// Group related diff keys (font weight + family + size → "Font") so the row
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
export function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
|
||||
if (variant === 'system') {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}
|
||||
>
|
||||
<ChromeMock dark />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
|
||||
>
|
||||
<ChromeMock dark={false} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <ChromeMock dark={variant === 'dark'} />
|
||||
}
|
||||
|
||||
function ChromeMock({ dark }: { dark: boolean }) {
|
||||
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
|
||||
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
|
||||
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
|
||||
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
|
||||
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
|
||||
const row = dark ? 'bg-white/10' : 'bg-black/10'
|
||||
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
|
||||
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
|
||||
const accent = 'bg-violet-500/80'
|
||||
return (
|
||||
<div className={cn('flex size-full', bg)}>
|
||||
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
|
||||
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', accent)} />
|
||||
<span className={cn('h-1 flex-1 rounded-sm', row)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', rowDim)} />
|
||||
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', rowDim)} />
|
||||
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-1.5">
|
||||
<div className="flex gap-1">
|
||||
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
|
||||
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex-1 space-y-1">
|
||||
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
|
||||
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
|
||||
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
|
||||
<span className={cn('size-1 rounded-full', accent)} />
|
||||
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,15 +19,24 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
}) => <div data-disabled={disabled ? 'true' : undefined}>{children}</div>
|
||||
}))
|
||||
|
||||
function renderHeader(canUnlinkPullRequest = true): string {
|
||||
function renderHeader({
|
||||
canUnlinkPullRequest = true,
|
||||
provider = 'github'
|
||||
}: {
|
||||
canUnlinkPullRequest?: boolean
|
||||
provider?: 'github' | 'gitlab'
|
||||
} = {}): string {
|
||||
const isGitLab = provider === 'gitlab'
|
||||
return renderToStaticMarkup(
|
||||
<ChecksPanelReviewHeader
|
||||
review={{
|
||||
provider: 'github',
|
||||
number: 2964,
|
||||
title: 'fix: pr-bug-scan validated finding',
|
||||
provider,
|
||||
number: isGitLab ? 31 : 2964,
|
||||
title: isGitLab ? 'Fix GitLab MR creation' : 'fix: pr-bug-scan validated finding',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/pull/2964',
|
||||
url: isGitLab
|
||||
? 'https://gitlab.com/acme/orca/-/merge_requests/31'
|
||||
: 'https://github.com/stablyai/orca/pull/2964',
|
||||
status: 'pending',
|
||||
updatedAt: '2026-05-31T22:58:01Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
@@ -57,9 +66,19 @@ describe('ChecksPanelReviewHeader', () => {
|
||||
})
|
||||
|
||||
it('disables unlinking when the displayed PR is not manually linked', () => {
|
||||
const markup = renderHeader(false)
|
||||
const markup = renderHeader({ canUnlinkPullRequest: false })
|
||||
|
||||
expect(markup).toContain('data-disabled="true"')
|
||||
expect(markup).toContain('unlink PR')
|
||||
})
|
||||
|
||||
it('shows GitLab MR unlink actions in the menu', () => {
|
||||
const markup = renderHeader({ provider: 'gitlab' })
|
||||
|
||||
expect(markup).toContain('Open on GitLab')
|
||||
expect(markup).toContain('!31')
|
||||
expect(markup).toContain('More MR actions')
|
||||
expect(markup).toContain('unlink MR')
|
||||
expect(markup).not.toContain('Link another PR')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,9 +94,12 @@ import {
|
||||
shouldShowChecksPanelPublishBranchAction
|
||||
} from './checks-panel-empty-state'
|
||||
import {
|
||||
cancelRuntimeGeneratePullRequestFields,
|
||||
generateRuntimePullRequestFields,
|
||||
getRuntimeGitScope,
|
||||
getRuntimeGitStatus,
|
||||
getRuntimeGitUpstreamStatus
|
||||
getRuntimeGitUpstreamStatus,
|
||||
type RuntimeGeneratePullRequestFieldsOverrides
|
||||
} from '@/runtime/runtime-git-client'
|
||||
import {
|
||||
buildChecksPanelGitStatusContextKey,
|
||||
@@ -134,6 +137,21 @@ import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-
|
||||
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
|
||||
import { formatCreateError } from './create-pull-request-review-copy'
|
||||
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
|
||||
import {
|
||||
resolveChecksPanelHostedReviewBaseRef,
|
||||
shouldOpenChecksPanelCreateComposer
|
||||
} from './checks-panel-review-creation'
|
||||
import {
|
||||
createRunningPullRequestGenerationRecord,
|
||||
getPullRequestGenerationRecordKey,
|
||||
resolvePullRequestGenerationCancel,
|
||||
resolvePullRequestGenerationFailure,
|
||||
resolvePullRequestGenerationSuccess,
|
||||
shouldHydratePullRequestGenerationResult,
|
||||
type PullRequestFieldRevisions,
|
||||
type PullRequestGenerationContext,
|
||||
type PullRequestGenerationFields
|
||||
} from '@/store/slices/pull-request-generation'
|
||||
import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups'
|
||||
@@ -184,7 +202,8 @@ export function ChecksPanelReviewHeader({
|
||||
const reviewNumberLabel = review.provider === 'gitlab' ? `!${review.number}` : `#${review.number}`
|
||||
const ReviewIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
|
||||
const reviewHostLabel = review.provider === 'gitlab' ? 'GitLab' : 'GitHub'
|
||||
const showPullRequestMenu = review.provider === 'github'
|
||||
const showPullRequestMenu = review.provider === 'github' || review.provider === 'gitlab'
|
||||
const shortReviewLabel = review.provider === 'gitlab' ? 'MR' : 'PR'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -226,12 +245,14 @@ export function ChecksPanelReviewHeader({
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.653c105ecc',
|
||||
'More PR actions'
|
||||
'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1',
|
||||
'More {{value0}} actions',
|
||||
{ value0: shortReviewLabel }
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.653c105ecc',
|
||||
'More PR actions'
|
||||
'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1',
|
||||
'More {{value0}} actions',
|
||||
{ value0: shortReviewLabel }
|
||||
)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -241,12 +262,21 @@ export function ChecksPanelReviewHeader({
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem disabled={!canUnlinkPullRequest} onSelect={onUnlinkPullRequest}>
|
||||
<Unlink className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onLinkAnotherPullRequest}>
|
||||
<Link className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.ChecksPanel.07871c0589', 'Link another PR')}
|
||||
{translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.a9d7c128e4',
|
||||
'unlink {{value0}}',
|
||||
{ value0: shortReviewLabel }
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{review.provider === 'github' ? (
|
||||
<DropdownMenuItem onSelect={onLinkAnotherPullRequest}>
|
||||
<Link className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.07871c0589',
|
||||
'Link another PR'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -344,6 +374,12 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
(s) => s.getHostedReviewCreationEligibility
|
||||
)
|
||||
const createHostedReview = useAppStore((s) => s.createHostedReview)
|
||||
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
|
||||
const allocatePullRequestGenerationRequestId = useAppStore(
|
||||
(s) => s.allocatePullRequestGenerationRequestId
|
||||
)
|
||||
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
|
||||
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
|
||||
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
|
||||
const conflictOperation = useAppStore((s) =>
|
||||
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
|
||||
@@ -461,6 +497,25 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
const activeWorktreePath = activeWorktree?.path ?? null
|
||||
const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null
|
||||
const hostedReviewCreationBaseRef = resolveChecksPanelHostedReviewBaseRef({
|
||||
worktreeBaseRef: activeWorktree?.baseRef,
|
||||
repoBaseRef: repo?.worktreeBaseRef
|
||||
})
|
||||
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath,
|
||||
repoId: repo?.id,
|
||||
branch
|
||||
})
|
||||
const activePullRequestGenerationRecordCandidate = activePullRequestGenerationKey
|
||||
? (prGenerationRecords[activePullRequestGenerationKey] ?? null)
|
||||
: null
|
||||
const activePullRequestGenerationRecord =
|
||||
activePullRequestGenerationRecordCandidate &&
|
||||
activePullRequestGenerationRecordCandidate.context.repoId === repo?.id &&
|
||||
activePullRequestGenerationRecordCandidate.context.branch === branch
|
||||
? activePullRequestGenerationRecordCandidate
|
||||
: null
|
||||
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId: activeConnectionId,
|
||||
worktreePath: activeWorktreePath
|
||||
@@ -619,7 +674,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
runtimeEnvironmentId,
|
||||
connectionId: repoConnectionId,
|
||||
branch,
|
||||
base: repo.worktreeBaseRef ?? null,
|
||||
base: hostedReviewCreationBaseRef,
|
||||
hasUncommittedChanges:
|
||||
gitStatusSnapshot?.contextKey === panelContextKey
|
||||
? gitStatusSnapshot.hasUncommittedChanges
|
||||
@@ -674,6 +729,141 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const connectionId = activeConnectionId ?? undefined
|
||||
await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId)
|
||||
}, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus])
|
||||
const handleGeneratePullRequestFieldsForActive = useCallback(
|
||||
async (
|
||||
fields: PullRequestGenerationFields,
|
||||
fieldRevisions: PullRequestFieldRevisions,
|
||||
overrides?: RuntimeGeneratePullRequestFieldsOverrides
|
||||
): Promise<void> => {
|
||||
if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) {
|
||||
return
|
||||
}
|
||||
const generationKey = activePullRequestGenerationKey
|
||||
if (
|
||||
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const requestId = allocatePullRequestGenerationRequestId()
|
||||
const context: PullRequestGenerationContext = {
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath,
|
||||
connectionId: activeConnectionId ?? undefined,
|
||||
requestId,
|
||||
repoId: repo.id,
|
||||
branch
|
||||
}
|
||||
const seed = { ...fields }
|
||||
// Why: Checks stays mounted across tab switches, but the composer itself
|
||||
// can unmount when eligibility refreshes; keep generation tied to the branch.
|
||||
setPullRequestGenerationRecord(
|
||||
generationKey,
|
||||
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await generateRuntimePullRequestFields(
|
||||
{
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId: context.worktreeId,
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId
|
||||
},
|
||||
{
|
||||
base: stripBaseRef((seed.base ?? '').trim()),
|
||||
title: seed.title,
|
||||
body: seed.body,
|
||||
draft: seed.draft
|
||||
},
|
||||
overrides
|
||||
)
|
||||
if (result.branchChangedByPreparation) {
|
||||
await handleBranchChangedByPullRequestGeneration()
|
||||
}
|
||||
if (result.success) {
|
||||
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
|
||||
}
|
||||
updatePullRequestGenerationRecord(generationKey, (record) => {
|
||||
if (!result.success) {
|
||||
return resolvePullRequestGenerationFailure({
|
||||
record,
|
||||
requestId,
|
||||
canceled: result.canceled,
|
||||
error: result.canceled ? null : result.error
|
||||
})
|
||||
}
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
return resolvePullRequestGenerationSuccess({
|
||||
record,
|
||||
requestId,
|
||||
result: {
|
||||
base: stripBaseRef(result.fields.base),
|
||||
title: result.fields.title,
|
||||
body: result.fields.body,
|
||||
draft: result.fields.draft
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
updatePullRequestGenerationRecord(generationKey, (record) =>
|
||||
resolvePullRequestGenerationFailure({
|
||||
record,
|
||||
requestId,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to generate pull request details'
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeConnectionId,
|
||||
activePullRequestGenerationKey,
|
||||
activeWorktreeId,
|
||||
activeWorktreePath,
|
||||
allocatePullRequestGenerationRequestId,
|
||||
branch,
|
||||
handleBranchChangedByPullRequestGeneration,
|
||||
repo,
|
||||
setPullRequestGenerationRecord,
|
||||
updatePullRequestGenerationRecord
|
||||
]
|
||||
)
|
||||
const handleCancelGeneratePullRequestFieldsForActive = useCallback((): void => {
|
||||
if (!activePullRequestGenerationKey) {
|
||||
return
|
||||
}
|
||||
const record = prGenerationRecords[activePullRequestGenerationKey]
|
||||
if (!record || record.status !== 'running') {
|
||||
return
|
||||
}
|
||||
const generationKey = activePullRequestGenerationKey
|
||||
updatePullRequestGenerationRecord(generationKey, (current) => {
|
||||
if (!current || current.context.requestId !== record.context.requestId) {
|
||||
return null
|
||||
}
|
||||
return resolvePullRequestGenerationCancel(current)
|
||||
})
|
||||
void cancelRuntimeGeneratePullRequestFields({
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId: record.context.worktreeId,
|
||||
worktreePath: record.context.worktreePath,
|
||||
connectionId: record.context.connectionId
|
||||
}).catch((error) => {
|
||||
updatePullRequestGenerationRecord(generationKey, (current) => {
|
||||
if (!current || current.context.requestId !== record.context.requestId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Failed to stop pull request generation',
|
||||
hydrated: false
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord])
|
||||
const prCreationDefaults = useMemo(() => {
|
||||
if (!settings) {
|
||||
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
@@ -696,12 +886,12 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
})
|
||||
}, [repo, settings])
|
||||
const createComposerOpen =
|
||||
!activeReview &&
|
||||
!isFolder &&
|
||||
Boolean(branch) &&
|
||||
(hostedReviewCreation?.canCreate === true ||
|
||||
hostedReviewCreation?.blockedReason === 'needs_push')
|
||||
const createComposerOpen = shouldOpenChecksPanelCreateComposer({
|
||||
activeReview,
|
||||
isFolder,
|
||||
branch,
|
||||
hostedReviewCreation
|
||||
})
|
||||
const {
|
||||
aiGenerationEnabled: prAiGenerationEnabled,
|
||||
base: prBase,
|
||||
@@ -722,7 +912,8 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
generateDisabled: prGenerateDisabled,
|
||||
generateDisabledReason: prGenerateDisabledReason,
|
||||
handleGenerate: handleGeneratePullRequestFields,
|
||||
handleCancelGenerate: handleCancelGeneratePullRequestFields
|
||||
handleCancelGenerate: handleCancelGeneratePullRequestFields,
|
||||
applyGeneratedFields: applyGeneratedPullRequestFields
|
||||
} = useCreatePullRequestDialogFields({
|
||||
open: createComposerOpen,
|
||||
repoId: repo?.id ?? '',
|
||||
@@ -734,8 +925,57 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
settings,
|
||||
submitting: isCreatingPr,
|
||||
prCreationDefaults,
|
||||
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration
|
||||
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration,
|
||||
generation: {
|
||||
generating: activePullRequestGenerationRecord?.status === 'running',
|
||||
generateError: activePullRequestGenerationRecord?.error ?? null,
|
||||
onGenerate: (fields, fieldRevisions, overrides) => {
|
||||
void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides)
|
||||
},
|
||||
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!activePullRequestGenerationKey ||
|
||||
!activePullRequestGenerationRecord ||
|
||||
activePullRequestGenerationRecord.status !== 'succeeded' ||
|
||||
!activePullRequestGenerationRecord.result ||
|
||||
activePullRequestGenerationRecord.hydrated
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!shouldHydratePullRequestGenerationResult({
|
||||
record: activePullRequestGenerationRecord
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
applyGeneratedPullRequestFields(
|
||||
activePullRequestGenerationRecord.result,
|
||||
activePullRequestGenerationRecord.seedFieldRevisions
|
||||
)
|
||||
updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) => {
|
||||
if (
|
||||
!record ||
|
||||
record.context.requestId !== activePullRequestGenerationRecord.context.requestId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
hydrated: true
|
||||
}
|
||||
})
|
||||
}, [
|
||||
activePullRequestGenerationKey,
|
||||
activePullRequestGenerationRecord,
|
||||
applyGeneratedPullRequestFields,
|
||||
updatePullRequestGenerationRecord
|
||||
])
|
||||
|
||||
const handlePrBaseChange = useCallback(
|
||||
(value: string): void => {
|
||||
setCreatePrError(null)
|
||||
@@ -985,7 +1225,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
repoPath: repo.path,
|
||||
...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}),
|
||||
branch,
|
||||
base: repo.worktreeBaseRef ?? null,
|
||||
base: hostedReviewCreationBaseRef,
|
||||
hasUncommittedChanges,
|
||||
hasUpstream: remoteStatus?.hasUpstream,
|
||||
ahead: remoteStatus?.ahead,
|
||||
@@ -1023,6 +1263,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
getHostedReviewCreationEligibility,
|
||||
gitStatusReadyForPanelContext,
|
||||
hasUncommittedChanges,
|
||||
hostedReviewCreationBaseRef,
|
||||
hostedReviewCreationRequestKey,
|
||||
isFolder,
|
||||
isPanelVisible,
|
||||
@@ -2466,11 +2707,17 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
}, [activeReview, activeWorktreeId])
|
||||
|
||||
const handleUnlinkPullRequest = useCallback(() => {
|
||||
if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) {
|
||||
if (!activeWorktreeId || !activeReview) {
|
||||
return
|
||||
}
|
||||
void updateWorktreeMeta(activeWorktreeId, { linkedPR: null })
|
||||
}, [activeReview?.provider, activeWorktreeId, linkedPR, updateWorktreeMeta])
|
||||
if (activeReview.provider === 'github' && linkedPR !== null) {
|
||||
void updateWorktreeMeta(activeWorktreeId, { linkedPR: null })
|
||||
return
|
||||
}
|
||||
if (activeReview.provider === 'gitlab' && linkedGitLabMR !== null) {
|
||||
void updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: null })
|
||||
}
|
||||
}, [activeReview, activeWorktreeId, linkedGitLabMR, linkedPR, updateWorktreeMeta])
|
||||
|
||||
const handleLinkAnotherPullRequest = useCallback(() => {
|
||||
if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') {
|
||||
@@ -2951,7 +3198,9 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
<ChecksPanelReviewHeader
|
||||
review={activeReview}
|
||||
isRefreshing={isRefreshing}
|
||||
canUnlinkPullRequest={linkedPR !== null}
|
||||
canUnlinkPullRequest={
|
||||
activeReview.provider === 'gitlab' ? linkedGitLabMR !== null : linkedPR !== null
|
||||
}
|
||||
onRefresh={() => void handleRefresh()}
|
||||
onOpenReview={handleOpenPR}
|
||||
onUnlinkPullRequest={handleUnlinkPullRequest}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { LoaderCircle, GitMerge, ChevronDown, GitPullRequestClosed } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -12,11 +11,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
|
||||
import type { GitHubPRMergeMethod } from '../../../../shared/types'
|
||||
import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods'
|
||||
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
|
||||
import { presentGitLabMRMergeState } from './gitlab-mr-merge-state'
|
||||
@@ -25,19 +21,9 @@ import {
|
||||
HostedReviewActionError,
|
||||
MergedReviewActions
|
||||
} from './HostedReviewStateActions'
|
||||
import { useHostedReviewActions, type HostedReviewActionInfo } from './use-hosted-review-actions'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type HostedReviewActionInfo = Pick<
|
||||
HostedReviewInfo,
|
||||
'provider' | 'number' | 'state' | 'status' | 'mergeable'
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
HostedReviewInfo,
|
||||
'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus'
|
||||
>
|
||||
>
|
||||
|
||||
export default function HostedReviewActions({
|
||||
review,
|
||||
githubPR,
|
||||
@@ -54,11 +40,6 @@ export default function HostedReviewActions({
|
||||
const isDeletingWorktree = useAppStore(
|
||||
(s) => s.deleteStateByWorktreeId[worktree.id]?.isDeleting ?? false
|
||||
)
|
||||
const confirm = useConfirmationDialog()
|
||||
const [merging, setMerging] = useState(false)
|
||||
const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const isGitLab = review.provider === 'gitlab'
|
||||
const shortLabel = isGitLab ? 'MR' : 'PR'
|
||||
const reviewLabel = isGitLab ? 'merge request' : 'pull request'
|
||||
@@ -81,6 +62,25 @@ export default function HostedReviewActions({
|
||||
() => resolveGitHubPRMergeMethods(isGitLab ? null : (githubPR?.mergeMethodSettings ?? null)),
|
||||
[githubPR?.mergeMethodSettings, isGitLab]
|
||||
)
|
||||
const {
|
||||
merging,
|
||||
stateUpdating,
|
||||
actionError,
|
||||
handleMerge,
|
||||
handleAutoMerge,
|
||||
handleCloseReview,
|
||||
handleReopenReview
|
||||
} = useHostedReviewActions({
|
||||
review,
|
||||
githubPR,
|
||||
repo,
|
||||
isGitLab,
|
||||
shortLabel,
|
||||
reviewLabel,
|
||||
defaultMergeMethod: mergeMethods.defaultMethod,
|
||||
autoMergeAction: mergePresentation.autoMergeAction,
|
||||
onRefreshReview
|
||||
})
|
||||
const isUpdatingReviewState = stateUpdating !== null
|
||||
const primaryMergeDisabled =
|
||||
merging ||
|
||||
@@ -90,169 +90,6 @@ export default function HostedReviewActions({
|
||||
merging || isUpdatingReviewState || !mergePresentation.directMergeAvailable
|
||||
const menuDisabled = merging || isUpdatingReviewState
|
||||
|
||||
const handleMerge = useCallback(
|
||||
async (method: GitHubPRMergeMethod = mergeMethods.defaultMethod) => {
|
||||
setMerging(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = isGitLab
|
||||
? await window.api.gl.mergeMR({
|
||||
repoPath: repo.path,
|
||||
iid: review.number,
|
||||
method
|
||||
})
|
||||
: await window.api.gh.mergePR({
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
prNumber: review.number,
|
||||
method,
|
||||
prRepo: githubPR?.prRepo ?? null
|
||||
})
|
||||
if (!result.ok) {
|
||||
setActionError(result.error)
|
||||
} else {
|
||||
await onRefreshReview()
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : 'Merge failed')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
githubPR?.prRepo,
|
||||
isGitLab,
|
||||
mergeMethods.defaultMethod,
|
||||
onRefreshReview,
|
||||
repo.id,
|
||||
repo.path,
|
||||
review.number
|
||||
]
|
||||
)
|
||||
|
||||
const handleAutoMerge = useCallback(async () => {
|
||||
if (isGitLab || !mergePresentation.autoMergeAction) {
|
||||
return
|
||||
}
|
||||
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
|
||||
setMerging(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = await window.api.gh.setPRAutoMerge({
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
prNumber: review.number,
|
||||
enabled,
|
||||
prRepo: githubPR?.prRepo ?? null
|
||||
})
|
||||
if (!result.ok) {
|
||||
setActionError(result.error)
|
||||
} else {
|
||||
await onRefreshReview()
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : 'Auto-merge update failed')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}, [
|
||||
githubPR?.prRepo,
|
||||
isGitLab,
|
||||
mergePresentation.autoMergeAction,
|
||||
onRefreshReview,
|
||||
repo.id,
|
||||
repo.path,
|
||||
review.number
|
||||
])
|
||||
|
||||
const handleReviewStateChange = useCallback(
|
||||
async (nextState: 'open' | 'closed') => {
|
||||
if (stateUpdating) {
|
||||
return
|
||||
}
|
||||
const isClosing = nextState === 'closed'
|
||||
const label = isClosing ? 'Close' : 'Reopen'
|
||||
const confirmed = await confirm({
|
||||
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
|
||||
description: isClosing
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
|
||||
'This will close the {{value0}}.',
|
||||
{ value0: reviewLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
|
||||
'This will reopen the {{value0}}.',
|
||||
{ value0: reviewLabel }
|
||||
),
|
||||
confirmLabel: label,
|
||||
confirmVariant: isClosing ? 'destructive' : 'default'
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
setStateUpdating(nextState)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = isGitLab
|
||||
? isClosing
|
||||
? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number })
|
||||
: await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number })
|
||||
: await window.api.gh.updatePRState({
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
prNumber: review.number,
|
||||
updates: { state: nextState }
|
||||
})
|
||||
if (!result.ok) {
|
||||
setActionError(result.error)
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
toast.success(
|
||||
isClosing
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
|
||||
'{{value0}} closed',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
|
||||
'{{value0}} reopened',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
)
|
||||
await onRefreshReview()
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}`
|
||||
setActionError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setStateUpdating(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
confirm,
|
||||
isGitLab,
|
||||
onRefreshReview,
|
||||
repo.id,
|
||||
repo.path,
|
||||
review.number,
|
||||
reviewLabel,
|
||||
shortLabel,
|
||||
stateUpdating
|
||||
]
|
||||
)
|
||||
|
||||
const handleCloseReview = useCallback(async () => {
|
||||
await handleReviewStateChange('closed')
|
||||
}, [handleReviewStateChange])
|
||||
|
||||
const handleReopenReview = useCallback(async () => {
|
||||
await handleReviewStateChange('open')
|
||||
}, [handleReviewStateChange])
|
||||
|
||||
const handleDeleteWorktree = useCallback(() => {
|
||||
// Why: route every UI delete entry point through the shared funnel so
|
||||
// skip-confirm, main-worktree, and child-workspace safeguards cannot drift.
|
||||
@@ -389,7 +226,6 @@ export default function HostedReviewActions({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (review.state === 'merged') {
|
||||
return (
|
||||
<MergedReviewActions
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveChecksPanelHostedReviewBaseRef,
|
||||
shouldOpenChecksPanelCreateComposer
|
||||
} from './checks-panel-review-creation'
|
||||
|
||||
describe('resolveChecksPanelHostedReviewBaseRef', () => {
|
||||
it('prefers the worktree base ref over the repo default', () => {
|
||||
expect(
|
||||
resolveChecksPanelHostedReviewBaseRef({
|
||||
worktreeBaseRef: ' release/1.4 ',
|
||||
repoBaseRef: 'main'
|
||||
})
|
||||
).toBe('release/1.4')
|
||||
})
|
||||
|
||||
it('falls back to the repo base ref when the worktree has no override', () => {
|
||||
expect(
|
||||
resolveChecksPanelHostedReviewBaseRef({
|
||||
worktreeBaseRef: null,
|
||||
repoBaseRef: ' main '
|
||||
})
|
||||
).toBe('main')
|
||||
})
|
||||
|
||||
it('returns null when both inputs are null', () => {
|
||||
expect(
|
||||
resolveChecksPanelHostedReviewBaseRef({
|
||||
worktreeBaseRef: null,
|
||||
repoBaseRef: null
|
||||
})
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('returns null when worktree base ref is whitespace-only', () => {
|
||||
expect(
|
||||
resolveChecksPanelHostedReviewBaseRef({
|
||||
worktreeBaseRef: ' ',
|
||||
repoBaseRef: null
|
||||
})
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('strips origin prefix from the worktree base ref', () => {
|
||||
expect(
|
||||
resolveChecksPanelHostedReviewBaseRef({
|
||||
worktreeBaseRef: 'origin/main',
|
||||
repoBaseRef: 'develop'
|
||||
})
|
||||
).toBe('main')
|
||||
})
|
||||
|
||||
it('strips upstream prefix from the repo base ref', () => {
|
||||
expect(
|
||||
resolveChecksPanelHostedReviewBaseRef({
|
||||
worktreeBaseRef: null,
|
||||
repoBaseRef: 'upstream/develop'
|
||||
})
|
||||
).toBe('develop')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldOpenChecksPanelCreateComposer', () => {
|
||||
it('opens for GitLab MR creation eligibility', () => {
|
||||
expect(
|
||||
shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: null,
|
||||
isFolder: false,
|
||||
branch: 'feature/gitlab-mr',
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('opens for push-before-create recovery', () => {
|
||||
expect(
|
||||
shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: null,
|
||||
isFolder: false,
|
||||
branch: 'feature/gitlab-mr',
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push'
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not open when an active review exists', () => {
|
||||
expect(
|
||||
shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: { provider: 'github', number: 123 },
|
||||
isFolder: false,
|
||||
branch: 'feature/test',
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not open for folder repos', () => {
|
||||
expect(
|
||||
shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: null,
|
||||
isFolder: true,
|
||||
branch: 'feature/test',
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not open when branch is empty', () => {
|
||||
expect(
|
||||
shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: null,
|
||||
isFolder: false,
|
||||
branch: '',
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
|
||||
|
||||
export function resolveChecksPanelHostedReviewBaseRef(input: {
|
||||
worktreeBaseRef?: string | null
|
||||
repoBaseRef?: string | null
|
||||
}): string | null {
|
||||
const worktreeBaseRef = normalizeChecksPanelHostedReviewBaseRef(input.worktreeBaseRef)
|
||||
return worktreeBaseRef || normalizeChecksPanelHostedReviewBaseRef(input.repoBaseRef)
|
||||
}
|
||||
|
||||
function normalizeChecksPanelHostedReviewBaseRef(ref: string | null | undefined): string | null {
|
||||
const normalizedRef = ref ? normalizeHostedReviewBaseRef(ref) : ''
|
||||
return normalizedRef || null
|
||||
}
|
||||
|
||||
export function shouldOpenChecksPanelCreateComposer(input: {
|
||||
activeReview: unknown | null
|
||||
isFolder: boolean
|
||||
branch: string
|
||||
hostedReviewCreation: HostedReviewCreationEligibility | null
|
||||
}): boolean {
|
||||
return (
|
||||
!input.activeReview &&
|
||||
!input.isFolder &&
|
||||
Boolean(input.branch) &&
|
||||
(input.hostedReviewCreation?.canCreate === true ||
|
||||
input.hostedReviewCreation?.blockedReason === 'needs_push')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { PRInfo, Repo } from '../../../../shared/types'
|
||||
import type { GitHubPRMergeMethod } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type HostedReviewActionInfo = Pick<
|
||||
HostedReviewInfo,
|
||||
'provider' | 'number' | 'state' | 'status' | 'mergeable'
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
HostedReviewInfo,
|
||||
'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus'
|
||||
>
|
||||
>
|
||||
|
||||
export function useHostedReviewActions({
|
||||
review,
|
||||
githubPR,
|
||||
repo,
|
||||
isGitLab,
|
||||
shortLabel,
|
||||
reviewLabel,
|
||||
defaultMergeMethod,
|
||||
autoMergeAction,
|
||||
onRefreshReview
|
||||
}: {
|
||||
review: HostedReviewActionInfo
|
||||
githubPR?: PRInfo | null
|
||||
repo: Repo
|
||||
isGitLab: boolean
|
||||
shortLabel: string
|
||||
reviewLabel: string
|
||||
defaultMergeMethod: GitHubPRMergeMethod
|
||||
autoMergeAction: GitHubPRAutoMergeAction | null
|
||||
onRefreshReview: () => Promise<void>
|
||||
}): {
|
||||
merging: boolean
|
||||
stateUpdating: 'open' | 'closed' | null
|
||||
actionError: string | null
|
||||
handleMerge: (method?: GitHubPRMergeMethod) => Promise<void>
|
||||
handleAutoMerge: () => Promise<void>
|
||||
handleCloseReview: () => Promise<void>
|
||||
handleReopenReview: () => Promise<void>
|
||||
} {
|
||||
const confirm = useConfirmationDialog()
|
||||
const [merging, setMerging] = useState(false)
|
||||
const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const handleMerge = useCallback(
|
||||
async (method: GitHubPRMergeMethod = defaultMergeMethod) => {
|
||||
setMerging(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = isGitLab
|
||||
? await window.api.gl.mergeMR({
|
||||
repoPath: repo.path,
|
||||
iid: review.number,
|
||||
method
|
||||
})
|
||||
: await window.api.gh.mergePR({
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
prNumber: review.number,
|
||||
method,
|
||||
prRepo: githubPR?.prRepo ?? null
|
||||
})
|
||||
if (!result.ok) {
|
||||
setActionError(result.error)
|
||||
} else {
|
||||
await onRefreshReview()
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : 'Merge failed')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
githubPR?.prRepo,
|
||||
isGitLab,
|
||||
defaultMergeMethod,
|
||||
onRefreshReview,
|
||||
repo.id,
|
||||
repo.path,
|
||||
review.number
|
||||
]
|
||||
)
|
||||
|
||||
const handleAutoMerge = useCallback(async () => {
|
||||
if (isGitLab || !autoMergeAction) {
|
||||
return
|
||||
}
|
||||
const enabled = autoMergeAction.kind === 'enable'
|
||||
setMerging(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = await window.api.gh.setPRAutoMerge({
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
prNumber: review.number,
|
||||
enabled,
|
||||
prRepo: githubPR?.prRepo ?? null
|
||||
})
|
||||
if (!result.ok) {
|
||||
setActionError(result.error)
|
||||
} else {
|
||||
await onRefreshReview()
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : 'Auto-merge update failed')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}, [
|
||||
githubPR?.prRepo,
|
||||
isGitLab,
|
||||
autoMergeAction,
|
||||
onRefreshReview,
|
||||
repo.id,
|
||||
repo.path,
|
||||
review.number
|
||||
])
|
||||
|
||||
const handleReviewStateChange = useCallback(
|
||||
async (nextState: 'open' | 'closed') => {
|
||||
if (stateUpdating) {
|
||||
return
|
||||
}
|
||||
const isClosing = nextState === 'closed'
|
||||
const label = isClosing ? 'Close' : 'Reopen'
|
||||
const confirmed = await confirm({
|
||||
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
|
||||
description: isClosing
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
|
||||
'This will close the {{value0}}.',
|
||||
{ value0: reviewLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
|
||||
'This will reopen the {{value0}}.',
|
||||
{ value0: reviewLabel }
|
||||
),
|
||||
confirmLabel: label,
|
||||
confirmVariant: isClosing ? 'destructive' : 'default'
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
setStateUpdating(nextState)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = isGitLab
|
||||
? isClosing
|
||||
? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number })
|
||||
: await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number })
|
||||
: await window.api.gh.updatePRState({
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
prNumber: review.number,
|
||||
updates: { state: nextState }
|
||||
})
|
||||
if (!result.ok) {
|
||||
setActionError(result.error)
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
toast.success(
|
||||
isClosing
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
|
||||
'{{value0}} closed',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
|
||||
'{{value0}} reopened',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
)
|
||||
await onRefreshReview()
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}`
|
||||
setActionError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setStateUpdating(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
confirm,
|
||||
isGitLab,
|
||||
onRefreshReview,
|
||||
repo.id,
|
||||
repo.path,
|
||||
review.number,
|
||||
reviewLabel,
|
||||
shortLabel,
|
||||
stateUpdating
|
||||
]
|
||||
)
|
||||
|
||||
const handleCloseReview = useCallback(async () => {
|
||||
await handleReviewStateChange('closed')
|
||||
}, [handleReviewStateChange])
|
||||
|
||||
const handleReopenReview = useCallback(async () => {
|
||||
await handleReviewStateChange('open')
|
||||
}, [handleReviewStateChange])
|
||||
|
||||
return {
|
||||
merging,
|
||||
stateUpdating,
|
||||
actionError,
|
||||
handleMerge,
|
||||
handleAutoMerge,
|
||||
handleCloseReview,
|
||||
handleReopenReview
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,9 @@ import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
import {
|
||||
canInspectLocalMcpConfigRoot,
|
||||
getMcpConfigCandidateParentDir,
|
||||
getMcpConfigParentDirs,
|
||||
inspectMcpConfigContent,
|
||||
MCP_CONFIG_CANDIDATES,
|
||||
MCP_STARTER_CONFIG,
|
||||
selectExistingMcpConfigCandidates,
|
||||
type McpConfigDirectoryEntry
|
||||
MCP_STARTER_CONFIG
|
||||
} from '../../../../shared/mcp-config'
|
||||
import { useAppStore } from '../../store'
|
||||
import { joinPath } from '../../lib/path'
|
||||
@@ -21,6 +17,7 @@ import { Button } from '../ui/button'
|
||||
import { isWindowsUserAgent } from '../terminal-pane/pane-helpers'
|
||||
import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFileRow'
|
||||
import { McpMissingConfigList } from './McpMissingConfigList'
|
||||
import { loadMcpConfigInspections } from './mcp-config-inspection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type McpConfigSectionProps = {
|
||||
@@ -29,11 +26,6 @@ type McpConfigSectionProps = {
|
||||
|
||||
const EMPTY_WORKTREES: Worktree[] = []
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return /ENOENT|no such file|not found/i.test(message)
|
||||
}
|
||||
|
||||
function countServers(configs: LoadedMcpConfigInspection[]): number {
|
||||
return configs.reduce((sum, config) => sum + config.servers.length, 0)
|
||||
}
|
||||
@@ -139,81 +131,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
||||
return
|
||||
}
|
||||
|
||||
const entriesByRelativeDir = new Map<string, readonly McpConfigDirectoryEntry[]>()
|
||||
const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId })
|
||||
entriesByRelativeDir.set('', rootEntries)
|
||||
|
||||
const rootDirectoryNames = new Set(
|
||||
rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name)
|
||||
)
|
||||
const unreadableParentDirMessages = new Map<string, string>()
|
||||
await Promise.all(
|
||||
getMcpConfigParentDirs().map(async (relativeDir) => {
|
||||
if (!rootDirectoryNames.has(relativeDir)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const entries = await window.api.fs.readDir({
|
||||
dirPath: joinPath(targetRootPath, relativeDir),
|
||||
connectionId
|
||||
})
|
||||
entriesByRelativeDir.set(relativeDir, entries)
|
||||
} catch (error) {
|
||||
unreadableParentDirMessages.set(
|
||||
relativeDir,
|
||||
extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`)
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const existingRelativePaths = new Set(
|
||||
selectExistingMcpConfigCandidates(entriesByRelativeDir).map(
|
||||
(candidate) => candidate.relativePath
|
||||
)
|
||||
)
|
||||
|
||||
const next = await Promise.all(
|
||||
MCP_CONFIG_CANDIDATES.map(async (candidate): Promise<LoadedMcpConfigInspection> => {
|
||||
const absolutePath = joinPath(targetRootPath, candidate.relativePath)
|
||||
const parentDirReadError = unreadableParentDirMessages.get(
|
||||
getMcpConfigCandidateParentDir(candidate)
|
||||
)
|
||||
if (parentDirReadError) {
|
||||
return {
|
||||
...inspectMcpConfigContent(candidate, null),
|
||||
exists: false,
|
||||
status: 'invalid',
|
||||
absolutePath,
|
||||
readError: parentDirReadError
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingRelativePaths.has(candidate.relativePath)) {
|
||||
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId })
|
||||
const inspection = inspectMcpConfigContent(
|
||||
candidate,
|
||||
result.isBinary ? '' : result.content
|
||||
)
|
||||
return { ...inspection, absolutePath }
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
|
||||
}
|
||||
return {
|
||||
...inspectMcpConfigContent(candidate, null),
|
||||
exists: false,
|
||||
status: 'invalid',
|
||||
absolutePath,
|
||||
readError: extractIpcErrorMessage(error, 'Unable to read config file.')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
const next = await loadMcpConfigInspections(targetRootPath, connectionId)
|
||||
if (mountedRef.current) {
|
||||
setConfigs(next)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
getMcpConfigCandidateParentDir,
|
||||
getMcpConfigParentDirs,
|
||||
inspectMcpConfigContent,
|
||||
MCP_CONFIG_CANDIDATES,
|
||||
selectExistingMcpConfigCandidates,
|
||||
type McpConfigDirectoryEntry
|
||||
} from '../../../../shared/mcp-config'
|
||||
import { joinPath } from '../../lib/path'
|
||||
import { extractIpcErrorMessage } from '../../lib/ipc-error'
|
||||
import type { LoadedMcpConfigInspection } from './McpConfigFileRow'
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return /ENOENT|no such file|not found/i.test(message)
|
||||
}
|
||||
|
||||
export async function loadMcpConfigInspections(
|
||||
targetRootPath: string,
|
||||
connectionId: string | undefined
|
||||
): Promise<LoadedMcpConfigInspection[]> {
|
||||
const entriesByRelativeDir = new Map<string, readonly McpConfigDirectoryEntry[]>()
|
||||
const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId })
|
||||
entriesByRelativeDir.set('', rootEntries)
|
||||
|
||||
const rootDirectoryNames = new Set(
|
||||
rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name)
|
||||
)
|
||||
const unreadableParentDirMessages = new Map<string, string>()
|
||||
await Promise.all(
|
||||
getMcpConfigParentDirs().map(async (relativeDir) => {
|
||||
if (!rootDirectoryNames.has(relativeDir)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const entries = await window.api.fs.readDir({
|
||||
dirPath: joinPath(targetRootPath, relativeDir),
|
||||
connectionId
|
||||
})
|
||||
entriesByRelativeDir.set(relativeDir, entries)
|
||||
} catch (error) {
|
||||
unreadableParentDirMessages.set(
|
||||
relativeDir,
|
||||
extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`)
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const existingRelativePaths = new Set(
|
||||
selectExistingMcpConfigCandidates(entriesByRelativeDir).map(
|
||||
(candidate) => candidate.relativePath
|
||||
)
|
||||
)
|
||||
|
||||
return Promise.all(
|
||||
MCP_CONFIG_CANDIDATES.map(async (candidate): Promise<LoadedMcpConfigInspection> => {
|
||||
const absolutePath = joinPath(targetRootPath, candidate.relativePath)
|
||||
const parentDirReadError = unreadableParentDirMessages.get(
|
||||
getMcpConfigCandidateParentDir(candidate)
|
||||
)
|
||||
if (parentDirReadError) {
|
||||
return {
|
||||
...inspectMcpConfigContent(candidate, null),
|
||||
exists: false,
|
||||
status: 'invalid',
|
||||
absolutePath,
|
||||
readError: parentDirReadError
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingRelativePaths.has(candidate.relativePath)) {
|
||||
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId })
|
||||
const inspection = inspectMcpConfigContent(candidate, result.isBinary ? '' : result.content)
|
||||
return { ...inspection, absolutePath }
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
|
||||
}
|
||||
return {
|
||||
...inspectMcpConfigContent(candidate, null),
|
||||
exists: false,
|
||||
status: 'invalid',
|
||||
absolutePath,
|
||||
readError: extractIpcErrorMessage(error, 'Unable to read config file.')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -232,6 +232,7 @@ export function FolderWorkspaceComposerDialog({
|
||||
quickAgent,
|
||||
autoRenameBranchFromWork: settings?.autoRenameBranchFromWork,
|
||||
agentCmdOverrides: settings?.agentCmdOverrides,
|
||||
isRemote: selectedRepoConnectionId !== null,
|
||||
createFolderWorkspace,
|
||||
onOpenChange
|
||||
})
|
||||
@@ -246,6 +247,7 @@ export function FolderWorkspaceComposerDialog({
|
||||
onOpenChange,
|
||||
projectGroup,
|
||||
quickAgent,
|
||||
selectedRepoConnectionId,
|
||||
settings?.agentCmdOverrides,
|
||||
settings?.autoRenameBranchFromWork,
|
||||
submitting,
|
||||
|
||||
@@ -32,6 +32,7 @@ type SubmitFolderWorkspaceCreateParams = {
|
||||
quickAgent: TuiAgent | null
|
||||
autoRenameBranchFromWork: boolean | undefined
|
||||
agentCmdOverrides: Record<string, string> | undefined
|
||||
isRemote?: boolean
|
||||
createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise<FolderWorkspace | null>
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
quickAgent,
|
||||
autoRenameBranchFromWork,
|
||||
agentCmdOverrides,
|
||||
isRemote,
|
||||
createFolderWorkspace,
|
||||
onOpenChange
|
||||
}: SubmitFolderWorkspaceCreateParams): Promise<void> {
|
||||
@@ -54,8 +56,10 @@ export async function submitFolderWorkspaceCreate({
|
||||
nameIsAutoManaged && linkedName
|
||||
? linkedName
|
||||
: name.trim() || linkedName || `${projectGroup.name} workspace`
|
||||
// Why: only suggest `orca linear` when the launched terminal can actually
|
||||
// resolve the CLI; SSH launches get the relay shim, local launches may not.
|
||||
const linearCliAvailable = linkedWorkItem?.linearIdentifier
|
||||
? await isOrcaCliAvailableForLaunch({ remote: projectGroup.connectionId != null })
|
||||
? await isOrcaCliAvailableForLaunch({ remote: isRemote ?? projectGroup.connectionId != null })
|
||||
: false
|
||||
const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem, {
|
||||
cliAvailable: linearCliAvailable
|
||||
@@ -85,6 +89,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
|
||||
const startupPlan = quickAgent
|
||||
? buildAgentStartupPlan({
|
||||
agent: quickAgent,
|
||||
|
||||
@@ -5,33 +5,20 @@ import type {
|
||||
ClaudeUsageSummary
|
||||
} from '../../../../shared/claude-usage-types'
|
||||
import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart'
|
||||
import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable'
|
||||
import { UsageBreakdownSection } from './UsageBreakdownSection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type ClaudeUsageDetailsProps = {
|
||||
daily: ClaudeUsageDailyPoint[]
|
||||
formatTokens: (value: number) => string
|
||||
modelBreakdown: ClaudeUsageBreakdownRow[]
|
||||
projectBreakdown: ClaudeUsageBreakdownRow[]
|
||||
recentSessions: ClaudeUsageSessionRow[]
|
||||
summary: ClaudeUsageSummary | null | undefined
|
||||
}
|
||||
|
||||
function formatSessionTime(timestamp: string): string {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp
|
||||
}
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function ClaudeUsageDetails({
|
||||
daily,
|
||||
formatTokens,
|
||||
modelBreakdown,
|
||||
projectBreakdown,
|
||||
recentSessions,
|
||||
@@ -42,131 +29,35 @@ export function ClaudeUsageDetails({
|
||||
<ClaudeUsageDailyChart daily={daily} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<ClaudeUsageBreakdownSection
|
||||
formatTokens={formatTokens}
|
||||
label={translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')}
|
||||
rows={modelBreakdown}
|
||||
<UsageBreakdownSection
|
||||
title={translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')}
|
||||
topLabel={translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}
|
||||
topValue={summary?.topModel}
|
||||
rows={modelBreakdown.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tokens: row.inputTokens + row.outputTokens,
|
||||
sessions: row.sessions,
|
||||
eventsOrTurns: row.turns
|
||||
}))}
|
||||
eventsOrTurns="turns"
|
||||
/>
|
||||
<ClaudeUsageBreakdownSection
|
||||
formatTokens={formatTokens}
|
||||
label={translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')}
|
||||
rows={projectBreakdown}
|
||||
<UsageBreakdownSection
|
||||
title={translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')}
|
||||
topLabel={translate('auto.components.stats.ClaudeUsagePane.f97435845c', 'Top project:')}
|
||||
topValue={summary?.topProject}
|
||||
rows={projectBreakdown.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tokens: row.inputTokens + row.outputTokens,
|
||||
sessions: row.sessions,
|
||||
eventsOrTurns: row.turns
|
||||
}))}
|
||||
eventsOrTurns="turns"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '}
|
||||
{summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined
|
||||
? `${Math.round(summary.cacheReuseRate * 100)}%`
|
||||
: translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentSessions.map((row) => (
|
||||
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatSessionTime(row.lastActiveAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{row.model ??
|
||||
translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.turns}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.inputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.cacheReadTokens + row.cacheWriteTokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<ClaudeUsageRecentSessionsTable recentSessions={recentSessions} summary={summary ?? null} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ClaudeUsageBreakdownSection({
|
||||
formatTokens,
|
||||
label,
|
||||
rows,
|
||||
topLabel,
|
||||
topValue
|
||||
}: {
|
||||
formatTokens: (value: number) => string
|
||||
label: string
|
||||
rows: ClaudeUsageBreakdownRow[]
|
||||
topLabel: string
|
||||
topValue: string | null | undefined
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{label}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{topLabel}{' '}
|
||||
{topValue ?? translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{rows.slice(0, 5).map((row) => (
|
||||
<div key={row.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-foreground">{row.label}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatTokens(row.inputTokens + row.outputTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions}{' '}
|
||||
{translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '}
|
||||
{row.turns} {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -324,7 +324,6 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
|
||||
<ClaudeUsageDetails
|
||||
daily={daily}
|
||||
formatTokens={formatTokens}
|
||||
modelBreakdown={modelBreakdown}
|
||||
projectBreakdown={projectBreakdown}
|
||||
recentSessions={recentSessions}
|
||||
|
||||
@@ -5,33 +5,20 @@ import type {
|
||||
CodexUsageSummary
|
||||
} from '../../../../shared/codex-usage-types'
|
||||
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
|
||||
import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable'
|
||||
import { UsageBreakdownSection } from './UsageBreakdownSection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type CodexUsageDetailsProps = {
|
||||
daily: CodexUsageDailyPoint[]
|
||||
formatTokens: (value: number) => string
|
||||
modelBreakdown: CodexUsageBreakdownRow[]
|
||||
projectBreakdown: CodexUsageBreakdownRow[]
|
||||
recentSessions: CodexUsageSessionRow[]
|
||||
summary: CodexUsageSummary | null | undefined
|
||||
}
|
||||
|
||||
function formatSessionTime(timestamp: string): string {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp
|
||||
}
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function CodexUsageDetails({
|
||||
daily,
|
||||
formatTokens,
|
||||
modelBreakdown,
|
||||
projectBreakdown,
|
||||
recentSessions,
|
||||
@@ -42,142 +29,36 @@ export function CodexUsageDetails({
|
||||
<CodexUsageDailyChart daily={daily} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<CodexUsageBreakdownSection
|
||||
formatTokens={formatTokens}
|
||||
label={translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')}
|
||||
rows={modelBreakdown}
|
||||
showInferredPricing={true}
|
||||
<UsageBreakdownSection
|
||||
title={translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')}
|
||||
topLabel={translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}
|
||||
topValue={summary?.topModel}
|
||||
rows={modelBreakdown.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tokens: row.totalTokens,
|
||||
sessions: row.sessions,
|
||||
eventsOrTurns: row.events,
|
||||
hasInferredPricing: row.hasInferredPricing
|
||||
}))}
|
||||
eventsOrTurns="events"
|
||||
/>
|
||||
<CodexUsageBreakdownSection
|
||||
formatTokens={formatTokens}
|
||||
label={translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')}
|
||||
rows={projectBreakdown}
|
||||
showInferredPricing={false}
|
||||
<UsageBreakdownSection
|
||||
title={translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')}
|
||||
topLabel={translate('auto.components.stats.CodexUsagePane.829ee743f2', 'Top project:')}
|
||||
topValue={summary?.topProject}
|
||||
rows={projectBreakdown.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tokens: row.totalTokens,
|
||||
sessions: row.sessions,
|
||||
eventsOrTurns: row.events
|
||||
}))}
|
||||
eventsOrTurns="events"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.stats.CodexUsagePane.0bd8655475',
|
||||
'Most recent local Codex sessions in this scope.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentSessions.map((row) => (
|
||||
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatSessionTime(row.lastActiveAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{row.model ??
|
||||
translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')}
|
||||
{row.hasInferredPricing ? ' *' : ''}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.inputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.totalTokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<CodexUsageRecentSessionsTable recentSessions={recentSessions} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CodexUsageBreakdownSection({
|
||||
formatTokens,
|
||||
label,
|
||||
rows,
|
||||
showInferredPricing,
|
||||
topLabel,
|
||||
topValue
|
||||
}: {
|
||||
formatTokens: (value: number) => string
|
||||
label: string
|
||||
rows: CodexUsageBreakdownRow[]
|
||||
showInferredPricing: boolean
|
||||
topLabel: string
|
||||
topValue: string | null | undefined
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{label}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{topLabel}{' '}
|
||||
{topValue ?? translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{rows.slice(0, 5).map((row) => (
|
||||
<div key={row.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-foreground">{row.label}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatTokens(row.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions}{' '}
|
||||
{translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '}
|
||||
{row.events} {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')}
|
||||
{showInferredPricing && row.hasInferredPricing
|
||||
? ` ${translate(
|
||||
'auto.components.stats.CodexUsagePane.247c93ca92',
|
||||
'• inferred pricing'
|
||||
)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -305,7 +305,6 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
|
||||
<CodexUsageDetails
|
||||
daily={daily}
|
||||
formatTokens={formatTokens}
|
||||
modelBreakdown={modelBreakdown}
|
||||
projectBreakdown={projectBreakdown}
|
||||
recentSessions={recentSessions}
|
||||
|
||||
@@ -5,35 +5,20 @@ import type {
|
||||
OpenCodeUsageSummary
|
||||
} from '../../../../shared/opencode-usage-types'
|
||||
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
|
||||
import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable'
|
||||
import { UsageBreakdownSection } from './UsageBreakdownSection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type OpenCodeUsageDetailsProps = {
|
||||
daily: OpenCodeUsageDailyPoint[]
|
||||
formatCost: (value: number | null) => string
|
||||
formatTokens: (value: number) => string
|
||||
modelBreakdown: OpenCodeUsageBreakdownRow[]
|
||||
projectBreakdown: OpenCodeUsageBreakdownRow[]
|
||||
recentSessions: OpenCodeUsageSessionRow[]
|
||||
summary: OpenCodeUsageSummary | null | undefined
|
||||
}
|
||||
|
||||
function formatSessionTime(timestamp: string): string {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp
|
||||
}
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function OpenCodeUsageDetails({
|
||||
daily,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
modelBreakdown,
|
||||
projectBreakdown,
|
||||
recentSessions,
|
||||
@@ -44,143 +29,36 @@ export function OpenCodeUsageDetails({
|
||||
<CodexUsageDailyChart daily={daily} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<OpenCodeUsageBreakdownSection
|
||||
formatCost={formatCost}
|
||||
formatTokens={formatTokens}
|
||||
label={translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')}
|
||||
rows={modelBreakdown}
|
||||
showCost={true}
|
||||
<UsageBreakdownSection
|
||||
title={translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')}
|
||||
topLabel={translate('auto.components.stats.OpenCodeUsagePane.a15206a63a', 'Top model:')}
|
||||
topValue={summary?.topModel}
|
||||
rows={modelBreakdown.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tokens: row.totalTokens,
|
||||
sessions: row.sessions,
|
||||
eventsOrTurns: row.events,
|
||||
estimatedCostUsd: row.estimatedCostUsd
|
||||
}))}
|
||||
eventsOrTurns="events"
|
||||
/>
|
||||
<OpenCodeUsageBreakdownSection
|
||||
formatCost={formatCost}
|
||||
formatTokens={formatTokens}
|
||||
label={translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')}
|
||||
rows={projectBreakdown}
|
||||
showCost={false}
|
||||
<UsageBreakdownSection
|
||||
title={translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')}
|
||||
topLabel={translate('auto.components.stats.OpenCodeUsagePane.048ffe4d65', 'Top project:')}
|
||||
topValue={summary?.topProject}
|
||||
rows={projectBreakdown.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tokens: row.totalTokens,
|
||||
sessions: row.sessions,
|
||||
eventsOrTurns: row.events
|
||||
}))}
|
||||
eventsOrTurns="events"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.stats.OpenCodeUsagePane.81817a641a',
|
||||
'Most recent local OpenCode sessions in this scope.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.d97bdf6e27', 'Last active')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentSessions.map((row) => (
|
||||
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatSessionTime(row.lastActiveAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{row.model ??
|
||||
translate('auto.components.stats.OpenCodeUsagePane.362231082f', 'Unknown')}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.inputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(row.totalTokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<OpenCodeUsageRecentSessionsTable recentSessions={recentSessions} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenCodeUsageBreakdownSection({
|
||||
formatCost,
|
||||
formatTokens,
|
||||
label,
|
||||
rows,
|
||||
showCost,
|
||||
topLabel,
|
||||
topValue
|
||||
}: {
|
||||
formatCost: (value: number | null) => string
|
||||
formatTokens: (value: number) => string
|
||||
label: string
|
||||
rows: OpenCodeUsageBreakdownRow[]
|
||||
showCost: boolean
|
||||
topLabel: string
|
||||
topValue: string | null | undefined
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{label}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{topLabel}{' '}
|
||||
{topValue ?? translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{rows.slice(0, 5).map((row) => (
|
||||
<div key={row.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-foreground">{row.label}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatTokens(row.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions}{' '}
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.bc0cb89901', 'sessions •')}{' '}
|
||||
{row.events}{' '}
|
||||
{translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')}
|
||||
{showCost && row.estimatedCostUsd !== null
|
||||
? ` • ${formatCost(row.estimatedCostUsd)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -325,8 +325,6 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
|
||||
<OpenCodeUsageDetails
|
||||
daily={daily}
|
||||
formatCost={formatCost}
|
||||
formatTokens={formatTokens}
|
||||
modelBreakdown={modelBreakdown}
|
||||
projectBreakdown={projectBreakdown}
|
||||
recentSessions={recentSessions}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatCost, formatTokens } from './usage-formatters'
|
||||
|
||||
export type UsageBreakdownRow = {
|
||||
key: string
|
||||
label: string
|
||||
tokens: number
|
||||
sessions: number
|
||||
eventsOrTurns: number
|
||||
hasInferredPricing?: boolean
|
||||
estimatedCostUsd?: number | null
|
||||
}
|
||||
|
||||
type UsageBreakdownSectionProps = {
|
||||
title: string
|
||||
topLabel: string
|
||||
topValue: string | null | undefined
|
||||
rows: UsageBreakdownRow[]
|
||||
eventsOrTurns: 'events' | 'turns'
|
||||
}
|
||||
|
||||
export function UsageBreakdownSection({
|
||||
title,
|
||||
topLabel,
|
||||
topValue,
|
||||
rows,
|
||||
eventsOrTurns
|
||||
}: UsageBreakdownSectionProps): React.JSX.Element {
|
||||
const eventsOrTurnsKey =
|
||||
eventsOrTurns === 'turns'
|
||||
? 'auto.components.stats.UsageBreakdownSection.32176e1d44'
|
||||
: 'auto.components.stats.UsageBreakdownSection.79a69522a5'
|
||||
const eventsOrTurnsLabel = eventsOrTurns === 'turns' ? 'turns' : 'events'
|
||||
const sessionsKey = 'auto.components.stats.UsageBreakdownSection.02a046792e'
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{title}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{topLabel}{' '}
|
||||
{topValue ?? translate('auto.components.stats.UsageBreakdownSection.7765a4c3e1', 'n/a')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{rows.slice(0, 5).map((row) => (
|
||||
<div key={row.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-foreground">{row.label}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{formatTokens(row.tokens)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.sessions} {translate(sessionsKey, 'sessions •')} {row.eventsOrTurns}{' '}
|
||||
{translate(eventsOrTurnsKey, eventsOrTurnsLabel)}
|
||||
{row.hasInferredPricing
|
||||
? ` ${translate('auto.components.stats.UsageBreakdownSection.247c93ca92', '• inferred pricing')}`
|
||||
: ''}
|
||||
{row.estimatedCostUsd !== null && row.estimatedCostUsd !== undefined
|
||||
? ` • ${formatCost(row.estimatedCostUsd)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatTokens } from './usage-formatters'
|
||||
|
||||
export type UsageSessionRow = {
|
||||
sessionId: string
|
||||
lastActiveAt: string
|
||||
projectLabel: string
|
||||
model: string | null
|
||||
events?: number
|
||||
turns?: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheTokens?: number
|
||||
totalTokens?: number
|
||||
hasInferredPricing?: boolean
|
||||
}
|
||||
|
||||
function formatSessionTime(timestamp: string): string {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp
|
||||
}
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
type UsageSessionsTableProps = {
|
||||
sessions: UsageSessionRow[]
|
||||
eventsColumn?: 'events' | 'turns'
|
||||
tokensColumn?: 'cache' | 'total'
|
||||
}
|
||||
|
||||
export function UsageSessionsTable({
|
||||
sessions,
|
||||
eventsColumn = 'events',
|
||||
tokensColumn = 'total'
|
||||
}: UsageSessionsTableProps): React.JSX.Element {
|
||||
const eventsLabel =
|
||||
eventsColumn === 'turns'
|
||||
? translate('auto.components.stats.UsageSessionsTable.1afc25eb06', 'Turns')
|
||||
: translate('auto.components.stats.UsageSessionsTable.0f03975d59', 'Events')
|
||||
const tokensLabel =
|
||||
tokensColumn === 'cache'
|
||||
? translate('auto.components.stats.UsageSessionsTable.21ea00bfa8', 'Cache')
|
||||
: translate('auto.components.stats.UsageSessionsTable.e0b988599d', 'Total')
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.UsageSessionsTable.01476891c7', 'Last active')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.UsageSessionsTable.c17bed0416', 'Project')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.UsageSessionsTable.f6a2c8d019', 'Model')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">{eventsLabel}</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.UsageSessionsTable.faf3444859', 'Input')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">
|
||||
{translate('auto.components.stats.UsageSessionsTable.a8b7487ff7', 'Output')}
|
||||
</th>
|
||||
<th className="px-2 py-2 font-medium">{tokensLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((row) => (
|
||||
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatSessionTime(row.lastActiveAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{row.model ??
|
||||
translate('auto.components.stats.UsageSessionsTable.cfe2282ffa', 'Unknown')}
|
||||
{row.hasInferredPricing ? ' *' : ''}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{eventsColumn === 'turns' ? row.turns : row.events}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{formatTokens(row.inputTokens)}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">{formatTokens(row.outputTokens)}</td>
|
||||
<td className="px-2 py-2 text-muted-foreground">
|
||||
{formatTokens(
|
||||
tokensColumn === 'cache'
|
||||
? (row.cacheTokens ?? 0)
|
||||
: (row.totalTokens ?? row.inputTokens + row.outputTokens)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1029,7 +1029,15 @@
|
||||
"f660aa1454": "Connecting",
|
||||
"7711ad5122": "Local setup command",
|
||||
"e5db1b0419": "Combined setup command",
|
||||
"addProjectBeforeWorkspace": "Add a project before creating a workspace."
|
||||
"addProjectBeforeWorkspace": "Add a project before creating a workspace.",
|
||||
"sshNotConnected": "SSH not connected",
|
||||
"connectingSsh": "Connecting SSH...",
|
||||
"sshAuthenticationFailed": "SSH authentication failed",
|
||||
"preparingSshConnection": "Preparing SSH connection...",
|
||||
"connected": "Connected",
|
||||
"reconnectingSsh": "Reconnecting SSH...",
|
||||
"sshReconnectionFailed": "SSH reconnection failed",
|
||||
"notConnected": "Not connected"
|
||||
},
|
||||
"NewWorkspaceComposerModal": {
|
||||
"fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace."
|
||||
@@ -2825,7 +2833,11 @@
|
||||
"4f8368c272": "Orca worktrees only",
|
||||
"cfe2282ffa": "Unknown",
|
||||
"7765a4c3e1": "n/a",
|
||||
"2d41fd45c6": " • Last scan error: {{value0}}"
|
||||
"2d41fd45c6": " • Last scan error: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"CodexUsageDailyChart": {
|
||||
"1e6f62d7e3": "Reasoning",
|
||||
@@ -2874,7 +2886,11 @@
|
||||
"bf6cf2d4dd": "Unknown",
|
||||
"ae255c3dba": "n/a",
|
||||
"247c93ca92": "• inferred pricing",
|
||||
"8a6655f7a2": " • Last scan error: {{value0}}"
|
||||
"8a6655f7a2": " • Last scan error: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"OpenCodeUsagePane": {
|
||||
"349f7c3f5c": "Total",
|
||||
@@ -2913,7 +2929,11 @@
|
||||
"e04c58327c": "Orca worktrees only",
|
||||
"362231082f": "Unknown",
|
||||
"8095a63426": "n/a",
|
||||
"6cc7782458": " • Last scan error: {{value0}}"
|
||||
"6cc7782458": " • Last scan error: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"ShareUsageButton": {
|
||||
"7d6b25323d": "Share on X",
|
||||
@@ -3032,6 +3052,22 @@
|
||||
"d9a4b3e2f1c5": "events"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsageBreakdownSection": {
|
||||
"7765a4c3e1": "n/a",
|
||||
"247c93ca92": "• inferred pricing"
|
||||
},
|
||||
"UsageSessionsTable": {
|
||||
"1afc25eb06": "Turns",
|
||||
"0f03975d59": "Events",
|
||||
"21ea00bfa8": "Cache",
|
||||
"e0b988599d": "Total",
|
||||
"01476891c7": "Last active",
|
||||
"c17bed0416": "Project",
|
||||
"f6a2c8d019": "Model",
|
||||
"faf3444859": "Input",
|
||||
"a8b7487ff7": "Output",
|
||||
"cfe2282ffa": "Unknown"
|
||||
}
|
||||
},
|
||||
"sparse": {
|
||||
@@ -3521,7 +3557,11 @@
|
||||
"376bed88e5": "The connection to the remote host encountered an error.",
|
||||
"4afcca1d24": "Reconnect",
|
||||
"11552bf786": "SSH Disconnected",
|
||||
"cb5938ae79": "Reconnecting..."
|
||||
"cb5938ae79": "Reconnecting...",
|
||||
"disconnected": "This remote repository is not connected.",
|
||||
"reconnecting": "Reconnecting to the remote host...",
|
||||
"reconnectionFailed": "Reconnection to the remote host failed.",
|
||||
"authFailed": "Authentication to the remote host failed."
|
||||
},
|
||||
"SshTargetRow": {
|
||||
"4677394048": "Connecting…",
|
||||
@@ -7579,7 +7619,9 @@
|
||||
"f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.",
|
||||
"aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.",
|
||||
"495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.",
|
||||
"3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host."
|
||||
"3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.",
|
||||
"b4f3ec62a1": "More {{value0}} actions",
|
||||
"a9d7c128e4": "unlink {{value0}}"
|
||||
},
|
||||
"CreatePullRequestDialog": {
|
||||
"2bc1b4345e": "Cancel",
|
||||
@@ -8963,7 +9005,11 @@
|
||||
"3c4adfd821": "fix login race condition",
|
||||
"56a0271428": "Isolated workspaces",
|
||||
"ef737dcee1": "GitHub & Linear tasks",
|
||||
"ac51c061e2": "codex"
|
||||
"ac51c061e2": "codex",
|
||||
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
|
||||
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
|
||||
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
|
||||
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
|
||||
},
|
||||
"FeatureWallBody": {
|
||||
"25ec5356d6": "Setup"
|
||||
@@ -9036,7 +9082,9 @@
|
||||
"6e3f5223c5": "Explorer",
|
||||
"ab2901bce6": "Checks",
|
||||
"d7f80060ca": "Source Control",
|
||||
"8e715588e4": "Search"
|
||||
"8e715588e4": "Search",
|
||||
"a6c8b9e32f": "Checks passed",
|
||||
"f4d5e1a7b2": "3 checks"
|
||||
},
|
||||
"ReviewShipAnimatedVisual": {
|
||||
"4d99496b8c": "Create PR",
|
||||
|
||||
@@ -466,30 +466,30 @@
|
||||
},
|
||||
"folderWorkspacePathStatus": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder"
|
||||
"missing": "Carpeta no encontrada",
|
||||
"notDirectory": "La ruta no es una carpeta",
|
||||
"ambiguousConnection": "No se puede determinar la conexión",
|
||||
"unavailable": "No se puede comprobar la carpeta"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar este espacio de trabajo de carpeta.",
|
||||
"notDirectory": "{{path}} existe, pero no es una carpeta.",
|
||||
"ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.",
|
||||
"unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo."
|
||||
},
|
||||
"createError": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder",
|
||||
"generic": "Failed to create folder workspace"
|
||||
"missing": "Carpeta no encontrada",
|
||||
"notDirectory": "La ruta no es una carpeta",
|
||||
"ambiguousConnection": "No se puede determinar la conexión",
|
||||
"unavailable": "No se puede comprobar la carpeta",
|
||||
"generic": "No se pudo crear el espacio de trabajo de carpeta"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import the folder.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar la carpeta.",
|
||||
"notDirectory": "{{path}} existe, pero no es una carpeta.",
|
||||
"ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.",
|
||||
"unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1029,7 +1029,15 @@
|
||||
"f660aa1454": "Conectando",
|
||||
"7711ad5122": "Comando de configuración local",
|
||||
"e5db1b0419": "Comando de configuración combinado",
|
||||
"addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo."
|
||||
"addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo.",
|
||||
"sshNotConnected": "SSH not connected",
|
||||
"connectingSsh": "Connecting SSH...",
|
||||
"sshAuthenticationFailed": "SSH authentication failed",
|
||||
"preparingSshConnection": "Preparing SSH connection...",
|
||||
"connected": "Connected",
|
||||
"reconnectingSsh": "Reconnecting SSH...",
|
||||
"sshReconnectionFailed": "SSH reconnection failed",
|
||||
"notConnected": "Not connected"
|
||||
},
|
||||
"NewWorkspaceComposerModal": {
|
||||
"fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo."
|
||||
@@ -2825,7 +2833,11 @@
|
||||
"4f8368c272": "Solo árboles de trabajo Orca",
|
||||
"cfe2282ffa": "Desconocido",
|
||||
"7765a4c3e1": "n / A",
|
||||
"2d41fd45c6": "• Error del último análisis: {{value0}}"
|
||||
"2d41fd45c6": "• Error del último análisis: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"CodexUsageDailyChart": {
|
||||
"1e6f62d7e3": "Razonamiento",
|
||||
@@ -2874,7 +2886,11 @@
|
||||
"bf6cf2d4dd": "Desconocido",
|
||||
"ae255c3dba": "n / A",
|
||||
"247c93ca92": "• precios inferidos",
|
||||
"8a6655f7a2": "• Error del último análisis: {{value0}}"
|
||||
"8a6655f7a2": "• Error del último análisis: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"OpenCodeUsagePane": {
|
||||
"349f7c3f5c": "Total",
|
||||
@@ -2913,7 +2929,11 @@
|
||||
"e04c58327c": "Solo árboles de trabajo Orca",
|
||||
"362231082f": "Desconocido",
|
||||
"8095a63426": "n / A",
|
||||
"6cc7782458": "• Error del último análisis: {{value0}}"
|
||||
"6cc7782458": "• Error del último análisis: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"ShareUsageButton": {
|
||||
"7d6b25323d": "Compartir en X",
|
||||
@@ -3032,6 +3052,22 @@
|
||||
"d9a4b3e2f1c5": "eventos"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsageBreakdownSection": {
|
||||
"7765a4c3e1": "n/a",
|
||||
"247c93ca92": "• inferred pricing"
|
||||
},
|
||||
"UsageSessionsTable": {
|
||||
"1afc25eb06": "Turns",
|
||||
"0f03975d59": "Events",
|
||||
"21ea00bfa8": "Cache",
|
||||
"e0b988599d": "Total",
|
||||
"01476891c7": "Last active",
|
||||
"c17bed0416": "Project",
|
||||
"f6a2c8d019": "Model",
|
||||
"faf3444859": "Input",
|
||||
"a8b7487ff7": "Output",
|
||||
"cfe2282ffa": "Unknown"
|
||||
}
|
||||
},
|
||||
"sparse": {
|
||||
@@ -3521,7 +3557,11 @@
|
||||
"376bed88e5": "La conexión al host remoto encontró un error.",
|
||||
"4afcca1d24": "Reconectar",
|
||||
"11552bf786": "SSH desconectado",
|
||||
"cb5938ae79": "Reconectando..."
|
||||
"cb5938ae79": "Reconectando...",
|
||||
"disconnected": "This remote repository is not connected.",
|
||||
"reconnecting": "Reconnecting to the remote host...",
|
||||
"reconnectionFailed": "Reconnection to the remote host failed.",
|
||||
"authFailed": "Authentication to the remote host failed."
|
||||
},
|
||||
"SshTargetRow": {
|
||||
"4677394048": "Conectando…",
|
||||
@@ -7579,7 +7619,9 @@
|
||||
"f273f2271c": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.",
|
||||
"aa95b81a3a": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.",
|
||||
"495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.",
|
||||
"3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host."
|
||||
"3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host.",
|
||||
"b4f3ec62a1": "More {{value0}} actions",
|
||||
"a9d7c128e4": "unlink {{value0}}"
|
||||
},
|
||||
"CreatePullRequestDialog": {
|
||||
"2bc1b4345e": "Cancelar",
|
||||
@@ -8963,7 +9005,11 @@
|
||||
"3c4adfd821": "arreglar la condición de carrera de inicio de sesión",
|
||||
"56a0271428": "Espacios de trabajo aislados",
|
||||
"ef737dcee1": "GitHub y tareas Lineares",
|
||||
"ac51c061e2": "codex"
|
||||
"ac51c061e2": "codex",
|
||||
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
|
||||
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
|
||||
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
|
||||
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
|
||||
},
|
||||
"FeatureWallBody": {
|
||||
"25ec5356d6": "Configuración"
|
||||
@@ -9036,7 +9082,9 @@
|
||||
"6e3f5223c5": "Explorador",
|
||||
"ab2901bce6": "cheques",
|
||||
"d7f80060ca": "Control de fuente",
|
||||
"8e715588e4": "Buscar"
|
||||
"8e715588e4": "Buscar",
|
||||
"a6c8b9e32f": "Checks passed",
|
||||
"f4d5e1a7b2": "3 checks"
|
||||
},
|
||||
"ReviewShipAnimatedVisual": {
|
||||
"4d99496b8c": "Crear relaciones públicas",
|
||||
|
||||
@@ -466,30 +466,30 @@
|
||||
},
|
||||
"folderWorkspacePathStatus": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder"
|
||||
"missing": "フォルダーが見つかりません",
|
||||
"notDirectory": "パスはフォルダーではありません",
|
||||
"ambiguousConnection": "接続を特定できません",
|
||||
"unavailable": "フォルダーを確認できません"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca は {{path}} を見つけられません。このフォルダーワークスペースを削除して再インポートしてください。",
|
||||
"notDirectory": "{{path}} は存在しますが、フォルダーではありません。",
|
||||
"ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。",
|
||||
"unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。"
|
||||
},
|
||||
"createError": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder",
|
||||
"generic": "Failed to create folder workspace"
|
||||
"missing": "フォルダーが見つかりません",
|
||||
"notDirectory": "パスはフォルダーではありません",
|
||||
"ambiguousConnection": "接続を特定できません",
|
||||
"unavailable": "フォルダーを確認できません",
|
||||
"generic": "フォルダーワークスペースの作成に失敗しました"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import the folder.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca は {{path}} を見つけられません。フォルダーを削除して再インポートしてください。",
|
||||
"notDirectory": "{{path}} は存在しますが、フォルダーではありません。",
|
||||
"ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。",
|
||||
"unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1029,7 +1029,15 @@
|
||||
"f660aa1454": "接続中",
|
||||
"7711ad5122": "ローカルセットアップコマンド",
|
||||
"e5db1b0419": "組み合わせセットアップコマンド",
|
||||
"addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。"
|
||||
"addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。",
|
||||
"sshNotConnected": "SSH not connected",
|
||||
"connectingSsh": "Connecting SSH...",
|
||||
"sshAuthenticationFailed": "SSH authentication failed",
|
||||
"preparingSshConnection": "Preparing SSH connection...",
|
||||
"connected": "Connected",
|
||||
"reconnectingSsh": "Reconnecting SSH...",
|
||||
"sshReconnectionFailed": "SSH reconnection failed",
|
||||
"notConnected": "Not connected"
|
||||
},
|
||||
"NewWorkspaceComposerModal": {
|
||||
"fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。"
|
||||
@@ -2825,7 +2833,11 @@
|
||||
"4f8368c272": "Orca ワークツリーのみ",
|
||||
"cfe2282ffa": "未知",
|
||||
"7765a4c3e1": "該当なし",
|
||||
"2d41fd45c6": "• 最終スキャン エラー: {{value0}}"
|
||||
"2d41fd45c6": "• 最終スキャン エラー: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"CodexUsageDailyChart": {
|
||||
"1e6f62d7e3": "推論",
|
||||
@@ -2874,7 +2886,11 @@
|
||||
"bf6cf2d4dd": "未知",
|
||||
"ae255c3dba": "該当なし",
|
||||
"247c93ca92": "• 推定価格",
|
||||
"8a6655f7a2": "• 最終スキャン エラー: {{value0}}"
|
||||
"8a6655f7a2": "• 最終スキャン エラー: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"OpenCodeUsagePane": {
|
||||
"349f7c3f5c": "合計",
|
||||
@@ -2913,7 +2929,11 @@
|
||||
"e04c58327c": "Orca ワークツリーのみ",
|
||||
"362231082f": "未知",
|
||||
"8095a63426": "該当なし",
|
||||
"6cc7782458": "• 最終スキャン エラー: {{value0}}"
|
||||
"6cc7782458": "• 最終スキャン エラー: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"ShareUsageButton": {
|
||||
"7d6b25323d": "Xで共有する",
|
||||
@@ -3032,6 +3052,22 @@
|
||||
"d9a4b3e2f1c5": "イベント"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsageBreakdownSection": {
|
||||
"7765a4c3e1": "n/a",
|
||||
"247c93ca92": "• inferred pricing"
|
||||
},
|
||||
"UsageSessionsTable": {
|
||||
"1afc25eb06": "Turns",
|
||||
"0f03975d59": "Events",
|
||||
"21ea00bfa8": "Cache",
|
||||
"e0b988599d": "Total",
|
||||
"01476891c7": "Last active",
|
||||
"c17bed0416": "Project",
|
||||
"f6a2c8d019": "Model",
|
||||
"faf3444859": "Input",
|
||||
"a8b7487ff7": "Output",
|
||||
"cfe2282ffa": "Unknown"
|
||||
}
|
||||
},
|
||||
"sparse": {
|
||||
@@ -3502,7 +3538,11 @@
|
||||
"376bed88e5": "リモート ホストへの接続でエラーが発生しました。",
|
||||
"4afcca1d24": "再接続",
|
||||
"11552bf786": "SSHが切断されました",
|
||||
"cb5938ae79": "再接続中..."
|
||||
"cb5938ae79": "再接続中...",
|
||||
"disconnected": "This remote repository is not connected.",
|
||||
"reconnecting": "Reconnecting to the remote host...",
|
||||
"reconnectionFailed": "Reconnection to the remote host failed.",
|
||||
"authFailed": "Authentication to the remote host failed."
|
||||
},
|
||||
"SshTargetRow": {
|
||||
"4677394048": "接続中…",
|
||||
@@ -7579,7 +7619,9 @@
|
||||
"f273f2271c": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。",
|
||||
"aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。",
|
||||
"495b2f8c4b": "agent を開始しましたが、選択したコメントを解決済みにできませんでした。",
|
||||
"3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。"
|
||||
"3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。",
|
||||
"b4f3ec62a1": "More {{value0}} actions",
|
||||
"a9d7c128e4": "unlink {{value0}}"
|
||||
},
|
||||
"CreatePullRequestDialog": {
|
||||
"2bc1b4345e": "キャンセル",
|
||||
@@ -8963,7 +9005,11 @@
|
||||
"3c4adfd821": "ログイン競合状態を修正",
|
||||
"56a0271428": "隔離されたワークスペース",
|
||||
"ef737dcee1": "GitHub とLinearタスク",
|
||||
"ac51c061e2": "codex"
|
||||
"ac51c061e2": "codex",
|
||||
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
|
||||
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
|
||||
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
|
||||
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
|
||||
},
|
||||
"FeatureWallBody": {
|
||||
"25ec5356d6": "設定"
|
||||
@@ -9036,7 +9082,9 @@
|
||||
"6e3f5223c5": "エクスプローラ",
|
||||
"ab2901bce6": "チェック",
|
||||
"d7f80060ca": "ソース管理",
|
||||
"8e715588e4": "検索"
|
||||
"8e715588e4": "検索",
|
||||
"a6c8b9e32f": "Checks passed",
|
||||
"f4d5e1a7b2": "3 checks"
|
||||
},
|
||||
"ReviewShipAnimatedVisual": {
|
||||
"4d99496b8c": "PRを作成する",
|
||||
|
||||
@@ -466,30 +466,30 @@
|
||||
},
|
||||
"folderWorkspacePathStatus": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder"
|
||||
"missing": "폴더를 찾을 수 없습니다",
|
||||
"notDirectory": "경로가 폴더가 아닙니다",
|
||||
"ambiguousConnection": "연결을 확인할 수 없습니다",
|
||||
"unavailable": "폴더를 확인할 수 없습니다"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 이 폴더 워크스페이스를 제거하고 다시 가져오세요.",
|
||||
"notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.",
|
||||
"ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.",
|
||||
"unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요."
|
||||
},
|
||||
"createError": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder",
|
||||
"generic": "Failed to create folder workspace"
|
||||
"missing": "폴더를 찾을 수 없습니다",
|
||||
"notDirectory": "경로가 폴더가 아닙니다",
|
||||
"ambiguousConnection": "연결을 확인할 수 없습니다",
|
||||
"unavailable": "폴더를 확인할 수 없습니다",
|
||||
"generic": "폴더 워크스페이스를 만들지 못했습니다"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import the folder.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 폴더를 제거하고 다시 가져오세요.",
|
||||
"notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.",
|
||||
"ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.",
|
||||
"unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1029,7 +1029,15 @@
|
||||
"f660aa1454": "연결 중",
|
||||
"7711ad5122": "로컬 설정 명령",
|
||||
"e5db1b0419": "결합된 설정 명령",
|
||||
"addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요."
|
||||
"addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요.",
|
||||
"sshNotConnected": "SSH not connected",
|
||||
"connectingSsh": "Connecting SSH...",
|
||||
"sshAuthenticationFailed": "SSH authentication failed",
|
||||
"preparingSshConnection": "Preparing SSH connection...",
|
||||
"connected": "Connected",
|
||||
"reconnectingSsh": "Reconnecting SSH...",
|
||||
"sshReconnectionFailed": "SSH reconnection failed",
|
||||
"notConnected": "Not connected"
|
||||
},
|
||||
"NewWorkspaceComposerModal": {
|
||||
"fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요."
|
||||
@@ -2825,7 +2833,11 @@
|
||||
"4f8368c272": "Orca 작업 트리만 해당",
|
||||
"cfe2282ffa": "알려지지 않은",
|
||||
"7765a4c3e1": "해당 없음",
|
||||
"2d41fd45c6": "• 마지막 스캔 오류: {{value0}}"
|
||||
"2d41fd45c6": "• 마지막 스캔 오류: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"CodexUsageDailyChart": {
|
||||
"1e6f62d7e3": "추리",
|
||||
@@ -2874,7 +2886,11 @@
|
||||
"bf6cf2d4dd": "알려지지 않은",
|
||||
"ae255c3dba": "해당 없음",
|
||||
"247c93ca92": "• 추론된 가격",
|
||||
"8a6655f7a2": "• 마지막 스캔 오류: {{value0}}"
|
||||
"8a6655f7a2": "• 마지막 스캔 오류: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"OpenCodeUsagePane": {
|
||||
"349f7c3f5c": "총",
|
||||
@@ -2913,7 +2929,11 @@
|
||||
"e04c58327c": "Orca 작업 트리만 해당",
|
||||
"362231082f": "알려지지 않은",
|
||||
"8095a63426": "해당 없음",
|
||||
"6cc7782458": "• 마지막 스캔 오류: {{value0}}"
|
||||
"6cc7782458": "• 마지막 스캔 오류: {{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"ShareUsageButton": {
|
||||
"7d6b25323d": "X에 공유",
|
||||
@@ -3032,6 +3052,22 @@
|
||||
"d9a4b3e2f1c5": "이벤트"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsageBreakdownSection": {
|
||||
"7765a4c3e1": "n/a",
|
||||
"247c93ca92": "• inferred pricing"
|
||||
},
|
||||
"UsageSessionsTable": {
|
||||
"1afc25eb06": "Turns",
|
||||
"0f03975d59": "Events",
|
||||
"21ea00bfa8": "Cache",
|
||||
"e0b988599d": "Total",
|
||||
"01476891c7": "Last active",
|
||||
"c17bed0416": "Project",
|
||||
"f6a2c8d019": "Model",
|
||||
"faf3444859": "Input",
|
||||
"a8b7487ff7": "Output",
|
||||
"cfe2282ffa": "Unknown"
|
||||
}
|
||||
},
|
||||
"sparse": {
|
||||
@@ -3502,7 +3538,11 @@
|
||||
"376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.",
|
||||
"4afcca1d24": "다시 연결",
|
||||
"11552bf786": "SSH 연결 끊김",
|
||||
"cb5938ae79": "다시 연결하는 중..."
|
||||
"cb5938ae79": "다시 연결하는 중...",
|
||||
"disconnected": "This remote repository is not connected.",
|
||||
"reconnecting": "Reconnecting to the remote host...",
|
||||
"reconnectionFailed": "Reconnection to the remote host failed.",
|
||||
"authFailed": "Authentication to the remote host failed."
|
||||
},
|
||||
"SshTargetRow": {
|
||||
"4677394048": "연결 중…",
|
||||
@@ -7579,7 +7619,9 @@
|
||||
"f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.",
|
||||
"aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.",
|
||||
"495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결됨으로 표시할 수 없습니다.",
|
||||
"3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다."
|
||||
"3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다.",
|
||||
"b4f3ec62a1": "More {{value0}} actions",
|
||||
"a9d7c128e4": "unlink {{value0}}"
|
||||
},
|
||||
"CreatePullRequestDialog": {
|
||||
"2bc1b4345e": "취소",
|
||||
@@ -8963,7 +9005,11 @@
|
||||
"3c4adfd821": "로그인 경쟁 조건 수정",
|
||||
"56a0271428": "격리된 워크스페이스",
|
||||
"ef737dcee1": "GitHub 및 Linear 작업",
|
||||
"ac51c061e2": "codex"
|
||||
"ac51c061e2": "codex",
|
||||
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
|
||||
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
|
||||
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
|
||||
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
|
||||
},
|
||||
"FeatureWallBody": {
|
||||
"25ec5356d6": "설정"
|
||||
@@ -9036,7 +9082,9 @@
|
||||
"6e3f5223c5": "탐침",
|
||||
"ab2901bce6": "검사",
|
||||
"d7f80060ca": "소스 제어",
|
||||
"8e715588e4": "검색"
|
||||
"8e715588e4": "검색",
|
||||
"a6c8b9e32f": "Checks passed",
|
||||
"f4d5e1a7b2": "3 checks"
|
||||
},
|
||||
"ReviewShipAnimatedVisual": {
|
||||
"4d99496b8c": "PR 작성",
|
||||
|
||||
@@ -466,30 +466,30 @@
|
||||
},
|
||||
"folderWorkspacePathStatus": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder"
|
||||
"missing": "未找到文件夹",
|
||||
"notDirectory": "路径不是文件夹",
|
||||
"ambiguousConnection": "无法确定连接",
|
||||
"unavailable": "无法检查文件夹"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca 找不到 {{path}}。请移除并重新导入此文件夹工作区。",
|
||||
"notDirectory": "{{path}} 存在,但它不是文件夹。",
|
||||
"ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。",
|
||||
"unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。"
|
||||
},
|
||||
"createError": {
|
||||
"title": {
|
||||
"missing": "Folder not found",
|
||||
"notDirectory": "Path is not a folder",
|
||||
"ambiguousConnection": "Cannot determine connection",
|
||||
"unavailable": "Cannot check folder",
|
||||
"generic": "Failed to create folder workspace"
|
||||
"missing": "未找到文件夹",
|
||||
"notDirectory": "路径不是文件夹",
|
||||
"ambiguousConnection": "无法确定连接",
|
||||
"unavailable": "无法检查文件夹",
|
||||
"generic": "创建文件夹工作区失败"
|
||||
},
|
||||
"description": {
|
||||
"missing": "Orca cannot find {{path}}. Remove and re-import the folder.",
|
||||
"notDirectory": "{{path}} exists, but it is not a folder.",
|
||||
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
|
||||
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
|
||||
"missing": "Orca 找不到 {{path}}。请移除并重新导入该文件夹。",
|
||||
"notDirectory": "{{path}} 存在,但它不是文件夹。",
|
||||
"ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。",
|
||||
"unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1029,7 +1029,15 @@
|
||||
"f660aa1454": "连接中",
|
||||
"7711ad5122": "本地设置命令",
|
||||
"e5db1b0419": "组合设置命令",
|
||||
"addProjectBeforeWorkspace": "在创建工作区之前添加项目。"
|
||||
"addProjectBeforeWorkspace": "在创建工作区之前添加项目。",
|
||||
"sshNotConnected": "SSH not connected",
|
||||
"connectingSsh": "Connecting SSH...",
|
||||
"sshAuthenticationFailed": "SSH authentication failed",
|
||||
"preparingSshConnection": "Preparing SSH connection...",
|
||||
"connected": "Connected",
|
||||
"reconnectingSsh": "Reconnecting SSH...",
|
||||
"sshReconnectionFailed": "SSH reconnection failed",
|
||||
"notConnected": "Not connected"
|
||||
},
|
||||
"NewWorkspaceComposerModal": {
|
||||
"fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。"
|
||||
@@ -2825,7 +2833,11 @@
|
||||
"4f8368c272": "仅 Orca 工作树",
|
||||
"cfe2282ffa": "未知",
|
||||
"7765a4c3e1": "不适用",
|
||||
"2d41fd45c6": "• 上次扫描错误:{{value0}}"
|
||||
"2d41fd45c6": "• 上次扫描错误:{{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"CodexUsageDailyChart": {
|
||||
"1e6f62d7e3": "推理",
|
||||
@@ -2874,7 +2886,11 @@
|
||||
"bf6cf2d4dd": "未知",
|
||||
"ae255c3dba": "不适用",
|
||||
"247c93ca92": "• 推断定价",
|
||||
"8a6655f7a2": "• 上次扫描错误:{{value0}}"
|
||||
"8a6655f7a2": "• 上次扫描错误:{{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"OpenCodeUsagePane": {
|
||||
"349f7c3f5c": "全部的",
|
||||
@@ -2913,7 +2929,11 @@
|
||||
"e04c58327c": "仅 Orca 工作树",
|
||||
"362231082f": "未知",
|
||||
"8095a63426": "不适用",
|
||||
"6cc7782458": "• 上次扫描错误:{{value0}}"
|
||||
"6cc7782458": "• 上次扫描错误:{{value0}}",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"rangeLast90Days": "Last 90 days",
|
||||
"rangeAllTime": "All time"
|
||||
},
|
||||
"ShareUsageButton": {
|
||||
"7d6b25323d": "分享到 X",
|
||||
@@ -3032,6 +3052,22 @@
|
||||
"d9a4b3e2f1c5": "事件"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsageBreakdownSection": {
|
||||
"7765a4c3e1": "n/a",
|
||||
"247c93ca92": "• inferred pricing"
|
||||
},
|
||||
"UsageSessionsTable": {
|
||||
"1afc25eb06": "Turns",
|
||||
"0f03975d59": "Events",
|
||||
"21ea00bfa8": "Cache",
|
||||
"e0b988599d": "Total",
|
||||
"01476891c7": "Last active",
|
||||
"c17bed0416": "Project",
|
||||
"f6a2c8d019": "Model",
|
||||
"faf3444859": "Input",
|
||||
"a8b7487ff7": "Output",
|
||||
"cfe2282ffa": "Unknown"
|
||||
}
|
||||
},
|
||||
"sparse": {
|
||||
@@ -3502,7 +3538,11 @@
|
||||
"376bed88e5": "与远程主机的连接遇到错误。",
|
||||
"4afcca1d24": "重新连接",
|
||||
"11552bf786": "SSH 已断开",
|
||||
"cb5938ae79": "正在重新连接..."
|
||||
"cb5938ae79": "正在重新连接...",
|
||||
"disconnected": "This remote repository is not connected.",
|
||||
"reconnecting": "Reconnecting to the remote host...",
|
||||
"reconnectionFailed": "Reconnection to the remote host failed.",
|
||||
"authFailed": "Authentication to the remote host failed."
|
||||
},
|
||||
"SshTargetRow": {
|
||||
"4677394048": "正在连接…",
|
||||
@@ -7579,7 +7619,9 @@
|
||||
"f273f2271c": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。",
|
||||
"aa95b81a3a": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。",
|
||||
"495b2f8c4b": "已启动 agent,但无法将选中的评论标记为已解决。",
|
||||
"3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。"
|
||||
"3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。",
|
||||
"b4f3ec62a1": "More {{value0}} actions",
|
||||
"a9d7c128e4": "unlink {{value0}}"
|
||||
},
|
||||
"CreatePullRequestDialog": {
|
||||
"2bc1b4345e": "取消",
|
||||
@@ -8963,7 +9005,11 @@
|
||||
"3c4adfd821": "修复登录竞争条件",
|
||||
"56a0271428": "独立的工作区",
|
||||
"ef737dcee1": "GitHub 和 Linear 任务",
|
||||
"ac51c061e2": "codex"
|
||||
"ac51c061e2": "codex",
|
||||
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
|
||||
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
|
||||
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
|
||||
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
|
||||
},
|
||||
"FeatureWallBody": {
|
||||
"25ec5356d6": "设置"
|
||||
@@ -9036,7 +9082,9 @@
|
||||
"6e3f5223c5": "探险家",
|
||||
"ab2901bce6": "检查项",
|
||||
"d7f80060ca": "源代码控制",
|
||||
"8e715588e4": "搜索"
|
||||
"8e715588e4": "搜索",
|
||||
"a6c8b9e32f": "Checks passed",
|
||||
"f4d5e1a7b2": "3 checks"
|
||||
},
|
||||
"ReviewShipAnimatedVisual": {
|
||||
"4d99496b8c": "创建PR",
|
||||
|
||||
Reference in New Issue
Block a user