mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/linear-cli-v2
# Conflicts: # src/renderer/src/components/mobile/MobileHero.tsx # src/renderer/src/components/onboarding/ThemeStep.tsx # src/renderer/src/components/right-sidebar/HostedReviewActions.tsx # src/renderer/src/components/settings/McpConfigSection.tsx # src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts # src/renderer/src/components/stats/ClaudeUsagePane.tsx # src/renderer/src/components/stats/CodexUsagePane.tsx # src/renderer/src/components/stats/OpenCodeUsagePane.tsx
This commit is contained in:
@@ -153,5 +153,75 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Enter a commit message to commit': 'コミットメッセージを入力してコミット',
|
||||
'Choose parent folder...': '親フォルダーを選択...',
|
||||
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.':
|
||||
'スタックした作業、古い生成ファイル、検証の失敗、および人間の対応が必要なものがないか確認してください。対応が必要なイシューのみを報告してください。'
|
||||
'スタックした作業、古い生成ファイル、検証の失敗、および人間の対応が必要なものがないか確認してください。対応が必要なイシューのみを報告してください。',
|
||||
// Round 6: homograph/term mistranslations surfaced by a full ja.json audit.
|
||||
// Why: machine translation rendered UI terms as unrelated everyday words.
|
||||
Smart: 'スマート',
|
||||
Jobs: 'ジョブ',
|
||||
Bold: '太字',
|
||||
Strike: '取り消し線',
|
||||
thread: 'スレッド',
|
||||
Lead: 'リード',
|
||||
Assignees: '担当者',
|
||||
'Assignees:': '担当者:',
|
||||
Origin: 'オリジン',
|
||||
Force: '強制',
|
||||
Kind: '種類',
|
||||
Address: 'アドレス',
|
||||
Mouse: 'マウス',
|
||||
Home: 'ホーム',
|
||||
Move: '移動',
|
||||
Change: '変更',
|
||||
Conflicts: '競合',
|
||||
conflict: '競合',
|
||||
'unresolved conflict': '未解決の競合',
|
||||
'Next match': '次の一致',
|
||||
'Previous match': '前の一致',
|
||||
'No matches': '一致なし',
|
||||
'New Issue': '新規イシュー',
|
||||
'Issue title': 'イシューのタイトル',
|
||||
'Sub-issue title': 'サブイシューのタイトル',
|
||||
Merged: 'マージ済み',
|
||||
Behind: '遅れ',
|
||||
'Head branch': 'ヘッドブランチ',
|
||||
Import: 'インポート',
|
||||
'Import…': 'インポート…',
|
||||
'Import...': 'インポート...',
|
||||
'Re-import': '再インポート',
|
||||
Local: 'ローカル',
|
||||
local: 'ローカル',
|
||||
'Scanning...': 'スキャン中...',
|
||||
Sparse: 'スパース',
|
||||
sparse: 'スパース',
|
||||
connection: '接続',
|
||||
'Est. cost': '推定コスト',
|
||||
'Est. API-equivalent cost': 'API 相当の推定コスト',
|
||||
'Est. spend': '推定費用',
|
||||
Spend: '費用',
|
||||
'new markdown': '新規 markdown',
|
||||
working: '実行中',
|
||||
'Working…': '処理中…',
|
||||
On: 'オン',
|
||||
Hold: 'ホールド',
|
||||
'Keep alive until reset': 'リセットされるまで維持',
|
||||
Forward: '進む',
|
||||
Fresh: '新規',
|
||||
Gone: '削除済み',
|
||||
Ding: 'ディン',
|
||||
'Take back': '操作を取り戻す',
|
||||
'Mobile driving': 'モバイルで操作中',
|
||||
'Mobile is driving this browser': 'モバイルがこのブラウザを操作しています',
|
||||
'Wants to run': '実行をリクエスト中',
|
||||
'No states found': 'ステータスが見つかりません',
|
||||
'Loading states': 'ステータスを読み込み中',
|
||||
vibrancy: '透過効果',
|
||||
ligature: '合字',
|
||||
Zinc: 'ジンク',
|
||||
Rose: 'ローズ',
|
||||
import: 'インポート',
|
||||
mouse: 'マウス',
|
||||
'Open job in GitLab': 'GitLab でジョブを開く',
|
||||
'Showing first 100 jobs': '最初の 100 件のジョブを表示しています',
|
||||
'Imported from Ghostty.': 'Ghostty からインポートしました。',
|
||||
'Claude Accounts': 'Claude アカウント'
|
||||
}
|
||||
|
||||
@@ -590,5 +590,7 @@ export const LOCALE_KEY_OVERRIDES = {
|
||||
zh: '已添加评审评论。',
|
||||
ja: 'レビューコメントを追加しました。'
|
||||
},
|
||||
// Port forwarding "Forward" is 転送, not the browser-navigation 進む.
|
||||
'auto.components.right.sidebar.PortsPanel.c9d106547a': { ja: '転送' },
|
||||
...KO_KEY_OVERRIDES
|
||||
}
|
||||
|
||||
@@ -82,7 +82,75 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Nautilus',
|
||||
'GitHub',
|
||||
'no_proxy',
|
||||
'Beta'
|
||||
'Beta',
|
||||
// Round 6: product/tool names, language names, and code tokens that machine
|
||||
// translation wrongly localized (e.g. tailscale→尾鱗, Swift→迅速, yarn→糸).
|
||||
'Tailscale',
|
||||
'tailscale',
|
||||
'Ghostty',
|
||||
'ghostty',
|
||||
'pwsh',
|
||||
'yarn',
|
||||
'Kagi',
|
||||
'kagi',
|
||||
'Bitbucket',
|
||||
'bitbucket',
|
||||
'GNOME',
|
||||
'gnome',
|
||||
'iCloud',
|
||||
'icloud',
|
||||
'ripgrep',
|
||||
'PowerShell',
|
||||
'powershell',
|
||||
'Mermaid',
|
||||
'mermaid',
|
||||
'Swift',
|
||||
'swift',
|
||||
'Rust',
|
||||
'rust',
|
||||
'Java',
|
||||
'java',
|
||||
'Go',
|
||||
'Python',
|
||||
'python',
|
||||
'Kotlin',
|
||||
'kotlin',
|
||||
'Ruby',
|
||||
'ruby',
|
||||
'Bash',
|
||||
'bash',
|
||||
'GraphQL',
|
||||
'graphql',
|
||||
'ide',
|
||||
'IDE',
|
||||
'ui',
|
||||
'UI',
|
||||
'otlp',
|
||||
'OTLP',
|
||||
'calt',
|
||||
'ai',
|
||||
'AI',
|
||||
'ci',
|
||||
'REST',
|
||||
'rest',
|
||||
'yaml',
|
||||
'yml',
|
||||
'true',
|
||||
'false',
|
||||
'/home/user',
|
||||
'/home/user/project',
|
||||
'/path/to/destination',
|
||||
'.orca/issue-command',
|
||||
'PLAN.md',
|
||||
'feat/mobile-page',
|
||||
'sk-...',
|
||||
'main',
|
||||
'master',
|
||||
'HEAD',
|
||||
'lint',
|
||||
'MD',
|
||||
'/home/user/projects',
|
||||
'Claude Code'
|
||||
])
|
||||
|
||||
export const BRAND_MISTRANSLATIONS = {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.61-rc.2",
|
||||
"version": "1.4.62",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Why: these are exact filled brand paths, not generic line approximations.
|
||||
export function IosBrandIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AndroidLogo(): React.JSX.Element {
|
||||
return (
|
||||
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M18.4395 5.5586c-.675 1.1664-1.352 2.3318-2.0274 3.498-.0366-.0155-.0742-.0286-.1113-.043-1.8249-.6957-3.484-.8-4.42-.787-1.8551.0185-3.3544.4643-4.2597.8203-.084-.1494-1.7526-3.021-2.0215-3.4864a1.1451 1.1451 0 0 0-.1406-.1914c-.3312-.364-.9054-.4859-1.379-.203-.475.282-.7136.9361-.3886 1.5019 1.9466 3.3696-.0966-.2158 1.9473 3.3593.0172.031-.4946.2642-1.3926 1.0177C2.8987 12.176.452 14.772 0 18.9902h24c-.119-1.1108-.3686-2.099-.7461-3.0683-.7438-1.9118-1.8435-3.2928-2.7402-4.1836a12.1048 12.1048 0 0 0-2.1309-1.6875c.6594-1.122 1.312-2.2559 1.9649-3.3848.2077-.3615.1886-.7956-.0079-1.1191a1.1001 1.1001 0 0 0-.8515-.5332c-.5225-.0536-.9392.3128-1.0488.5449zm-.0391 8.461c.3944.5926.324 1.3306-.1563 1.6503-.4799.3197-1.188.0985-1.582-.4941-.3944-.5927-.324-1.3307.1563-1.6504.4727-.315 1.1812-.1086 1.582.4941zM7.207 13.5273c.4803.3197.5506 1.0577.1563 1.6504-.394.5926-1.1038.8138-1.584.4941-.48-.3197-.5503-1.0577-.1563-1.6504.4008-.6021 1.1087-.8106 1.584-.4941z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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 } from './MobileHeroPaired'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -27,59 +29,6 @@ function getDeviceLabel(): string {
|
||||
return 'computer'
|
||||
}
|
||||
|
||||
export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<div className="mp-intro-shell">
|
||||
<div className="mp-eyebrow-row">
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mp-h1">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.cd4e5e816f',
|
||||
'Your workspaces, in your pocket.'
|
||||
)}
|
||||
</h1>
|
||||
<p className="mp-lead">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.b4ccce5cb7',
|
||||
"Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk."
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
className="mp-platform-badges"
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.ec0607bf66',
|
||||
'Supported mobile platforms'
|
||||
)}
|
||||
>
|
||||
<span className="mp-platform-label">
|
||||
{translate('auto.components.mobile.MobileHero.da1d5e5ed0', 'Available on')}
|
||||
</span>
|
||||
<span className="mp-platform-badge">
|
||||
<IosBrandIcon />
|
||||
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
|
||||
</span>
|
||||
<span className="mp-platform-badge">
|
||||
<AndroidLogo />
|
||||
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mp-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
className="mp-primary-action mp-flow-primary-action"
|
||||
onClick={onStart}
|
||||
>
|
||||
{translate('auto.components.mobile.MobileHero.10d27b4cba', 'Get started')}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type HeroFlowProps = {
|
||||
stepIdx: StepIndex
|
||||
platform: Platform
|
||||
@@ -350,20 +299,3 @@ export function HeroFlow({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: these are exact filled brand paths, not generic line approximations.
|
||||
function IosBrandIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AndroidLogo(): React.JSX.Element {
|
||||
return (
|
||||
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M18.4395 5.5586c-.675 1.1664-1.352 2.3318-2.0274 3.498-.0366-.0155-.0742-.0286-.1113-.043-1.8249-.6957-3.484-.8-4.42-.787-1.8551.0185-3.3544.4643-4.2597.8203-.084-.1494-1.7526-3.021-2.0215-3.4864a1.1451 1.1451 0 0 0-.1406-.1914c-.3312-.364-.9054-.4859-1.379-.203-.475.282-.7136.9361-.3886 1.5019 1.9466 3.3696-.0966-.2158 1.9473 3.3593.0172.031-.4946.2642-1.3926 1.0177C2.8987 12.176.452 14.772 0 18.9902h24c-.119-1.1108-.3686-2.099-.7461-3.0683-.7438-1.9118-1.8435-3.2928-2.7402-4.1836a12.1048 12.1048 0 0 0-2.1309-1.6875c.6594-1.122 1.312-2.2559 1.9649-3.3848.2077-.3615.1886-.7956-.0079-1.1191a1.1001 1.1001 0 0 0-.8515-.5332c-.5225-.0536-.9392.3128-1.0488.5449zm-.0391 8.461c.3944.5926.324 1.3306-.1563 1.6503-.4799.3197-1.188.0985-1.582-.4941-.3944-.5927-.324-1.3307.1563-1.6504.4727-.315 1.1812-.1086 1.582.4941zM7.207 13.5273c.4803.3197.5506 1.0577.1563 1.6504-.394.5926-1.1038.8138-1.584.4941-.48-.3197-.5503-1.0577-.1563-1.6504.4008-.6021 1.1087-.8106 1.584-.4941z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<div className="mp-intro-shell">
|
||||
<div className="mp-eyebrow-row">
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mp-h1">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.cd4e5e816f',
|
||||
'Your workspaces, in your pocket.'
|
||||
)}
|
||||
</h1>
|
||||
<p className="mp-lead">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.b4ccce5cb7',
|
||||
"Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk."
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
className="mp-platform-badges"
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.ec0607bf66',
|
||||
'Supported mobile platforms'
|
||||
)}
|
||||
>
|
||||
<span className="mp-platform-label">
|
||||
{translate('auto.components.mobile.MobileHero.da1d5e5ed0', 'Available on')}
|
||||
</span>
|
||||
<span className="mp-platform-badge">
|
||||
<IosBrandIcon />
|
||||
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
|
||||
</span>
|
||||
<span className="mp-platform-badge">
|
||||
<AndroidLogo />
|
||||
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mp-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
className="mp-primary-action mp-flow-primary-action"
|
||||
onClick={onStart}
|
||||
>
|
||||
{translate('auto.components.mobile.MobileHero.10d27b4cba', 'Get started')}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import type { GhosttyImportPreview } from '../../../../shared/types'
|
||||
import ghosttyIcon from '../../../../../resources/ghostty.svg'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { DiscoveryState } from './ThemeStep'
|
||||
|
||||
export function GhosttyDiscoveryRow({
|
||||
discovery,
|
||||
importing,
|
||||
disabled,
|
||||
onImport
|
||||
}: {
|
||||
discovery: DiscoveryState
|
||||
importing: boolean
|
||||
disabled: boolean
|
||||
onImport: (preview: GhosttyImportPreview) => void
|
||||
}) {
|
||||
// Why: 'idle' is the pre-effect state that persists on non-Mac (the
|
||||
// discovery effect short-circuits there), so render nothing instead of
|
||||
// showing the dashed-border "Looking for a Ghostty config..." placeholder.
|
||||
if (discovery.status === 'absent' || discovery.status === 'idle') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (discovery.status === 'detecting') {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-dashed border-border bg-transparent px-3.5 py-2.5 text-[12px] text-muted-foreground">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
|
||||
{translate(
|
||||
'auto.components.onboarding.ThemeStep.2c3aa538f8',
|
||||
'Looking for a Ghostty config...'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (discovery.status === 'imported') {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.07] px-3.5 py-2.5 text-[12px] text-foreground">
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">
|
||||
{translate('auto.components.onboarding.ThemeStep.78b6386140', 'Imported from Ghostty.')}
|
||||
</span>
|
||||
{discovery.fields.length > 0 && (
|
||||
<span className="text-muted-foreground"> {discovery.fields.join(' · ')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { preview, fields } = discovery
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-violet-500/30 bg-violet-500/[0.06] px-3.5 py-2.5">
|
||||
<img src={ghosttyIcon} alt="" className="size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-foreground">
|
||||
<span className="font-medium">
|
||||
{translate(
|
||||
'auto.components.onboarding.ThemeStep.7ee9234e54',
|
||||
'Ghostty config detected.'
|
||||
)}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}{' '}
|
||||
{fields.length > 0
|
||||
? fields.map((f) => f.toLowerCase()).join(', ')
|
||||
: translate('auto.components.onboarding.ThemeStep.906c4373fe', 'settings')}
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
{preview.configPath && (
|
||||
<div
|
||||
className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground"
|
||||
title={preview.configPath}
|
||||
>
|
||||
{preview.configPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded-md bg-foreground px-3 py-1.5 text-[11.5px] font-semibold text-background hover:bg-foreground/90 disabled:opacity-50"
|
||||
disabled={importing || disabled}
|
||||
onClick={() => onImport(preview)}
|
||||
>
|
||||
{importing
|
||||
? translate('auto.components.onboarding.ThemeStep.ad19e5c916', 'Importing...')
|
||||
: translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { GhosttyDiscoveryRow } from './GhosttyDiscoveryRow'
|
||||
import type {
|
||||
DiscoveryStatusEmitted,
|
||||
GhosttyImportPreview,
|
||||
GlobalSettings
|
||||
} from '../../../../shared/types'
|
||||
import { GhosttyDiscoveryRow } from './ThemeStepGhosttyDiscoveryRow'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type ThemeStepProps = {
|
||||
|
||||
@@ -19,9 +19,12 @@ 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 { HostedReviewDeleteWorkspaceButton } from './HostedReviewDeleteWorkspaceButton'
|
||||
import { HostedReviewReopenButton } from './HostedReviewReopenButton'
|
||||
import { presentGitLabMRMergeState } from './gitlab-mr-merge-state'
|
||||
import {
|
||||
ClosedReviewActions,
|
||||
HostedReviewActionError,
|
||||
MergedReviewActions
|
||||
} from './HostedReviewStateActions'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type HostedReviewActionInfo = Pick<
|
||||
@@ -371,30 +374,27 @@ export default function HostedReviewActions({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{actionError && <div className="text-[10px] text-rose-500 break-words">{actionError}</div>}
|
||||
<HostedReviewActionError message={actionError} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (review.state === 'closed') {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<HostedReviewReopenButton
|
||||
label={shortLabel}
|
||||
reopening={stateUpdating === 'open'}
|
||||
disabled={isUpdatingReviewState}
|
||||
onReopen={() => void handleReopenReview()}
|
||||
/>
|
||||
{actionError && <div className="text-[10px] text-rose-500 break-words">{actionError}</div>}
|
||||
</div>
|
||||
<ClosedReviewActions
|
||||
shortLabel={shortLabel}
|
||||
stateUpdating={stateUpdating}
|
||||
actionError={actionError}
|
||||
onReopenReview={() => void handleReopenReview()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (review.state === 'merged') {
|
||||
return (
|
||||
<HostedReviewDeleteWorkspaceButton
|
||||
deleting={isDeletingWorktree}
|
||||
onDelete={handleDeleteWorktree}
|
||||
<MergedReviewActions
|
||||
isDeletingWorktree={isDeletingWorktree}
|
||||
onDeleteWorktree={handleDeleteWorktree}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CircleDot, LoaderCircle, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function HostedReviewActionError({
|
||||
message
|
||||
}: {
|
||||
message: string | null
|
||||
}): React.JSX.Element | null {
|
||||
return message ? <div className="text-[10px] text-rose-500 break-words">{message}</div> : null
|
||||
}
|
||||
|
||||
export function ClosedReviewActions({
|
||||
shortLabel,
|
||||
stateUpdating,
|
||||
actionError,
|
||||
onReopenReview
|
||||
}: {
|
||||
shortLabel: string
|
||||
stateUpdating: 'open' | 'closed' | null
|
||||
actionError: string | null
|
||||
onReopenReview: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={onReopenReview}
|
||||
disabled={stateUpdating !== null}
|
||||
>
|
||||
{stateUpdating === 'open' ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<CircleDot className="size-3.5" />
|
||||
)}
|
||||
{stateUpdating === 'open'
|
||||
? translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.6645ac7dd1',
|
||||
'Reopening...'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.3ce211ece6',
|
||||
'Reopen {{value0}}',
|
||||
{ value0: shortLabel }
|
||||
)}
|
||||
</Button>
|
||||
<HostedReviewActionError message={actionError} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MergedReviewActions({
|
||||
isDeletingWorktree,
|
||||
onDeleteWorktree
|
||||
}: {
|
||||
isDeletingWorktree: boolean
|
||||
onDeleteWorktree: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={onDeleteWorktree}
|
||||
disabled={isDeletingWorktree}
|
||||
>
|
||||
{isDeletingWorktree ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
{isDeletingWorktree
|
||||
? translate('auto.components.right.sidebar.HostedReviewActions.eefd50457e', 'Deleting...')
|
||||
: translate(
|
||||
'auto.components.right.sidebar.HostedReviewActions.e4aca40024',
|
||||
'Delete Workspace'
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
isMissingMcpConfigFileError,
|
||||
selectMcpTargetWorktree
|
||||
} from './mcp-config-inspection-state'
|
||||
import { McpMissingConfigList } from './McpMissingConfigList'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type McpConfigSectionProps = {
|
||||
@@ -395,23 +396,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingConfigs.length > 0 && !inspectionUnavailable ? (
|
||||
<div className="space-y-1.5 border-t border-border/50 px-3 py-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.settings.McpConfigSection.4d16a0d9ac', 'Checked')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{missingConfigs.map((config) => (
|
||||
<span
|
||||
key={config.candidate.relativePath}
|
||||
className="rounded-md border border-border/50 bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{config.candidate.relativePath}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!inspectionUnavailable ? <McpMissingConfigList missingConfigs={missingConfigs} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { LoadedMcpConfigInspection } from './McpConfigFileRow'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function McpMissingConfigList({
|
||||
missingConfigs
|
||||
}: {
|
||||
missingConfigs: LoadedMcpConfigInspection[]
|
||||
}): React.JSX.Element | null {
|
||||
if (missingConfigs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 border-t border-border/50 px-3 py-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.settings.McpConfigSection.4d16a0d9ac', 'Checked')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{missingConfigs.map((config) => (
|
||||
<span
|
||||
key={config.candidate.relativePath}
|
||||
className="rounded-md border border-border/50 bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{config.candidate.relativePath}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,8 +106,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
|
||||
const html = renderNestedStep(0)
|
||||
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('aria-label="Monorepo name"')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).toContain('Import as group')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).not.toContain('>Import</button>')
|
||||
})
|
||||
@@ -116,8 +116,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
|
||||
const html = renderNestedStep(1)
|
||||
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('aria-label="Monorepo name"')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).toContain('Import as group')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).not.toContain('>Import</button>')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ComponentProps } from 'react'
|
||||
import { act, useState, type ComponentProps } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -78,13 +78,13 @@ describe('AddRepoNestedImportStep', () => {
|
||||
expect(html).toContain('Import repositories from folder')
|
||||
expect(html).toContain('Found 3 repositories in')
|
||||
expect(html).toContain('/workspace/platform')
|
||||
expect(html).toContain('aria-label="Monorepo name"')
|
||||
expect(html).toContain('aria-label="What is a monorepo name?"')
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).not.toContain('What is a')
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('Choose this if these projects belong together')
|
||||
expect(html).toContain('Import them as a group if they're a monorepo')
|
||||
expect(html).toContain('Orca will group them and let you work from the parent folder')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toContain('Import as group')
|
||||
expect(html).toContain('payments/api')
|
||||
expect(html).toContain('billing/api')
|
||||
expect(html).not.toContain('disabled=""')
|
||||
@@ -97,9 +97,9 @@ describe('AddRepoNestedImportStep', () => {
|
||||
|
||||
expect(html).toContain('Is this a monorepo?')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).toContain('Yes, import as monorepo')
|
||||
expect(html).toContain('Import as group')
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Import as group<\/button>/)
|
||||
})
|
||||
|
||||
it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => {
|
||||
@@ -130,11 +130,55 @@ describe('AddRepoNestedImportStep', () => {
|
||||
})
|
||||
|
||||
act(() => {
|
||||
findButton(host, 'Yes, import as monorepo').click()
|
||||
findButton(host, 'Import as group').click()
|
||||
findButton(host, 'No, import separately').click()
|
||||
})
|
||||
|
||||
expect(onImport).toHaveBeenNthCalledWith(1, 'group')
|
||||
expect(onImport).toHaveBeenNthCalledWith(2, 'separate')
|
||||
})
|
||||
|
||||
it('shows progress only on the clicked import action', () => {
|
||||
const onImport = vi.fn()
|
||||
const host = document.createElement('div')
|
||||
container = host
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
|
||||
function Harness(): React.JSX.Element {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
|
||||
isAdding={isAdding}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={(mode) => {
|
||||
onImport(mode)
|
||||
setIsAdding(true)
|
||||
}}
|
||||
onStopScan={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root?.render(<Harness />)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
findButton(host, 'Import as group').click()
|
||||
})
|
||||
|
||||
expect(onImport).toHaveBeenCalledWith('group')
|
||||
expect(findButton(host, 'Import as group').querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useId, type Dispatch, type SetStateAction } from 'react'
|
||||
import { CircleHelp, CircleStop, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useId, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { CircleStop, Loader2 } from 'lucide-react'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -36,6 +36,20 @@ export function AddRepoNestedImportStep({
|
||||
}: AddRepoNestedImportStepProps): React.JSX.Element {
|
||||
const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath
|
||||
const groupNameInputId = useId()
|
||||
const [pendingImportMode, setPendingImportMode] = useState<'group' | 'separate' | null>(null)
|
||||
const showSeparateSpinner = isAdding && pendingImportMode === 'separate'
|
||||
const showGroupSpinner = isAdding && pendingImportMode === 'group'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdding) {
|
||||
setPendingImportMode(null)
|
||||
}
|
||||
}, [isAdding])
|
||||
|
||||
const handleImport = (mode: 'group' | 'separate'): void => {
|
||||
setPendingImportMode(mode)
|
||||
onImport(mode)
|
||||
}
|
||||
const repoCountLabel =
|
||||
scan.repos.length === 1
|
||||
? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository')
|
||||
@@ -97,7 +111,7 @@ export function AddRepoNestedImportStep({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.d75170194e',
|
||||
'Choose this if these projects belong together. Orca will group them and let you work from the parent folder.'
|
||||
"Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -106,37 +120,15 @@ export function AddRepoNestedImportStep({
|
||||
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
|
||||
'Monorepo name'
|
||||
'Group name'
|
||||
)}
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.e907ec8935',
|
||||
'What is a monorepo name?'
|
||||
)}
|
||||
className="size-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CircleHelp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="max-w-64">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.b20bb7c24f',
|
||||
'Keeps these repos together in one group. Best for related repos like microservices.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id={groupNameInputId}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
|
||||
'Monorepo name'
|
||||
'Group name'
|
||||
)}
|
||||
value={groupName}
|
||||
onChange={(event) => onGroupNameChange(event.target.value)}
|
||||
@@ -147,22 +139,24 @@ export function AddRepoNestedImportStep({
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => onImport('separate')}
|
||||
onClick={() => handleImport('separate')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
variant="outline"
|
||||
>
|
||||
{showSeparateSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.aa0247680d',
|
||||
'No, import separately'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onImport('group')}
|
||||
onClick={() => handleImport('group')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
>
|
||||
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
|
||||
'Yes, import as monorepo'
|
||||
'Import as group'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -335,17 +335,10 @@ export function FolderWorkspaceComposerDialog({
|
||||
repoId={repoId}
|
||||
selectedRepoIsGit={true}
|
||||
onRepoChange={handleRepoChange}
|
||||
primaryActionLabel={
|
||||
quickAgent
|
||||
? translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart',
|
||||
'Create & Start Agent'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.create',
|
||||
'Create Workspace'
|
||||
)
|
||||
}
|
||||
primaryActionLabel={translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.create',
|
||||
'Create workspace'
|
||||
)}
|
||||
projectLabel={translate(
|
||||
'auto.components.sidebar.FolderWorkspaceComposerDialog.sourceProject',
|
||||
'Task Source'
|
||||
|
||||
@@ -15,6 +15,16 @@ type WorktreeListComponent = React.ComponentType<{
|
||||
|
||||
let WorktreeList: WorktreeListComponent
|
||||
|
||||
function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
|
||||
return {
|
||||
fetchFolderWorkspacePathStatus: vi.fn(),
|
||||
folderWorkspaces: [],
|
||||
folderWorkspacePathStatuses: {},
|
||||
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
|
||||
getFreshFolderWorkspacePathStatus: () => null
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector(mockStore.state)) as ((
|
||||
@@ -316,6 +326,7 @@ function setLineageFixtureState(
|
||||
grandchild.isUnread = unreadWorktreeIds.has(grandchild.id)
|
||||
|
||||
mockStore.state = {
|
||||
...makeFolderWorkspacePathStatusMockState(),
|
||||
activeModal: '',
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
@@ -396,6 +407,7 @@ function setProjectGroupWithoutWorktreeRowsState(filterRepoIds: string[] = []):
|
||||
}
|
||||
|
||||
mockStore.state = {
|
||||
...makeFolderWorkspacePathStatusMockState(),
|
||||
activeModal: '',
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
@@ -456,6 +468,7 @@ function setEmptyUngroupedProjectState(filterRepoIds: string[] = []): void {
|
||||
}
|
||||
|
||||
mockStore.state = {
|
||||
...makeFolderWorkspacePathStatusMockState(),
|
||||
activeModal: '',
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
|
||||
@@ -31,6 +31,16 @@ type WorktreeListComponent = React.ComponentType<{
|
||||
|
||||
let WorktreeList: WorktreeListComponent
|
||||
|
||||
function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
|
||||
return {
|
||||
fetchFolderWorkspacePathStatus: vi.fn(),
|
||||
folderWorkspaces: [],
|
||||
folderWorkspacePathStatuses: {},
|
||||
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
|
||||
getFreshFolderWorkspacePathStatus: () => null
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector(mockStore.state)) as ((
|
||||
@@ -234,6 +244,7 @@ function setLineageState(options: { deletingChild?: boolean } = {}): void {
|
||||
}
|
||||
})
|
||||
mockStore.state = {
|
||||
...makeFolderWorkspacePathStatusMockState(),
|
||||
activeModal: '',
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateAndRevealFolderWorkspace: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace
|
||||
}))
|
||||
|
||||
import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit'
|
||||
|
||||
function makeProjectGroup(): ProjectGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/repo/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeFolderWorkspace(): FolderWorkspace {
|
||||
return {
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'hi',
|
||||
folderPath: '/repo/platform/hi',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('submitFolderWorkspaceCreate', () => {
|
||||
afterEach(() => {
|
||||
mocks.activateAndRevealFolderWorkspace.mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('closes the composer after creation even when reveal fails', async () => {
|
||||
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
|
||||
const onOpenChange = vi.fn()
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mocks.activateAndRevealFolderWorkspace.mockImplementation(() => {
|
||||
throw new Error('activation failed')
|
||||
})
|
||||
|
||||
await submitFolderWorkspaceCreate({
|
||||
projectGroup: makeProjectGroup(),
|
||||
name: 'hi',
|
||||
lastAutoName: '',
|
||||
linkedWorkItem: null,
|
||||
note: '',
|
||||
quickAgent: null,
|
||||
autoRenameBranchFromWork: false,
|
||||
agentCmdOverrides: {},
|
||||
createFolderWorkspace,
|
||||
onOpenChange
|
||||
})
|
||||
|
||||
expect(createFolderWorkspace).toHaveBeenCalledWith({
|
||||
projectGroupId: 'group-1',
|
||||
name: 'hi',
|
||||
linkedTask: null
|
||||
})
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith(
|
||||
'folder-workspace-1',
|
||||
undefined
|
||||
)
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to activate folder workspace after create:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type LinkedWorkItemSummary
|
||||
} from '@/lib/new-workspace'
|
||||
import { getLinkedWorkItemPromptContext } from '@/lib/linked-work-item-context'
|
||||
import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability'
|
||||
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation'
|
||||
@@ -67,8 +68,11 @@ export async function submitFolderWorkspaceCreate({
|
||||
return
|
||||
}
|
||||
|
||||
const linearCliAvailable = linkedWorkItem?.linearIdentifier
|
||||
? await isOrcaCliAvailableForLaunch({ remote: projectGroup.connectionId != null })
|
||||
: false
|
||||
const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem, {
|
||||
cliAvailable: false
|
||||
cliAvailable: linearCliAvailable
|
||||
})
|
||||
const startupPrompt = buildAgentPromptWithContext(
|
||||
note,
|
||||
@@ -97,6 +101,12 @@ export async function submitFolderWorkspaceCreate({
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
activateAndRevealFolderWorkspace(workspace.id, startup ? { startup } : undefined)
|
||||
onOpenChange(false)
|
||||
try {
|
||||
activateAndRevealFolderWorkspace(workspace.id, startup ? { startup } : undefined)
|
||||
} catch (error) {
|
||||
// Why: creation already succeeded. Do not leave the completed create modal
|
||||
// open if the follow-up reveal/startup path hits a transient issue.
|
||||
console.error('Failed to activate folder workspace after create:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,12 @@ import type { ClaudeUsageRange, ClaudeUsageScope } from '../../../../shared/clau
|
||||
import { useAppStore } from '../../store'
|
||||
import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart'
|
||||
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
|
||||
import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable'
|
||||
import { ShareUsageButton } from './ShareUsageButton'
|
||||
import { StatCard } from './StatCard'
|
||||
import { UsagePaneFilterControls } from './UsagePaneFilterControls'
|
||||
import { UsageTrackingDisabledCard } from './UsageTrackingDisabledCard'
|
||||
import {
|
||||
formatCost,
|
||||
formatSessionTime,
|
||||
formatTokens,
|
||||
formatUpdatedAt
|
||||
} from './usage-display-formatting'
|
||||
import { formatCost, formatTokens, formatUpdatedAt } from './usage-display-formatting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
@@ -292,72 +288,7 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
</section>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
ClaudeUsageSessionRow,
|
||||
ClaudeUsageSummary
|
||||
} from '../../../../shared/claude-usage-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatSessionTime, formatTokens } from './usage-formatters'
|
||||
|
||||
export function ClaudeUsageRecentSessionsTable({
|
||||
recentSessions,
|
||||
summary
|
||||
}: {
|
||||
recentSessions: ClaudeUsageSessionRow[]
|
||||
summary: ClaudeUsageSummary | null
|
||||
}): 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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -4,16 +4,12 @@ import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-
|
||||
import { useAppStore } from '../../store'
|
||||
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
|
||||
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
|
||||
import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable'
|
||||
import { ShareUsageButton } from './ShareUsageButton'
|
||||
import { StatCard } from './StatCard'
|
||||
import { UsagePaneFilterControls } from './UsagePaneFilterControls'
|
||||
import { UsageTrackingDisabledCard } from './UsageTrackingDisabledCard'
|
||||
import {
|
||||
formatCost,
|
||||
formatSessionTime,
|
||||
formatTokens,
|
||||
formatUpdatedAt
|
||||
} from './usage-display-formatting'
|
||||
import { formatCost, formatTokens, formatUpdatedAt } from './usage-display-formatting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
@@ -269,73 +265,7 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
</section>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CodexUsageSessionRow } from '../../../../shared/codex-usage-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatSessionTime, formatTokens } from './usage-formatters'
|
||||
|
||||
export function CodexUsageRecentSessionsTable({
|
||||
recentSessions
|
||||
}: {
|
||||
recentSessions: CodexUsageSessionRow[]
|
||||
}): 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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -7,15 +7,11 @@ import type {
|
||||
import { useAppStore } from '../../store'
|
||||
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
|
||||
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
|
||||
import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable'
|
||||
import { StatCard } from './StatCard'
|
||||
import { UsagePaneFilterControls } from './UsagePaneFilterControls'
|
||||
import { UsageTrackingDisabledCard } from './UsageTrackingDisabledCard'
|
||||
import {
|
||||
formatCost,
|
||||
formatSessionTime,
|
||||
formatTokens,
|
||||
formatUpdatedAt
|
||||
} from './usage-display-formatting'
|
||||
import { formatCost, formatTokens, formatUpdatedAt } from './usage-display-formatting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
@@ -293,78 +289,7 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
</section>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { OpenCodeUsageSessionRow } from '../../../../shared/opencode-usage-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatSessionTime, formatTokens } from './usage-formatters'
|
||||
|
||||
export function OpenCodeUsageRecentSessionsTable({
|
||||
recentSessions
|
||||
}: {
|
||||
recentSessions: OpenCodeUsageSessionRow[]
|
||||
}): 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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function formatTokens(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}k`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatCost(value: number | null): string {
|
||||
if (value === null) {
|
||||
return 'n/a'
|
||||
}
|
||||
return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(timestamp: number | null): string {
|
||||
if (!timestamp) {
|
||||
return 'Not scanned yet'
|
||||
}
|
||||
return `Updated ${new Date(timestamp).toLocaleString()}`
|
||||
}
|
||||
|
||||
export 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'
|
||||
})
|
||||
}
|
||||
@@ -3085,7 +3085,6 @@
|
||||
"2f8298f3c3": "Stop scan",
|
||||
"c157f31a95": "Import as group",
|
||||
"40199ef7b3": "Group name",
|
||||
"b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
|
||||
"787412361a": "What is a group name?",
|
||||
"5f857ba8e6": "in",
|
||||
"4df0d08cc5": "Found",
|
||||
@@ -3094,11 +3093,10 @@
|
||||
"cf9d382ca1": "Import",
|
||||
"220dd32d83": "Scanning...",
|
||||
"fb33359f69": "Is this a monorepo?",
|
||||
"d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.",
|
||||
"39d51212cc": "Monorepo name",
|
||||
"e907ec8935": "What is a monorepo name?",
|
||||
"d75170194e": "Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder.",
|
||||
"39d51212cc": "Group name",
|
||||
"aa0247680d": "No, import separately",
|
||||
"a0bc4d1f8e": "Yes, import as monorepo",
|
||||
"a0bc4d1f8e": "Import as group",
|
||||
"8401a7a0d0": "1 repository",
|
||||
"d4f1df62ef": "{{value0}} repositories",
|
||||
"b4263a2ac4": "Found {{value0}} in {{value1}}.",
|
||||
@@ -3848,8 +3846,7 @@
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"create": "Create workspace",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
|
||||
@@ -3085,7 +3085,6 @@
|
||||
"2f8298f3c3": "Detener escaneo",
|
||||
"c157f31a95": "Importar como grupo",
|
||||
"40199ef7b3": "Nombre del grupo",
|
||||
"b20bb7c24f": "Mantiene estos repos juntos en un grupo. Lo mejor para repos relacionados como microservicios.",
|
||||
"787412361a": "¿Qué es el nombre de un grupo?",
|
||||
"5f857ba8e6": "en",
|
||||
"4df0d08cc5": "Encontró",
|
||||
@@ -3094,11 +3093,10 @@
|
||||
"cf9d382ca1": "Importar",
|
||||
"220dd32d83": "Exploración...",
|
||||
"fb33359f69": "¿Es esto un monorepo?",
|
||||
"d75170194e": "Elige esto si estos proyectos pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
|
||||
"39d51212cc": "Nombre del monorepo",
|
||||
"e907ec8935": "¿Qué es un nombre de monorepo?",
|
||||
"d75170194e": "Impórtalos como grupo si son un monorepo o si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
|
||||
"39d51212cc": "Nombre del grupo",
|
||||
"aa0247680d": "No, importar por separado",
|
||||
"a0bc4d1f8e": "Sí, importar como monorepo",
|
||||
"a0bc4d1f8e": "Importar como grupo",
|
||||
"8401a7a0d0": "1 repositorio",
|
||||
"d4f1df62ef": "{{value0}} repositorios",
|
||||
"b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.",
|
||||
@@ -3848,8 +3846,7 @@
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"create": "Create workspace",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3066,7 +3066,6 @@
|
||||
"2f8298f3c3": "스캔 중지",
|
||||
"c157f31a95": "그룹으로 가져오기",
|
||||
"40199ef7b3": "그룹 이름",
|
||||
"b20bb7c24f": "이러한 repos를 하나의 그룹에 함께 유지합니다. 마이크로서비스와 같은 관련 repos 에 가장 적합합니다.",
|
||||
"787412361a": "그룹 이름은 무엇입니까?",
|
||||
"5f857ba8e6": "~에",
|
||||
"4df0d08cc5": "발견됨",
|
||||
@@ -3075,11 +3074,10 @@
|
||||
"cf9d382ca1": "수입",
|
||||
"220dd32d83": "스캐닝...",
|
||||
"fb33359f69": "이 폴더가 모노레포인가요?",
|
||||
"d75170194e": "이 프로젝트들이 함께 속한다면 이것을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
|
||||
"39d51212cc": "모노레포 이름",
|
||||
"e907ec8935": "모노레포 이름이란 무엇인가요?",
|
||||
"d75170194e": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
|
||||
"39d51212cc": "그룹 이름",
|
||||
"aa0247680d": "아니요, 별도로 가져오기",
|
||||
"a0bc4d1f8e": "예, 모노레포로 가져오기",
|
||||
"a0bc4d1f8e": "그룹으로 가져오기",
|
||||
"8401a7a0d0": "저장소 1개",
|
||||
"d4f1df62ef": "저장소 {{value0}}개",
|
||||
"b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.",
|
||||
@@ -3598,7 +3596,7 @@
|
||||
"c1f4a31623": "{{value0}}개 하위 워크스페이스 표시",
|
||||
"e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기",
|
||||
"0cd15956d4": "{{value0}}개 하위 워크스페이스 숨기기",
|
||||
"bd37a57ac8": "Create workspace for {{value0}}",
|
||||
"bd37a57ac8": "{{value0}}에 대한 워크스페이스 만들기",
|
||||
"b667b59632": "Some projects could not be removed from Orca",
|
||||
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
|
||||
"groupDeleteFailed": "Failed to delete group",
|
||||
@@ -3848,8 +3846,7 @@
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"create": "워크스페이스 만들기",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
|
||||
@@ -3066,7 +3066,6 @@
|
||||
"2f8298f3c3": "停止扫描",
|
||||
"c157f31a95": "作为组导入",
|
||||
"40199ef7b3": "团体名称",
|
||||
"b20bb7c24f": "将这些 repos 放在一组中。最适合微服务等相关 repos。",
|
||||
"787412361a": "什么是群组名称?",
|
||||
"5f857ba8e6": "在",
|
||||
"4df0d08cc5": "成立",
|
||||
@@ -3075,11 +3074,10 @@
|
||||
"cf9d382ca1": "导入",
|
||||
"220dd32d83": "扫描...",
|
||||
"fb33359f69": "这是 Monorepo 吗?",
|
||||
"d75170194e": "如果这些项目属于同一组,请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。",
|
||||
"39d51212cc": "Monorepo 名称",
|
||||
"e907ec8935": "什么是 Monorepo 名称?",
|
||||
"d75170194e": "如果它们是 Monorepo,或本来就应该放在一起,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。",
|
||||
"39d51212cc": "组名称",
|
||||
"aa0247680d": "否,单独导入",
|
||||
"a0bc4d1f8e": "是,作为 Monorepo 导入",
|
||||
"a0bc4d1f8e": "作为组导入",
|
||||
"8401a7a0d0": "1 个仓库",
|
||||
"d4f1df62ef": "{{value0}} 个仓库",
|
||||
"b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。",
|
||||
@@ -3598,7 +3596,7 @@
|
||||
"c1f4a31623": "显示 {{value0}} 个子工作区",
|
||||
"e97297cb75": "隐藏 {{value0}} 个子工作区",
|
||||
"0cd15956d4": "隐藏 {{value0}} 个子工作区",
|
||||
"bd37a57ac8": "Create workspace for {{value0}}",
|
||||
"bd37a57ac8": "为 {{value0}} 创建工作区",
|
||||
"b667b59632": "Some projects could not be removed from Orca",
|
||||
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
|
||||
"groupDeleteFailed": "Failed to delete group",
|
||||
@@ -3848,8 +3846,7 @@
|
||||
"connectFailed": "Failed to connect to project.",
|
||||
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
|
||||
"title": "Create Folder Workspace",
|
||||
"createStart": "Create & Start Agent",
|
||||
"create": "Create Workspace",
|
||||
"create": "创建工作区",
|
||||
"sourceProject": "Task Source",
|
||||
"chooseSourceProject": "Choose task source"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user