Continue host context checklist implementation

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 18:55:55 -07:00
co-authored by Orca
parent 293f84546c
commit 762f42a4e4
18 changed files with 843 additions and 559 deletions
+12 -6
View File
@@ -23,7 +23,7 @@ The target model is:
- [~] Audit user-facing copy so the product says **Host** for Local Mac, SSH hosts, remote servers, and future cloud VMs, while preserving technical labels only where useful.
- [x] Document the invariant that task source context and run host are separate choices.
- [~] Add compatibility adapters for existing repo-id/path-based callers so old data can be read while new code moves to explicit contexts.
- [~] Add schema migration notes for old automations, cached task selections, pending worktree requests, and any persisted task drawer state.
- [x] Add schema migration notes for old automations, cached task selections, pending worktree requests, and any persisted task drawer state.
Done means: a new agent can read the shared types and understand exactly where work item data comes from versus where execution happens.
@@ -99,7 +99,7 @@ Done means: settings pages no longer imply one global provider/account when the
- [~] Ensure request ownership is explicit for provider fetches, provider mutations, file-system calls, git calls, terminal calls, and automation runs.
- [~] Add capability negotiation for remote servers so new clients can degrade gracefully with older servers.
- [~] Add version-skew UX for unsupported source-context or host-context APIs.
- [~] Ensure SSH hosts and remote servers expose comparable availability/health states to the UI.
- [x] Ensure SSH hosts and remote servers expose comparable availability/health states to the UI.
- [x] Keep future cloud VM support as a host capability model, not a separate user-facing mental model.
Done means: no background request can accidentally use the wrong host because it fell back to global state.
@@ -107,9 +107,9 @@ Done means: no background request can accidentally use the wrong host because it
## 8. Sidebar And Project Host UX
- [x] Verify sidebar only shows host entries for projects that actually exist on those hosts.
- [~] Verify disconnected hosts are visually disabled and not selectable when selection would fail.
- [x] Verify disconnected hosts are visually disabled and not selectable when selection would fail.
- [x] Keep local-only users in the low-jarring path: no unnecessary host chrome when only one local host exists.
- [~] Preserve workspace drag/reorder behavior and host-section drag behavior after project-first host changes.
- [x] Preserve workspace drag/reorder behavior and host-section drag behavior after project-first host changes.
- [x] Verify collapsed/expanded host behavior is stable while dragging.
- [x] Ensure host labels, counts, and status indicators are visually calm and aligned with docs/STYLEGUIDE.md.
@@ -119,7 +119,7 @@ Done means: the sidebar reflects project availability, not every configured SSH
- [~] Add CLI selectors for logical project, project host setup, source host/context, and run host.
- [x] Keep SSH and remote server behavior represented as host capabilities rather than separate product concepts.
- [ ] Ensure remote server mode can still support users who want the desktop client to behave like “the server” when explicitly configured.
- [x] Ensure remote server mode can still support users who want the desktop client to behave like “the server” when explicitly configured.
- [x] Ensure SSH remains the lightweight “execute on this machine over SSH” path.
- [~] Ensure remote server remains the durable runtime path for web/mobile handoff, background work, and richer server-owned state.
- [x] Note cloud VM provisioning as future host onboarding/provisioning work, not a requirement for this PR.
@@ -128,7 +128,7 @@ Done means: Local, SSH, remote server, and future cloud VMs share one Host model
## 10. Verification Matrix
- [~] Unit-test source-context serialization, cache keys, and provider routing.
- [x] Unit-test source-context serialization, cache keys, and provider routing.
- [x] Unit-test automation migration from repo-id/path-based records.
- [x] Unit-test workspace creation selectors for logical project plus host setup.
- [ ] Integration-test local project with local provider credentials.
@@ -220,3 +220,9 @@ Done means: reviewers and future agents can understand the vision, implementatio
- [x] 2026-06-12: Added `taskSourceContext` and `workspaceRunContext` to retryable pending worktree creation requests, seeded them from the Composer's selected task source and project-host setup, and verified the pending store preserves both identities with focused worktree tests plus web typecheck.
- [x] 2026-06-12: Updated PR #5071 with the current verified scope, evidence docs, validation commands, explicit out-of-scope cloud/onboarding items, and remaining provider/version-skew risk.
- [x] 2026-06-12: Added load-time backfill for legacy automations and automation runs that predate `runContext`/`sourceContext`, deriving explicit project host and provider source contexts from the saved repo id/path; verified with focused persistence migration coverage, automation service/precheck tests, daemon/settings merge-repair tests, and full `pnpm run typecheck`.
- [x] 2026-06-12: Repaired the merge-repair lint gate without disabling max-lines by extracting automation run-target/usage modules and settings subcomponents, fixed stale source-context hook dependencies, and tightened GitHub PR checks cache ownership so `noCache` bypasses cache/in-flight requests while head-specific checks are reused during refresh events; verified targeted oxlint, focused automation/settings/GitHub cache tests (10 files / 157 tests), `github-checks-cache.test.ts`, `git diff --check`, and full `pnpm run typecheck`.
- [x] 2026-06-12: Preserved the explicit remote-server focus path as Settings → Active Server, with copy explaining that selecting a saved server makes it the default Host for server-routed projects, files, terminals, provider accounts, and browser/mobile handoff rather than introducing a separate Focused Host Mode; verified with `RuntimeEnvironmentsPane.test.ts`.
- [x] 2026-06-12: Added persisted context migration notes to `notes/project-first-host-model.md` covering old automations/runs, legacy `Automation.projectId`, cached task selections, retryable pending workspace creation requests, and task drawer/navigation replay state.
- [x] 2026-06-12: Completed source-context unit coverage by adding GitLab/Linear/Jira provider identity cache-scope serialization tests and rerunning shared source-context, GitHub source/cache routing, UI open-task, and task-navigation history suites (6 files / 261 tests) plus targeted oxlint.
- [x] 2026-06-12: Reconciled Add Project host-selection tests with the disabled-host UX: disconnected SSH hosts and blocked runtime hosts remain visible but disabled, the hook falls back to Local Mac instead of selecting a disconnected SSH host, and selection handlers ignore unavailable hosts; verified Add Project host selector/selection/dialog, sidebar host options, and execution-host registry tests (5 files / 34 tests) plus targeted oxlint.
- [x] 2026-06-12: Re-verified project-first drag/reorder behavior across workspace manual order, drag units, workspace-board pointer drops, host-section order/rows, repo reorder host splitting, and project-header drag helpers; 9 files / 62 tests passed plus targeted oxlint.
+24
View File
@@ -890,6 +890,30 @@ Landed so far:
project-plus-host resolution, same-name non-merging, and unavailable setup
reasons.
Persisted context migration notes:
- Existing automations and automation runs are upgraded on load when they do not
yet contain `runContext` or `sourceContext`. The migration derives a
`WorkspaceRunContext` from the saved repo/project-host setup and derives a
`TaskSourceContext` from the repo provider identity when available. Missing
provider identity is preserved as `null` rather than guessed.
- `Automation.projectId` remains accepted as the legacy repo-id compatibility
field during this transition. New dispatch/precheck paths prefer the explicit
run context; older records continue to load and run through the backfilled
context or return a clear unavailable-target error.
- Pending workspace creation requests now persist both `taskSourceContext` and
`workspaceRunContext`. Older pending requests without those fields continue
through the repo-id fallback path; newer retries preserve where the task came
from separately from where the workspace should be created.
- Cached GitHub task selections and task-detail navigation entries include the
task source context in their cache/history keys when present. Legacy entries
without source context are kept under an explicit `legacy` scope so old drawer
history can still reopen without colliding with source-scoped entries.
- Task drawer state remains renderer-session state rather than a durable disk
migration surface. The important persisted boundary is the navigation/history
and pending-workspace handoff state that can replay an item after refresh or
retry; those now carry source context when the caller has it.
Important limitation:
- This is not the full migration yet. `Repo` remains the source of truth for the
@@ -0,0 +1,82 @@
import type { Store } from '../persistence'
import type { Automation } from '../../shared/automations-types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { ProjectHostSetup, Repo } from '../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
export type AutomationRunTargetResult =
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
| { ok: false; error: string }
function getLegacyPrecheckCwd(store: Store, automation: Automation): string | null {
if (automation.workspaceMode === 'existing') {
const parsed = automation.workspaceId
? splitWorktreeIdForFilesystem(automation.workspaceId)
: null
return parsed?.worktreePath ?? null
}
return store.getRepo(automation.projectId)?.path ?? null
}
export function resolveAutomationRunTarget(
store: Store,
automation: Automation
): AutomationRunTargetResult {
const context = automation.runContext ?? null
if (!context) {
const repo = store.getRepo(automation.projectId)
const cwd = getLegacyPrecheckCwd(store, automation)
if (!repo || !cwd) {
return { ok: false, error: 'Automation run target is no longer available.' }
}
return { ok: true, cwd, repo }
}
const setup = store
.getProjectHostSetups()
.find((candidate) => candidate.id === context.projectHostSetupId)
if (!setup) {
return {
ok: false,
error: 'Project is not set up on the selected automation host anymore.'
}
}
if (setup.setupState !== 'ready') {
return {
ok: false,
error: `Project setup on the selected automation host is ${setup.setupState}.`
}
}
if (
setup.projectId !== context.projectId ||
setup.hostId !== context.hostId ||
setup.repoId !== context.repoId
) {
return {
ok: false,
error: 'Automation run target no longer matches the selected project host setup.'
}
}
const repo = store.getRepo(context.repoId)
if (!repo) {
return {
ok: false,
error: 'Repository for the selected automation host is no longer available.'
}
}
if (getRepoExecutionHostId(repo) !== context.hostId) {
return {
ok: false,
error: 'Repository is no longer attached to the selected automation host.'
}
}
if (repo.path !== setup.path || context.path !== setup.path) {
return {
ok: false,
error: 'Project path for the selected automation host has changed.'
}
}
return { ok: true, cwd: setup.path, repo, setup }
}
@@ -0,0 +1,99 @@
import type { Automation, AutomationRun, AutomationRunUsage } from '../../shared/automations-types'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
function createUnavailableAutomationUsage(
collectedAt: number,
provider: AutomationRunUsage['provider'],
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage {
return {
status: 'unavailable',
provider,
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
}
}
function getAutomationUsageProvider(
automation: Automation | undefined
): AutomationRunUsage['provider'] {
if (automation?.agentId === 'codex') {
return 'codex'
}
if (automation?.agentId === 'claude') {
return 'claude'
}
return null
}
export async function collectAutomationRunUsage({
automation,
run,
claudeUsage,
codexUsage
}: {
automation: Automation | undefined
run: AutomationRun
claudeUsage: ClaudeUsageStore | null
codexUsage: CodexUsageStore | null
}): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
provider: AutomationRunUsage['provider'],
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage =>
createUnavailableAutomationUsage(collectedAt, provider, unavailableReason, unavailableMessage)
if (!automation || run.status !== 'completed') {
return unavailable(
getAutomationUsageProvider(automation),
'run_not_finished',
'Usage is only collected for completed automation runs.'
)
}
if (automation.executionTargetType === 'ssh') {
return unavailable(
getAutomationUsageProvider(automation),
'remote_usage_unavailable',
'Remote automation usage is not available from local usage logs.'
)
}
if (automation.agentId === 'claude') {
if (!claudeUsage) {
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
}
return claudeUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
if (automation.agentId === 'codex') {
if (!codexUsage) {
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
}
return codexUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.')
}
+11 -159
View File
@@ -6,15 +6,13 @@ import type {
AutomationDispatchResult,
AutomationPrecheckResult,
AutomationRun,
AutomationRunStatus,
AutomationRunUsage
AutomationRunStatus
} from '../../shared/automations-types'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { runAutomationPrecheck } from './precheck-runner'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { ProjectHostSetup, Repo } from '../../shared/types'
import { resolveAutomationRunTarget } from './run-target-resolution'
import { collectAutomationRunUsage } from './run-usage-collection'
const DEFAULT_TICK_MS = 60 * 1000
@@ -89,7 +87,7 @@ export class AutomationService {
if (run.trigger !== 'scheduled' || !automation.precheck) {
return null
}
const target = this.resolveRunTarget(automation)
const target = resolveAutomationRunTarget(this.store, automation)
if (!target.ok) {
return {
command: automation.precheck.command,
@@ -126,7 +124,12 @@ export class AutomationService {
if (run.usage) {
return run
}
const usage = await this.collectRunUsage(run)
const usage = await collectAutomationRunUsage({
automation: this.store.listAutomations().find((entry) => entry.id === run.automationId),
run,
claudeUsage: this.claudeUsage,
codexUsage: this.codexUsage
})
return this.store.updateAutomationRun({
runId: run.id,
status: run.status,
@@ -137,83 +140,6 @@ export class AutomationService {
})
}
private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> {
const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId)
const collectedAt = Date.now()
const unavailable = (
provider: AutomationRunUsage['provider'],
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider,
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
})
if (!automation || run.status !== 'completed') {
return unavailable(
automation?.agentId === 'codex'
? 'codex'
: automation?.agentId === 'claude'
? 'claude'
: null,
'run_not_finished',
'Usage is only collected for completed automation runs.'
)
}
if (automation.executionTargetType === 'ssh') {
return unavailable(
automation.agentId === 'codex'
? 'codex'
: automation.agentId === 'claude'
? 'claude'
: null,
'remote_usage_unavailable',
'Remote automation usage is not available from local usage logs.'
)
}
if (automation.agentId === 'claude') {
if (!this.claudeUsage) {
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
}
return this.claudeUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
if (automation.agentId === 'codex') {
if (!this.codexUsage) {
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
}
return this.codexUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
return unavailable(
null,
'provider_unsupported',
'This agent does not report usage to Orca yet.'
)
}
private async evaluateDueRuns(): Promise<void> {
if (this.evaluating) {
return
@@ -232,80 +158,6 @@ export class AutomationService {
}
}
private getLegacyPrecheckCwd(automation: Automation): string | null {
if (automation.workspaceMode === 'existing') {
const parsed = automation.workspaceId
? splitWorktreeIdForFilesystem(automation.workspaceId)
: null
return parsed?.worktreePath ?? null
}
return this.store.getRepo(automation.projectId)?.path ?? null
}
private resolveRunTarget(
automation: Automation
):
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
| { ok: false; error: string } {
const context = automation.runContext ?? null
if (!context) {
const repo = this.store.getRepo(automation.projectId)
const cwd = this.getLegacyPrecheckCwd(automation)
if (!repo || !cwd) {
return { ok: false, error: 'Automation run target is no longer available.' }
}
return { ok: true, cwd, repo }
}
const setup = this.store
.getProjectHostSetups()
.find((candidate) => candidate.id === context.projectHostSetupId)
if (!setup) {
return {
ok: false,
error: 'Project is not set up on the selected automation host anymore.'
}
}
if (setup.setupState !== 'ready') {
return {
ok: false,
error: `Project setup on the selected automation host is ${setup.setupState}.`
}
}
if (
setup.projectId !== context.projectId ||
setup.hostId !== context.hostId ||
setup.repoId !== context.repoId
) {
return {
ok: false,
error: 'Automation run target no longer matches the selected project host setup.'
}
}
const repo = this.store.getRepo(context.repoId)
if (!repo) {
return {
ok: false,
error: 'Repository for the selected automation host is no longer available.'
}
}
if (getRepoExecutionHostId(repo) !== context.hostId) {
return {
ok: false,
error: 'Repository is no longer attached to the selected automation host.'
}
}
if (repo.path !== setup.path || context.path !== setup.path) {
return {
ok: false,
error: 'Project path for the selected automation host has changed.'
}
}
return { ok: true, cwd: setup.path, repo, setup }
}
private async evaluateAutomation(automation: Automation, now: number): Promise<void> {
const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now)
if (scheduledFor === null) {
@@ -333,7 +185,7 @@ export class AutomationService {
automation: Automation,
run: AutomationRun
): Promise<AutomationRun> {
const target = this.resolveRunTarget(automation)
const target = resolveAutomationRunTarget(this.store, automation)
if (!target.ok) {
return this.store.updateAutomationRun({
runId: run.id,
@@ -1903,7 +1903,7 @@ function PRFilesCombinedDiffViewer({
)
})
},
[baseSha, fileByPath, headSha, prNumber, repoId, repoPath]
[baseSha, fileByPath, headSha, prNumber, repoId, repoPath, sourceContext]
)
const retrySection = useCallback(
@@ -40,10 +40,7 @@ import type {
GitLabWorkItemDetails,
MRComment
} from '../../../shared/types'
import {
getTaskSourceCacheScope,
type TaskSourceContext
} from '../../../shared/task-source-context'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
type Props = {
@@ -365,7 +362,6 @@ export default function GitLabItemDialog({
const [retryingJobId, setRetryingJobId] = useState<number | null>(null)
const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null)
const mountedRef = useMountedRef()
const sourceContextKey = sourceContext ? getTaskSourceCacheScope(sourceContext) : ''
const repoSelector = useMemo<GitLabDialogRepoSelector | null>(() => {
if (!repoPath) {
return null
@@ -375,7 +371,7 @@ export default function GitLabItemDialog({
...(repoId ? { repoId } : {}),
...(sourceContext ? { sourceContext } : {})
}
}, [repoId, repoPath, sourceContext, sourceContextKey])
}, [repoId, repoPath, sourceContext])
const updateCommentDraft = useCallback(
(value: string): void => {
setCommentDraftState({ itemId, value })
@@ -26,6 +26,7 @@ import { SourceControlAiActionRecipeDefaults } from './SourceControlAiActionReci
import { matchesSettingsSearch } from './settings-search'
import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
import { HostedReviewCreationDefaults } from './HostedReviewCreationDefaults'
type CommitMessageAiPaneProps = {
settings: GlobalSettings
@@ -323,113 +324,12 @@ export function CommitMessageAiPane({
})
) {
const prDefaults = config.prCreationDefaults ?? {}
const rows: {
key: keyof NonNullable<SourceControlAiSettings['prCreationDefaults']>
label: string
description: string
}[] = [
{
key: 'draft',
label: translate(
'auto.components.settings.CommitMessageAiPane.6ba48f07a4',
'Draft by default'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.e001734396',
'Create hosted reviews as drafts unless changed in the composer.'
)
},
{
key: 'useTemplate',
label: translate(
'auto.components.settings.CommitMessageAiPane.d8b6764d79',
'Use review template when available'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.6278c0ce43',
'Prefer repository pull request templates when no description is set.'
)
},
{
key: 'generateDetailsOnOpen',
label: translate(
'auto.components.settings.CommitMessageAiPane.d5f0de6309',
'Generate details when opening Create PR'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.b27b0809f3',
'Run hosted-review detail generation once when the composer opens.'
)
},
{
key: 'openAfterCreate',
label: translate(
'auto.components.settings.CommitMessageAiPane.7662715213',
'Open hosted review after creation'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.b125eabffa',
'Open the created hosted review in your browser after submit.'
)
}
]
sections.push(
<SearchableSetting
<HostedReviewCreationDefaults
key="pr-creation-defaults"
title={translate(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
)}
description={translate(
'auto.components.settings.CommitMessageAiPane.e9d46a544d',
'Defaults used when the hosted-review composer opens.'
)}
keywords={[
'hosted review',
'pull request',
'merge request',
'pr',
'draft',
'template',
'generate',
'open'
]}
className="space-y-3 px-1 py-2"
>
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.CommitMessageAiPane.347094560b',
'Used by repositories that inherit global hosted-review defaults.'
)}
</p>
</div>
<div className="space-y-2">
{rows.map((row) => (
<label
key={row.key}
className="flex items-start justify-between gap-4 rounded-md border border-border px-3 py-2"
>
<span className="space-y-0.5">
<span className="block text-xs font-medium text-foreground">{row.label}</span>
<span className="block text-[11px] text-muted-foreground">{row.description}</span>
</span>
<input
type="checkbox"
checked={prDefaults[row.key] === true}
onChange={(event) => onPrDefaultChange(row.key, event.target.checked)}
className="mt-0.5 size-4 rounded border-border accent-primary"
/>
</label>
))}
</div>
</SearchableSetting>
prDefaults={prDefaults}
onPrDefaultChange={onPrDefaultChange}
/>
)
}
@@ -0,0 +1,128 @@
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
import { translate } from '@/i18n/i18n'
type HostedReviewDefaultKey = keyof NonNullable<SourceControlAiSettings['prCreationDefaults']>
const KEYWORDS = [
'hosted review',
'pull request',
'merge request',
'pr',
'draft',
'template',
'generate',
'open'
]
function getHostedReviewDefaultRows(): {
key: HostedReviewDefaultKey
label: string
description: string
}[] {
return [
{
key: 'draft',
label: translate(
'auto.components.settings.CommitMessageAiPane.6ba48f07a4',
'Draft by default'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.e001734396',
'Create hosted reviews as drafts unless changed in the composer.'
)
},
{
key: 'useTemplate',
label: translate(
'auto.components.settings.CommitMessageAiPane.d8b6764d79',
'Use review template when available'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.6278c0ce43',
'Prefer repository pull request templates when no description is set.'
)
},
{
key: 'generateDetailsOnOpen',
label: translate(
'auto.components.settings.CommitMessageAiPane.d5f0de6309',
'Generate details when opening Create PR'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.b27b0809f3',
'Run hosted-review detail generation once when the composer opens.'
)
},
{
key: 'openAfterCreate',
label: translate(
'auto.components.settings.CommitMessageAiPane.7662715213',
'Open hosted review after creation'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.b125eabffa',
'Open the created hosted review in your browser after submit.'
)
}
]
}
export function HostedReviewCreationDefaults({
prDefaults,
onPrDefaultChange
}: {
prDefaults: NonNullable<SourceControlAiSettings['prCreationDefaults']>
onPrDefaultChange: (key: HostedReviewDefaultKey, value: boolean) => void
}): React.JSX.Element {
return (
<SearchableSetting
key="pr-creation-defaults"
title={translate(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
)}
description={translate(
'auto.components.settings.CommitMessageAiPane.e9d46a544d',
'Defaults used when the hosted-review composer opens.'
)}
keywords={KEYWORDS}
className="space-y-3 px-1 py-2"
>
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.CommitMessageAiPane.347094560b',
'Used by repositories that inherit global hosted-review defaults.'
)}
</p>
</div>
<div className="space-y-2">
{getHostedReviewDefaultRows().map((row) => (
<label
key={row.key}
className="flex items-start justify-between gap-4 rounded-md border border-border px-3 py-2"
>
<span className="space-y-0.5">
<span className="block text-xs font-medium text-foreground">{row.label}</span>
<span className="block text-[11px] text-muted-foreground">{row.description}</span>
</span>
<input
type="checkbox"
checked={prDefaults[row.key] === true}
onChange={(event) => onPrDefaultChange(row.key, event.target.checked)}
className="mt-0.5 size-4 rounded border-border accent-primary"
/>
</label>
))}
</div>
</SearchableSetting>
)
}
@@ -0,0 +1,163 @@
import { Pencil, Trash2 } from 'lucide-react'
import type {
Repo,
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import {
getTerminalQuickCommandBody,
getTerminalQuickCommandScope,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { RepoBadgeMark } from '../repo/RepoBadgeLabel'
import { getQuickCommandRepoLabel } from './QuickCommandsScopeFilter'
function getScopeLabel(
scope: TerminalQuickCommandScope,
repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
): string {
if (scope.type === 'global') {
return 'Global'
}
const repo = repoById.get(scope.repoId)
return repo ? getQuickCommandRepoLabel(repo) : 'Missing project'
}
function QuickCommandRow({
command,
repoById,
onEdit,
onRemove
}: {
command: TerminalQuickCommand
repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
onEdit: (command: TerminalQuickCommand) => void
onRemove: (command: TerminalQuickCommand) => void
}): React.JSX.Element {
const scope = getTerminalQuickCommandScope(command)
return (
<div className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 shadow-xs">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{command.label ||
translate('auto.components.settings.QuickCommandsPane.2bb9e38e93', 'Untitled')}
</div>
<Badge variant="outline" className="max-w-44 gap-1.5">
{scope.type === 'repo' ? (
<>
<RepoBadgeMark color={repoById.get(scope.repoId)?.badgeColor} />
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
</>
) : (
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
)}
</Badge>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground/80">
{isTerminalAgentQuickCommand(command) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : null}
<span className={cn('truncate', isTerminalAgentQuickCommand(command) ? '' : 'font-mono')}>
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}`
: getTerminalQuickCommandBody(command) ||
translate(
'auto.components.settings.QuickCommandsPane.0252ddd578',
'No command text'
)}
</span>
</div>
</div>
<div className="shrink-0 text-[11px] font-medium text-foreground/75">
{isTerminalAgentQuickCommand(command)
? translate('auto.components.settings.QuickCommandsPane.4ccc63da87', 'Agent')
: command.appendEnter
? translate('auto.components.settings.QuickCommandsPane.9b3e338d62', 'Enter')
: translate('auto.components.settings.QuickCommandsPane.9fcfc29519', 'Insert')}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.settings.QuickCommandsPane.7d90fd5299',
'Edit {{value0}}',
{
value0: command.label || 'quick command'
}
)}
onClick={() => onEdit(command)}
>
<Pencil />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.settings.QuickCommandsPane.8764c6e9e4',
'Remove {{value0}}',
{
value0: command.label || 'quick command'
}
)}
onClick={() => onRemove(command)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</div>
)
}
export function QuickCommandsList({
commands,
visibleCommands,
repoById,
onEdit,
onRemove
}: {
commands: TerminalQuickCommand[]
visibleCommands: TerminalQuickCommand[]
repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
onEdit: (command: TerminalQuickCommand) => void
onRemove: (command: TerminalQuickCommand) => void
}): React.JSX.Element {
return (
<div className="overflow-hidden rounded-lg border border-border/50 bg-muted/20">
{visibleCommands.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{commands.length === 0
? translate(
'auto.components.settings.QuickCommandsPane.38d61927e6',
'No quick commands saved.'
)
: translate(
'auto.components.settings.QuickCommandsPane.3eb9897ab0',
'No commands in the selected scopes.'
)}
</div>
) : (
<div className="max-h-[60vh] space-y-2 overflow-y-auto p-2 scrollbar-sleek">
{visibleCommands.map((command) => (
<QuickCommandRow
key={command.id}
command={command}
repoById={repoById}
onEdit={onEdit}
onRemove={onRemove}
/>
))}
</div>
)}
</div>
)
}
@@ -1,32 +1,19 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { Check, ChevronsUpDown, Pencil, Plus, Trash2 } from 'lucide-react'
import type {
GlobalSettings,
Repo,
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import {
getTerminalQuickCommandBody,
getTerminalQuickCommandScope,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import { Plus } from 'lucide-react'
import type { GlobalSettings, TerminalQuickCommand } from '../../../../shared/types'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
import {
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
import { useAppStore } from '../../store'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Command, CommandItem, CommandList } from '../ui/command'
import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import RepoBadgeLabel, { RepoBadgeMark } from '../repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
import { QuickCommandsList } from './QuickCommandsList'
import { GLOBAL_SCOPE_KEY, QuickCommandsScopeFilter } from './QuickCommandsScopeFilter'
type QuickCommandsPaneProps = {
settings: GlobalSettings
@@ -34,8 +21,6 @@ type QuickCommandsPaneProps = {
addCommandIntentSignal?: number
}
const GLOBAL_SCOPE_KEY = '__global__'
type EditorState =
| {
mode: 'add'
@@ -47,10 +32,6 @@ type EditorState =
}
| null
function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
return repo.displayName || repo.path
}
export function shouldOpenQuickCommandAddIntent(
addCommandIntentSignal: number | undefined,
consumedAddIntentSignal: number
@@ -58,17 +39,6 @@ export function shouldOpenQuickCommandAddIntent(
return Boolean(addCommandIntentSignal && consumedAddIntentSignal !== addCommandIntentSignal)
}
function getScopeLabel(
scope: TerminalQuickCommandScope,
repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
): string {
if (scope.type === 'global') {
return 'Global'
}
const repo = repoById.get(scope.repoId)
return repo ? getRepoLabel(repo) : 'Missing project'
}
export function QuickCommandsPane({
settings,
updateSettings,
@@ -164,32 +134,6 @@ export function QuickCommandsPane({
setScopeSelection(null)
}
const renderTriggerLabel = (): React.JSX.Element => {
if (showAll) {
return (
<span>
{translate('auto.components.settings.QuickCommandsPane.c6b155911b', 'All commands')}
</span>
)
}
const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY)
const selectedRepos = repos.filter((r) => effectiveSelection.has(r.id))
const parts: string[] = []
if (includesGlobal) {
parts.push('Global')
}
if (selectedRepos.length > 0) {
const [first, ...rest] = selectedRepos
parts.push(rest.length > 0 ? `${first.displayName} +${rest.length}` : first.displayName)
}
return (
<span className="truncate">
{parts.join(', ') ||
translate('auto.components.settings.QuickCommandsPane.d1d0976320', 'None')}
</span>
)
}
const saveCommand = (next: TerminalQuickCommand): void => {
// Why: re-read from the store so save lands on the latest list when
// multiple edit dialogs fire in quick succession.
@@ -248,204 +192,23 @@ export function QuickCommandsPane({
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={scopePopoverOpen}
className="h-8 min-w-52 justify-between px-3 text-xs font-normal"
>
{renderTriggerLabel()}
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<div className="border-b border-border">
<button
type="button"
onClick={handleSelectAll}
onMouseDown={(event) => event.preventDefault()}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
showAll && 'opacity-80'
)}
>
<Check
className={cn(
'size-3 text-muted-foreground',
showAll ? 'opacity-70' : 'opacity-0'
)}
/>
<span>
{translate(
'auto.components.settings.QuickCommandsPane.c6b155911b',
'All commands'
)}
</span>
</button>
</div>
<CommandList>
<CommandItem
value={GLOBAL_SCOPE_KEY}
onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0'
)}
/>
<span>
{translate('auto.components.settings.QuickCommandsPane.8c877dec41', 'Global')}
</span>
</CommandItem>
{repos.map((repo) => {
const isSelected = effectiveSelection.has(repo.id)
return (
<CommandItem
key={repo.id}
value={repo.id}
onSelect={() => toggleScope(repo.id)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
isSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<RepoBadgeLabel
name={getRepoLabel(repo)}
color={repo.badgeColor}
className="max-w-full"
/>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<QuickCommandsScopeFilter
repos={repos}
effectiveSelection={effectiveSelection}
showAll={showAll}
scopePopoverOpen={scopePopoverOpen}
setScopePopoverOpen={setScopePopoverOpen}
handleSelectAll={handleSelectAll}
toggleScope={toggleScope}
/>
<div className="overflow-hidden rounded-lg border border-border/50 bg-muted/20">
{visibleCommands.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{commands.length === 0
? translate(
'auto.components.settings.QuickCommandsPane.38d61927e6',
'No quick commands saved.'
)
: translate(
'auto.components.settings.QuickCommandsPane.3eb9897ab0',
'No commands in the selected scopes.'
)}
</div>
) : (
<div className="max-h-[60vh] space-y-2 overflow-y-auto p-2 scrollbar-sleek">
{visibleCommands.map((command) => {
const scope = getTerminalQuickCommandScope(command)
return (
<div
key={command.id}
className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 shadow-xs"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{command.label ||
translate(
'auto.components.settings.QuickCommandsPane.2bb9e38e93',
'Untitled'
)}
</div>
<Badge variant="outline" className="max-w-44 gap-1.5">
{scope.type === 'repo' ? (
<>
<RepoBadgeMark color={repoById.get(scope.repoId)?.badgeColor} />
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
</>
) : (
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
)}
</Badge>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground/80">
{isTerminalAgentQuickCommand(command) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : null}
<span
className={cn(
'truncate',
isTerminalAgentQuickCommand(command) ? '' : 'font-mono'
)}
>
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}`
: getTerminalQuickCommandBody(command) ||
translate(
'auto.components.settings.QuickCommandsPane.0252ddd578',
'No command text'
)}
</span>
</div>
</div>
<div className="shrink-0 text-[11px] font-medium text-foreground/75">
{isTerminalAgentQuickCommand(command)
? translate('auto.components.settings.QuickCommandsPane.4ccc63da87', 'Agent')
: command.appendEnter
? translate(
'auto.components.settings.QuickCommandsPane.9b3e338d62',
'Enter'
)
: translate(
'auto.components.settings.QuickCommandsPane.9fcfc29519',
'Insert'
)}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.settings.QuickCommandsPane.7d90fd5299',
'Edit {{value0}}',
{ value0: command.label || 'quick command' }
)}
onClick={() => setEditor({ mode: 'edit', command })}
>
<Pencil />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.settings.QuickCommandsPane.8764c6e9e4',
'Remove {{value0}}',
{ value0: command.label || 'quick command' }
)}
onClick={() => void removeCommand(command)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</div>
)
})}
</div>
)}
</div>
<QuickCommandsList
commands={commands}
visibleCommands={visibleCommands}
repoById={repoById}
onEdit={(command) => setEditor({ mode: 'edit', command })}
onRemove={(command) => void removeCommand(command)}
/>
{editor !== null ? (
<TerminalQuickCommandDialog
@@ -0,0 +1,161 @@
import { Check, ChevronsUpDown } from 'lucide-react'
import type { Dispatch, SetStateAction } from 'react'
import type { Repo } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Command, CommandItem, CommandList } from '../ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import RepoBadgeLabel from '../repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
export const GLOBAL_SCOPE_KEY = '__global__'
export function getQuickCommandRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
return repo.displayName || repo.path
}
function ScopeTriggerLabel({
showAll,
effectiveSelection,
repos
}: {
showAll: boolean
effectiveSelection: ReadonlySet<string>
repos: Repo[]
}): React.JSX.Element {
if (showAll) {
return (
<span>
{translate('auto.components.settings.QuickCommandsPane.c6b155911b', 'All commands')}
</span>
)
}
const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY)
const selectedRepos = repos.filter((repo) => effectiveSelection.has(repo.id))
const parts: string[] = []
if (includesGlobal) {
parts.push('Global')
}
if (selectedRepos.length > 0) {
const [first, ...rest] = selectedRepos
parts.push(rest.length > 0 ? `${first.displayName} +${rest.length}` : first.displayName)
}
return (
<span className="truncate">
{parts.join(', ') ||
translate('auto.components.settings.QuickCommandsPane.d1d0976320', 'None')}
</span>
)
}
export function QuickCommandsScopeFilter({
repos,
effectiveSelection,
showAll,
scopePopoverOpen,
setScopePopoverOpen,
handleSelectAll,
toggleScope
}: {
repos: Repo[]
effectiveSelection: ReadonlySet<string>
showAll: boolean
scopePopoverOpen: boolean
setScopePopoverOpen: Dispatch<SetStateAction<boolean>>
handleSelectAll: () => void
toggleScope: (key: string) => void
}): React.JSX.Element {
return (
<div className="flex flex-wrap items-center gap-2">
<Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={scopePopoverOpen}
className="h-8 min-w-52 justify-between px-3 text-xs font-normal"
>
<ScopeTriggerLabel
showAll={showAll}
effectiveSelection={effectiveSelection}
repos={repos}
/>
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<div className="border-b border-border">
<button
type="button"
onClick={handleSelectAll}
onMouseDown={(event) => event.preventDefault()}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
showAll && 'opacity-80'
)}
>
<Check
className={cn(
'size-3 text-muted-foreground',
showAll ? 'opacity-70' : 'opacity-0'
)}
/>
<span>
{translate(
'auto.components.settings.QuickCommandsPane.c6b155911b',
'All commands'
)}
</span>
</button>
</div>
<CommandList>
<CommandItem
value={GLOBAL_SCOPE_KEY}
onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0'
)}
/>
<span>
{translate('auto.components.settings.QuickCommandsPane.8c877dec41', 'Global')}
</span>
</CommandItem>
{repos.map((repo) => {
const isSelected = effectiveSelection.has(repo.id)
return (
<CommandItem
key={repo.id}
value={repo.id}
onSelect={() => toggleScope(repo.id)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
isSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<RepoBadgeLabel
name={getQuickCommandRepoLabel(repo)}
color={repo.badgeColor}
className="max-w-full"
/>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}
@@ -5,6 +5,7 @@ import {
} from '../../../../shared/protocol-version'
import {
evaluateHostDetails,
getActiveServerModeDescription,
getHostDetailsDescription,
getHostDetailsSummary,
getRuntimeCapabilitiesSummary,
@@ -127,4 +128,11 @@ describe('RuntimeEnvironmentsPane host details', () => {
})
).toBe('runtime.environments.v1, browser.screencast.v1, terminal.multiplex.v1 +1')
})
it('explains that selecting a saved server is the explicit default Host mode', () => {
expect(getActiveServerModeDescription(true)).toContain('default Host')
expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff')
expect(getActiveServerModeDescription(false)).toContain('default Host')
expect(getActiveServerModeDescription(false)).toContain('paired Orca runtime')
})
})
@@ -110,6 +110,18 @@ export function getRuntimeCapabilitiesSummary(status: RuntimeStatus | null | und
return hiddenCount > 0 ? `${visibleCapabilities} +${hiddenCount}` : visibleCapabilities
}
export function getActiveServerModeDescription(allowLocalRuntime: boolean): string {
return allowLocalRuntime
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a',
"Local keeps today's desktop behavior. Selecting a saved server makes that server the default Host for server-routed projects, files, terminals, provider accounts, and browser/mobile handoff."
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8',
'Selecting a saved server makes this browser use that paired Orca runtime as its default Host.'
)
}
export function RuntimeEnvironmentsPane({
settings,
switchRuntimeEnvironment,
@@ -462,15 +474,7 @@ export function RuntimeEnvironmentsPane({
)}
</Label>
<p className="text-xs text-muted-foreground">
{allowLocalRuntime
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.f75ce1c7a5',
"Local keeps today's desktop behavior. Saved servers route supported client calls through the remote runtime."
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.8cf8790697',
'Saved servers route this browser through a paired Orca runtime.'
)}
{getActiveServerModeDescription(allowLocalRuntime)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -127,7 +127,7 @@ describe('useAddRepoHostSelection', () => {
expect(setStep).toHaveBeenCalledWith('add')
})
it('keeps a disconnected SSH host selected so the remote step can connect it', async () => {
it('falls back from a disconnected selected SSH host to Local Mac', async () => {
mocks.stateValues = ['ssh:ssh-1', false]
mocks.hostOptions[1] = {
...mocks.hostOptions[1],
@@ -137,11 +137,11 @@ describe('useAddRepoHostSelection', () => {
const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
expect(result.selectedHostId).toBe('ssh:ssh-1')
expect(result.selectedSshTargetId).toBe('ssh-1')
expect(result.selectedHostId).toBe('local')
expect(result.selectedSshTargetId).toBeNull()
})
it('selects a disconnected SSH host so the user can connect it inline', async () => {
it('does not select a disconnected SSH host', async () => {
mocks.stateValues = ['local', false]
mocks.hostOptions[1] = {
...mocks.hostOptions[1],
@@ -154,8 +154,8 @@ describe('useAddRepoHostSelection', () => {
await result.handleSelectAddProjectHost('ssh:ssh-1')
expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalled()
expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1')
expect(setStep).toHaveBeenCalledWith('add')
expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1')
expect(setStep).not.toHaveBeenCalled()
})
it('does not auto-select the active runtime host while it is unavailable', async () => {
@@ -521,6 +521,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
initialLinkedWorkItem,
initialTaskSourceContext,
linkedWorkItem,
newWorkspaceDraft?.linkedWorkItem?.url,
newWorkspaceDraft?.taskSourceContext,
persistDraft,
projects,
@@ -2702,6 +2703,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepoIsGit,
selectedRepoSettings,
selectedRepoRequiresConnection,
selectedWorkspaceTarget,
showProjectRequiredError,
settings?.agentCmdOverrides,
settings?.agentDefaultArgs,
@@ -2713,6 +2715,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
sparseError,
effectivePresetId,
telemetrySource,
taskSourceContext,
checkedHooksRepoId,
commitHookCheckIfCurrent,
loadHookCheckForRepo,
+55 -8
View File
@@ -238,7 +238,7 @@ function getGitHubWorkItemSourceSettings(
): AppState['settings'] {
if (sourceContext?.provider === 'github') {
return {
...(settings ?? {}),
...settings,
...getTaskSourceRuntimeSettings(sourceContext)
} as AppState['settings']
}
@@ -564,6 +564,7 @@ function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolea
const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed)
const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently
const EMPTY_CHECKS_CACHE_TTL = 10_000
// Why: the NewWorkspace page's work-item list is a browse surface, not a
// source of truth, so 60s staleness is fine — stale data renders instantly
// while a background refresh keeps it current.
@@ -578,7 +579,12 @@ const inflightPRRequests = new Map<
{ promise: Promise<PRInfo | null>; force: boolean; generation: number; lookupHintKey: string }
>()
const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>()
const inflightChecksRequests = new Map<string, Promise<PRCheckDetail[]>>()
type InflightChecks = {
promise: Promise<PRCheckDetail[]>
force: boolean
noCache: boolean
}
const inflightChecksRequests = new Map<string, InflightChecks>()
const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
type InflightWorkItems = {
promise: Promise<GitHubWorkItem[]>
@@ -818,6 +824,10 @@ function isFresh<T>(entry: CacheEntry<T> | undefined, ttl = CACHE_TTL): entry is
return entry !== undefined && Date.now() - entry.fetchedAt < ttl
}
function getPRChecksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | undefined): number {
return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL
}
function findWorktreeById(state: AppState, worktreeId: string): Worktree | null {
for (const worktrees of Object.values(state.worktreesByRepo)) {
const worktree = worktrees.find((w) => w.id === worktreeId)
@@ -2700,7 +2710,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
if (
!options?.force &&
isFresh(cached, CHECKS_CACHE_TTL) &&
!options?.noCache &&
isFresh(cached, getPRChecksCacheTtl(cached)) &&
(!headSha || cached.headSha === headSha)
) {
const cachedChecks = cached.data ?? []
@@ -2725,7 +2736,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const inflightRequest = inflightChecksRequests.get(inflightKey)
if (inflightRequest) {
return inflightRequest
if (
(options?.force && !inflightRequest.force) ||
(options?.noCache && !inflightRequest.noCache)
) {
await inflightRequest.promise.catch(() => {})
} else {
return inflightRequest.promise
}
}
const request = (async () => {
@@ -2740,7 +2758,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: options?.force
noCache: Boolean(options?.force || options?.noCache)
},
{ timeoutMs: 30_000 }
)
@@ -2750,7 +2768,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: options?.force
noCache: Boolean(options?.force || options?.noCache)
})) as PRCheckDetail[])
set((s) => {
const nextState: Partial<AppState> = {
@@ -2793,7 +2811,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})()
inflightChecksRequests.set(inflightKey, request)
inflightChecksRequests.set(inflightKey, {
promise: request,
force: Boolean(options?.force),
noCache: Boolean(options?.force || options?.noCache)
})
return request
},
@@ -3211,6 +3233,18 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const checksCacheKeys = [
...(alias.repoId
? [
...(pr.headSha
? [
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId,
aliasExecutionHostId
)
]
: []),
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
@@ -3221,6 +3255,18 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
)
]
: []),
...(pr.headSha
? [
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId,
aliasExecutionHostId
)
]
: []),
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
@@ -3239,7 +3285,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
checksEntry.headSha &&
pr.headSha &&
checksEntry.headSha === pr.headSha &&
event.outcome.fetchedAt - checksEntry.fetchedAt < CHECKS_CACHE_TTL
event.outcome.fetchedAt - checksEntry.fetchedAt <
getPRChecksCacheTtl(checksEntry)
) {
return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) }
}
+49 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { toRuntimeExecutionHostId, toSshExecutionHostId } from './execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId
} from './execution-host'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext,
@@ -96,6 +100,50 @@ describe('task source context', () => {
expect(local).not.toBe(differentRepo)
})
it('serializes provider identities for GitLab, Linear, and Jira cache scopes', () => {
const base = {
projectId: 'project-1',
hostId: LOCAL_EXECUTION_HOST_ID,
repoId: 'repo-1'
} as const
expect(
getTaskSourceCacheScope({
...base,
provider: 'gitlab',
providerIdentity: { provider: 'gitlab', namespace: 'stably', project: 'orca' }
})
).toContain(encodeURIComponent('stably/orca'))
expect(
getTaskSourceCacheScope({
...base,
provider: 'linear',
providerIdentity: { provider: 'linear', workspaceId: 'workspace-1', teamKey: 'ENG' }
})
).toContain(encodeURIComponent('workspace-1/ENG'))
expect(
getTaskSourceCacheScope({
...base,
provider: 'jira',
providerIdentity: {
provider: 'jira',
siteUrl: 'https://example.atlassian.net',
projectKey: 'OPS'
}
})
).toContain(encodeURIComponent('https://example.atlassian.net/OPS'))
})
it('drops provider identities that do not match the source provider', () => {
expect(
normalizeTaskSourceContext({
provider: 'gitlab',
projectId: 'project-1',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
})?.providerIdentity
).toBeNull()
})
it('builds workspace run context from an explicit project host setup', () => {
expect(
buildWorkspaceRunContext({