mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
Localize remaining automation strings and optimize search
- Add 12 i18n keys for automation labels, counts, and usage display - Extract AutomationPaneTab and SelectedExternalRunPage types to shared automation-page-state module - Optimize search fingerprint by truncating prompts to indexed prefix for bounded performance per tick - Improve escape-key handling in search field to clear input before blurring - Remove deprecated getAutomationListSearchQuery function
This commit is contained in:
@@ -152,7 +152,7 @@ export function ExternalAutomationDeleteDialog({
|
||||
'auto.components.automations.AutomationsPage.8500baacb4',
|
||||
'external source'
|
||||
)}{' '}
|
||||
{translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}
|
||||
{translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}{' '}
|
||||
{externalDeleteTarget?.manager.targetLabel}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -104,7 +104,7 @@ export function AutomationListExternalRows({
|
||||
}
|
||||
const nextRunLabel = entry.job.enabled
|
||||
? formatExternalDate(entry.job.nextRunAt, relativeNow)
|
||||
: 'Paused'
|
||||
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
|
||||
const entrySshStatus =
|
||||
entry.manager.target.type === 'ssh'
|
||||
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
|
||||
@@ -151,7 +151,11 @@ export function AutomationListExternalRows({
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="truncate">
|
||||
{entry.manager.provider === 'hermes'
|
||||
? `${entry.job.runCount} ${entry.job.runCount === 1 ? 'run' : 'runs'}`
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.runCount',
|
||||
'{{count}} runs',
|
||||
{ count: entry.job.runCount }
|
||||
)
|
||||
: entry.manager.canManage
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.aecdc3681f',
|
||||
|
||||
@@ -67,6 +67,20 @@ export function AutomationListLocalRows({
|
||||
onToggle: (automation: Automation) => void
|
||||
onDelete: (automation: Automation) => void
|
||||
}): React.JSX.Element {
|
||||
// Why: one pass over runs instead of a full scan per rendered automation —
|
||||
// this list re-renders on the relativeNow timer.
|
||||
const runsByAutomationId = React.useMemo(() => {
|
||||
const grouped = new Map<string, AutomationRun[]>()
|
||||
for (const run of runs) {
|
||||
const existing = grouped.get(run.automationId)
|
||||
if (existing) {
|
||||
existing.push(run)
|
||||
} else {
|
||||
grouped.set(run.automationId, [run])
|
||||
}
|
||||
}
|
||||
return grouped
|
||||
}, [runs])
|
||||
return (
|
||||
<>
|
||||
{automations.map((automation) => {
|
||||
@@ -84,24 +98,50 @@ export function AutomationListLocalRows({
|
||||
automationHostTarget,
|
||||
sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id)
|
||||
})
|
||||
const baseRefLabel =
|
||||
automation.baseBranch ??
|
||||
automationRepo?.worktreeBaseRef ??
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.projectDefaultBaseRef',
|
||||
'project default'
|
||||
)
|
||||
const workspaceLabel =
|
||||
automation.workspaceMode === 'new_per_run'
|
||||
? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}`
|
||||
: (automationWorktree?.displayName ?? 'Missing workspace')
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.createFromBaseRef',
|
||||
'Create from {{baseRef}}',
|
||||
{ baseRef: baseRefLabel }
|
||||
)
|
||||
: (automationWorktree?.displayName ??
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.missingWorkspace',
|
||||
'Missing workspace'
|
||||
))
|
||||
const usageSummary = summarizeAutomationRunUsage(
|
||||
runs.filter((run) => run.automationId === automation.id)
|
||||
runsByAutomationId.get(automation.id) ?? []
|
||||
)
|
||||
const usageText =
|
||||
usageSummary.knownRuns > 0
|
||||
? `${formatAutomationCost(
|
||||
usageSummary.estimatedCostUsd
|
||||
)} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens`
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.runUsageSummary',
|
||||
'{{cost}} est. · {{tokens}} tokens',
|
||||
{
|
||||
cost: formatAutomationCost(usageSummary.estimatedCostUsd),
|
||||
tokens: formatAutomationTokens(usageSummary.totalTokens)
|
||||
}
|
||||
)
|
||||
: usageSummary.unavailableRuns > 0
|
||||
? 'Usage unavailable'
|
||||
: 'No run usage yet'
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.usageUnavailable',
|
||||
'Usage unavailable'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationsPage.noRunUsageYet',
|
||||
'No run usage yet'
|
||||
)
|
||||
const nextRunLabel = automation.enabled
|
||||
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
|
||||
: 'Paused'
|
||||
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
|
||||
const scheduleLabel = formatAutomationSchedule(automation.rrule)
|
||||
return (
|
||||
<ContextMenu key={automation.id}>
|
||||
|
||||
@@ -45,8 +45,13 @@ export function AutomationListSearchField({
|
||||
)}
|
||||
aria-invalid={isTooLarge || undefined}
|
||||
aria-describedby={isTooLarge ? 'automations-list-search-too-large' : undefined}
|
||||
className="h-8 border-border/60 bg-background pl-8 text-xs"
|
||||
style={hasText ? { paddingRight: isTooLarge ? 72 : 28 } : undefined}
|
||||
// Why: the page-level capture Escape handler blurs inputs; this opts out
|
||||
// so the first Escape clears the query without also losing focus.
|
||||
data-escape-clears-value={hasText ? 'true' : undefined}
|
||||
className={cn(
|
||||
'h-8 border-border/60 bg-background pl-8 text-xs',
|
||||
hasText && (isTooLarge ? 'pr-20' : 'pr-7')
|
||||
)}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape' || event.nativeEvent.isComposing) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n'
|
||||
type AutomationRunHistoryProps = {
|
||||
runs: AutomationRun[]
|
||||
automationId: string
|
||||
worktreeMap: Map<string, Worktree>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
onOpenRun: (run: AutomationRun) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -37,16 +37,9 @@ import type { AutomationTargetAvailability } from './automation-target-availabil
|
||||
import type { AutomationRunViewState } from './automation-run-view-state'
|
||||
import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
|
||||
import type { ExternalAutomationSourceAvailability } from './external-automation-source-availability'
|
||||
import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type AutomationPaneTab = 'overview' | 'runs'
|
||||
|
||||
type SelectedExternalRunPage = {
|
||||
manager: ExternalAutomationManager
|
||||
job: ExternalAutomationJob
|
||||
run: ExternalAutomationRun
|
||||
}
|
||||
|
||||
type AutomationsDetailPaneProps = {
|
||||
selected: Automation | null
|
||||
selectedExternal: ExternalAutomationListEntry | null
|
||||
@@ -70,7 +63,7 @@ type AutomationsDetailPaneProps = {
|
||||
selectedAutomationRunPageViewState: AutomationRunViewState | null
|
||||
canRerunSelectedAutomationRunPage: boolean
|
||||
isSelectedAutomationRunPageRerunPending: boolean
|
||||
worktreeMap: Map<string, Worktree>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
fetchExternalAutomationRuns: FetchExternalAutomationRuns
|
||||
onActivePaneTabChange: (tab: AutomationPaneTab) => void
|
||||
onClearExternalRunPage: () => void
|
||||
@@ -263,10 +256,19 @@ export function AutomationsDetailPane({
|
||||
relativeNow
|
||||
),
|
||||
'Orca',
|
||||
selectedAutomationRunPageWorkspaceDisplay?.detailLabel ?? 'No workspace'
|
||||
selectedAutomationRunPageWorkspaceDisplay?.detailLabel ??
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.noWorkspace',
|
||||
'No workspace'
|
||||
)
|
||||
]}
|
||||
detail={
|
||||
selectedAutomationRunPage.outputSnapshot?.truncated ? 'Latest saved output' : null
|
||||
selectedAutomationRunPage.outputSnapshot?.truncated
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.latestSavedOutput',
|
||||
'Latest saved output'
|
||||
)
|
||||
: null
|
||||
}
|
||||
statusLabel={getAutomationRunStatusLabel(selectedAutomationRunPage.status)}
|
||||
statusVariant={getAutomationRunStatusVariant(selectedAutomationRunPage.status)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
|
||||
import type { AutomationHostTarget } from './automation-host-client'
|
||||
import { clampAutomationListSearchQueryInput } from './automation-list-search'
|
||||
import type { AutomationPaneTab } from './automation-page-state'
|
||||
import { AutomationListSearchField } from './AutomationListSearchField'
|
||||
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
@@ -47,7 +48,7 @@ type AutomationsListPanelProps = {
|
||||
externalActionKey: string | null
|
||||
selectAutomationId: (automationId: string | null) => void
|
||||
selectExternalKey: (externalKey: string | null) => void
|
||||
setActivePaneTab: (tab: 'overview' | 'runs') => void
|
||||
setActivePaneTab: (tab: AutomationPaneTab) => void
|
||||
runNow: (automation: Automation) => void
|
||||
openEditDialog: (automation: Automation) => void
|
||||
toggleAutomation: (automation: Automation) => void
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
getRuntimeSourceHostAvailability,
|
||||
type RepoBackedAutomationSourceContext
|
||||
} from './automation-source-context'
|
||||
import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
|
||||
import {
|
||||
getExternalAutomationKey,
|
||||
getExternalAutomationSourceKey,
|
||||
@@ -122,14 +123,6 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
const AGENTS = getAgentCatalog().map((agent) => agent.id)
|
||||
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
|
||||
type AutomationPaneTab = 'overview' | 'runs'
|
||||
|
||||
type SelectedExternalRunPage = {
|
||||
manager: ExternalAutomationManager
|
||||
job: ExternalAutomationJob
|
||||
run: ExternalAutomationRun
|
||||
}
|
||||
|
||||
export default function AutomationsPage(): React.JSX.Element {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
|
||||
@@ -236,6 +229,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
|
||||
const editRequestRef = useRef(0)
|
||||
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
|
||||
// Why: both dialogs stay mounted, so a shared ref would let one dialog's
|
||||
// unmount clear the focus target the other still needs.
|
||||
const externalDeleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const completionInFlightRef = useRef<Set<string>>(new Set())
|
||||
const rerunRunIdsInFlightRef = useRef<Set<string>>(new Set())
|
||||
const workspaceNameCacheRef = useRef<Map<string, string>>(new Map())
|
||||
@@ -1880,6 +1876,12 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: fields that clear their own value on Escape consume this press;
|
||||
// blurring here would drop focus and let the next Escape close the page.
|
||||
if (target.dataset.escapeClearsValue === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: match Tasks page behavior: Esc first exits field focus, then exits
|
||||
// the page once focus is back on page chrome.
|
||||
if (
|
||||
@@ -2026,7 +2028,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
|
||||
<ExternalAutomationDeleteDialog
|
||||
externalDeleteTarget={externalDeleteTarget}
|
||||
confirmButtonRef={deleteConfirmButtonRef}
|
||||
confirmButtonRef={externalDeleteConfirmButtonRef}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setExternalDeleteTarget(null)
|
||||
@@ -2079,12 +2081,22 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
activePaneTab={activePaneTab}
|
||||
relativeNow={relativeNow}
|
||||
externalActionKey={externalActionKey}
|
||||
selectedRepoDisplayName={selectedRepo?.displayName ?? 'Unknown project'}
|
||||
selectedRepoDisplayName={
|
||||
selectedRepo?.displayName ??
|
||||
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
|
||||
}
|
||||
selectedRepoDefaultBaseRef={selectedRepo?.worktreeBaseRef ?? null}
|
||||
selectedWorkspaceName={
|
||||
selected?.workspaceMode === 'new_per_run'
|
||||
? 'New workspace each run'
|
||||
: (selectedWorktree?.displayName ?? 'Missing workspace')
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.cd8397cc32',
|
||||
'New workspace each run'
|
||||
)
|
||||
: (selectedWorktree?.displayName ??
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.missingWorkspace',
|
||||
'Missing workspace'
|
||||
))
|
||||
}
|
||||
hostLabelById={hostLabelById}
|
||||
selectedRunNowAvailability={selectedRunNowAvailability}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
filterByAutomationListSearch,
|
||||
filterByAutomationListSearchIndex,
|
||||
getActiveAutomationListSearchQuery,
|
||||
getAutomationListSearchQuery,
|
||||
isAutomationListSearchQueryTooLarge,
|
||||
normalizeAutomationListSearchField,
|
||||
resolveAutomationListSearchQuery,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
|
||||
describe('automation-list-search', () => {
|
||||
it('normalizes query casing and whitespace', () => {
|
||||
expect(getAutomationListSearchQuery(' Auto PR ')).toBe('auto pr')
|
||||
expect(getActiveAutomationListSearchQuery(' Auto PR ')).toBe('auto pr')
|
||||
expect(resolveAutomationListSearchQuery(' Auto PR ')).toEqual({
|
||||
status: 'active',
|
||||
@@ -33,7 +31,6 @@ describe('automation-list-search', () => {
|
||||
it('rejects oversized queries without searching', () => {
|
||||
const oversized = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1)
|
||||
expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true)
|
||||
expect(getAutomationListSearchQuery(oversized)).toBeNull()
|
||||
expect(getActiveAutomationListSearchQuery(oversized)).toBeNull()
|
||||
expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' })
|
||||
expect(
|
||||
@@ -51,6 +48,14 @@ describe('automation-list-search', () => {
|
||||
expect(filterByAutomationListSearch(items, oversized, (item) => item)).toBe(items)
|
||||
})
|
||||
|
||||
it('rejects queries over the byte limit but under the code-unit limit', () => {
|
||||
// 3 UTF-8 bytes per character, so half the cap in code units is over it in bytes.
|
||||
const multiByte = '한'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES / 2)
|
||||
expect(multiByte.length).toBeLessThanOrEqual(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES)
|
||||
expect(isAutomationListSearchQueryTooLarge(multiByte)).toBe(true)
|
||||
expect(resolveAutomationListSearchQuery(multiByte)).toEqual({ status: 'too_large' })
|
||||
})
|
||||
|
||||
it('treats whitespace-only queries as inactive (no search work)', () => {
|
||||
expect(getActiveAutomationListSearchQuery(' \t ')).toBeNull()
|
||||
expect(resolveAutomationListSearchQuery(' ')).toEqual({ status: 'inactive' })
|
||||
@@ -152,6 +157,12 @@ describe('automation-list-search', () => {
|
||||
'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1)
|
||||
)
|
||||
).toBe(items)
|
||||
// Why: a desynchronized index must leave the list unfiltered, not blank it.
|
||||
expect(
|
||||
filterByActiveAutomationListSearchQuery(items, indexes.slice(0, 1), 'apk').map(
|
||||
(item) => item.id
|
||||
)
|
||||
).toEqual(['1', '2', '3'])
|
||||
})
|
||||
|
||||
it('builds a stable fingerprint from search sources only', () => {
|
||||
|
||||
@@ -77,21 +77,6 @@ export function getActiveAutomationListSearchQuery(
|
||||
return resolved.status === 'active' ? resolved.query : null
|
||||
}
|
||||
|
||||
/** @deprecated Prefer resolveAutomationListSearchQuery. null means too large. */
|
||||
export function getAutomationListSearchQuery(
|
||||
rawQuery: string,
|
||||
maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES
|
||||
): string | null {
|
||||
const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes)
|
||||
if (resolved.status === 'too_large') {
|
||||
return null
|
||||
}
|
||||
if (resolved.status === 'inactive') {
|
||||
return ''
|
||||
}
|
||||
return resolved.query
|
||||
}
|
||||
|
||||
/** Avoid splitting a surrogate pair at the cap boundary. */
|
||||
export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string {
|
||||
if (value.length <= maxCodeUnits) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
ExternalAutomationJob,
|
||||
ExternalAutomationManager,
|
||||
ExternalAutomationRun
|
||||
} from '../../../../shared/automations-types'
|
||||
|
||||
/** Detail-pane tab shared by the page, its list panel, and the detail pane. */
|
||||
export type AutomationPaneTab = 'overview' | 'runs'
|
||||
|
||||
/** External run opened as a full page inside the detail pane. */
|
||||
export type SelectedExternalRunPage = {
|
||||
manager: ExternalAutomationManager
|
||||
job: ExternalAutomationJob
|
||||
run: ExternalAutomationRun
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
buildAutomationListSearchIndex,
|
||||
buildAutomationProjectSearchText,
|
||||
resolveAutomationListSearchQuery,
|
||||
truncateAutomationListSearchField,
|
||||
AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS,
|
||||
type AutomationListSearchFields,
|
||||
type AutomationListSearchIndex
|
||||
} from './automation-list-search'
|
||||
@@ -59,7 +61,8 @@ export function useAutomationListSearch({
|
||||
const isListSearchActive = activeListSearchQuery !== null
|
||||
|
||||
// Why: fingerprint includes id + search fields so refresh ticks that only
|
||||
// change nextRunAt / usage do not rebuild indexes or re-run matching.
|
||||
// change nextRunAt / usage do not rebuild indexes or re-run matching. Prompts
|
||||
// are truncated to the indexed prefix so each tick stays O(bound) per row.
|
||||
const automationSearchFingerprint = useMemo(
|
||||
() =>
|
||||
automations
|
||||
@@ -69,7 +72,11 @@ export function useAutomationListSearch({
|
||||
displayName: repo?.displayName,
|
||||
path: repo?.path
|
||||
})
|
||||
return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${automation.prompt}`
|
||||
const prompt = truncateAutomationListSearchField(
|
||||
automation.prompt,
|
||||
AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS
|
||||
)
|
||||
return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${prompt}`
|
||||
})
|
||||
.join('\u0000'),
|
||||
[automations, repoMap]
|
||||
@@ -102,7 +109,11 @@ export function useAutomationListSearch({
|
||||
if (entry.kind === 'source') {
|
||||
return `${entry.key}\u0001${entry.manager.targetLabel}\u0001${getExternalProviderLabel(entry.manager)}\u0001`
|
||||
}
|
||||
return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${entry.job.prompt ?? entry.job.promptPreview ?? ''}`
|
||||
const prompt = truncateAutomationListSearchField(
|
||||
entry.job.prompt ?? entry.job.promptPreview ?? '',
|
||||
AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS
|
||||
)
|
||||
return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${prompt}`
|
||||
})
|
||||
.join('\u0000'),
|
||||
[externalAutomationEntries]
|
||||
|
||||
@@ -13919,7 +13919,19 @@
|
||||
"53f06f0ad5": "Retry source",
|
||||
"pendingAutomationMissing": "Automation no longer available.",
|
||||
"pendingAutomationRunMissing": "Run history no longer available.",
|
||||
"noSearchMatches": "No automations match your search."
|
||||
"noSearchMatches": "No automations match your search.",
|
||||
"paused": "Paused",
|
||||
"runCount": "{{count}} runs",
|
||||
"runCount_one": "{{count}} run",
|
||||
"runCount_other": "{{count}} runs",
|
||||
"projectDefaultBaseRef": "project default",
|
||||
"createFromBaseRef": "Create from {{baseRef}}",
|
||||
"missingWorkspace": "Missing workspace",
|
||||
"runUsageSummary": "{{cost}} est. · {{tokens}} tokens",
|
||||
"usageUnavailable": "Usage unavailable",
|
||||
"noRunUsageYet": "No run usage yet",
|
||||
"noWorkspace": "No workspace",
|
||||
"latestSavedOutput": "Latest saved output"
|
||||
},
|
||||
"CreateFromPicker": {
|
||||
"f061f49e3f": "Search repo branches...",
|
||||
|
||||
Reference in New Issue
Block a user