From 762f42a4e49822e922d1e20e47ef670d3f6568f1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 12 Jun 2026 18:55:55 -0700 Subject: [PATCH] Continue host context checklist implementation Co-authored-by: Orca --- .../host-context-implementation-checklist.md | 18 +- notes/project-first-host-model.md | 24 ++ src/main/automations/run-target-resolution.ts | 82 +++++ src/main/automations/run-usage-collection.ts | 99 +++++++ src/main/automations/service.ts | 170 +---------- .../src/components/GitHubItemDialog.tsx | 2 +- .../src/components/GitLabItemDialog.tsx | 8 +- .../settings/CommitMessageAiPane.tsx | 110 +------ .../settings/HostedReviewCreationDefaults.tsx | 128 ++++++++ .../components/settings/QuickCommandsList.tsx | 163 ++++++++++ .../components/settings/QuickCommandsPane.tsx | 279 ++---------------- .../settings/QuickCommandsScopeFilter.tsx | 161 ++++++++++ .../settings/RuntimeEnvironmentsPane.test.ts | 8 + .../settings/RuntimeEnvironmentsPane.tsx | 22 +- .../use-add-repo-host-selection.test.ts | 12 +- src/renderer/src/hooks/useComposerState.ts | 3 + src/renderer/src/store/slices/github.ts | 63 +++- src/shared/task-source-context.test.ts | 50 +++- 18 files changed, 843 insertions(+), 559 deletions(-) create mode 100644 src/main/automations/run-target-resolution.ts create mode 100644 src/main/automations/run-usage-collection.ts create mode 100644 src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx create mode 100644 src/renderer/src/components/settings/QuickCommandsList.tsx create mode 100644 src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index 56c698fff01..96273bbbddf 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -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. diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 83a1bb56f60..4368f0c381e 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -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 diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts new file mode 100644 index 00000000000..77250e5d85e --- /dev/null +++ b/src/main/automations/run-target-resolution.ts @@ -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 } +} diff --git a/src/main/automations/run-usage-collection.ts b/src/main/automations/run-usage-collection.ts new file mode 100644 index 00000000000..ab1233e5765 --- /dev/null +++ b/src/main/automations/run-usage-collection.ts @@ -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 { + 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.') +} diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 0b33ca2123a..3f5bccf57a4 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -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 { - 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 { 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 { const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now) if (scheduledFor === null) { @@ -333,7 +185,7 @@ export class AutomationService { automation: Automation, run: AutomationRun ): Promise { - const target = this.resolveRunTarget(automation) + const target = resolveAutomationRunTarget(this.store, automation) if (!target.ok) { return this.store.updateAutomationRun({ runId: run.id, diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 71f87a0c9c4..ee339d6e926 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1903,7 +1903,7 @@ function PRFilesCombinedDiffViewer({ ) }) }, - [baseSha, fileByPath, headSha, prNumber, repoId, repoPath] + [baseSha, fileByPath, headSha, prNumber, repoId, repoPath, sourceContext] ) const retrySection = useCallback( diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index 299623275c1..9d425d9cc54 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -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(null) const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null) const mountedRef = useMountedRef() - const sourceContextKey = sourceContext ? getTaskSourceCacheScope(sourceContext) : '' const repoSelector = useMemo(() => { 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 }) diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 0a33fba4db6..9b8ebc6d9ff 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -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 - 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( - -
- -

- {translate( - 'auto.components.settings.CommitMessageAiPane.347094560b', - 'Used by repositories that inherit global hosted-review defaults.' - )} -

-
-
- {rows.map((row) => ( - - ))} -
-
+ prDefaults={prDefaults} + onPrDefaultChange={onPrDefaultChange} + /> ) } diff --git a/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx b/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx new file mode 100644 index 00000000000..3f4b1ac7298 --- /dev/null +++ b/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx @@ -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 + +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 + onPrDefaultChange: (key: HostedReviewDefaultKey, value: boolean) => void +}): React.JSX.Element { + return ( + +
+ +

+ {translate( + 'auto.components.settings.CommitMessageAiPane.347094560b', + 'Used by repositories that inherit global hosted-review defaults.' + )} +

+
+
+ {getHostedReviewDefaultRows().map((row) => ( + + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/QuickCommandsList.tsx b/src/renderer/src/components/settings/QuickCommandsList.tsx new file mode 100644 index 00000000000..bffc71f2d82 --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsList.tsx @@ -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 { + 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> + onEdit: (command: TerminalQuickCommand) => void + onRemove: (command: TerminalQuickCommand) => void +}): React.JSX.Element { + const scope = getTerminalQuickCommandScope(command) + return ( +
+
+
+
+ {command.label || + translate('auto.components.settings.QuickCommandsPane.2bb9e38e93', 'Untitled')} +
+ + {scope.type === 'repo' ? ( + <> + + {getScopeLabel(scope, repoById)} + + ) : ( + {getScopeLabel(scope, repoById)} + )} + +
+
+ {isTerminalAgentQuickCommand(command) ? ( + + + + ) : null} + + {isTerminalAgentQuickCommand(command) + ? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}` + : getTerminalQuickCommandBody(command) || + translate( + 'auto.components.settings.QuickCommandsPane.0252ddd578', + 'No command text' + )} + +
+
+
+ {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')} +
+ + +
+ ) +} + +export function QuickCommandsList({ + commands, + visibleCommands, + repoById, + onEdit, + onRemove +}: { + commands: TerminalQuickCommand[] + visibleCommands: TerminalQuickCommand[] + repoById: Map> + onEdit: (command: TerminalQuickCommand) => void + onRemove: (command: TerminalQuickCommand) => void +}): React.JSX.Element { + return ( +
+ {visibleCommands.length === 0 ? ( +
+ {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.' + )} +
+ ) : ( +
+ {visibleCommands.map((command) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index 872fb4469ef..4b70c72c900 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -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): 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 { - 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 ( - - {translate('auto.components.settings.QuickCommandsPane.c6b155911b', 'All commands')} - - ) - } - 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 ( - - {parts.join(', ') || - translate('auto.components.settings.QuickCommandsPane.d1d0976320', 'None')} - - ) - } - 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({ -
- - - - - - -
- -
- - toggleScope(GLOBAL_SCOPE_KEY)} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - - - {translate('auto.components.settings.QuickCommandsPane.8c877dec41', 'Global')} - - - {repos.map((repo) => { - const isSelected = effectiveSelection.has(repo.id) - return ( - toggleScope(repo.id)} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - - - - ) - })} - -
-
-
-
+ -
- {visibleCommands.length === 0 ? ( -
- {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.' - )} -
- ) : ( -
- {visibleCommands.map((command) => { - const scope = getTerminalQuickCommandScope(command) - return ( -
-
-
-
- {command.label || - translate( - 'auto.components.settings.QuickCommandsPane.2bb9e38e93', - 'Untitled' - )} -
- - {scope.type === 'repo' ? ( - <> - - {getScopeLabel(scope, repoById)} - - ) : ( - {getScopeLabel(scope, repoById)} - )} - -
-
- {isTerminalAgentQuickCommand(command) ? ( - - - - ) : null} - - {isTerminalAgentQuickCommand(command) - ? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}` - : getTerminalQuickCommandBody(command) || - translate( - 'auto.components.settings.QuickCommandsPane.0252ddd578', - 'No command text' - )} - -
-
-
- {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' - )} -
- - -
- ) - })} -
- )} -
+ setEditor({ mode: 'edit', command })} + onRemove={(command) => void removeCommand(command)} + /> {editor !== null ? ( ): string { + return repo.displayName || repo.path +} + +function ScopeTriggerLabel({ + showAll, + effectiveSelection, + repos +}: { + showAll: boolean + effectiveSelection: ReadonlySet + repos: Repo[] +}): React.JSX.Element { + if (showAll) { + return ( + + {translate('auto.components.settings.QuickCommandsPane.c6b155911b', 'All commands')} + + ) + } + 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 ( + + {parts.join(', ') || + translate('auto.components.settings.QuickCommandsPane.d1d0976320', 'None')} + + ) +} + +export function QuickCommandsScopeFilter({ + repos, + effectiveSelection, + showAll, + scopePopoverOpen, + setScopePopoverOpen, + handleSelectAll, + toggleScope +}: { + repos: Repo[] + effectiveSelection: ReadonlySet + showAll: boolean + scopePopoverOpen: boolean + setScopePopoverOpen: Dispatch> + handleSelectAll: () => void + toggleScope: (key: string) => void +}): React.JSX.Element { + return ( +
+ + + + + + +
+ +
+ + toggleScope(GLOBAL_SCOPE_KEY)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + + + {translate('auto.components.settings.QuickCommandsPane.8c877dec41', 'Global')} + + + {repos.map((repo) => { + const isSelected = effectiveSelection.has(repo.id) + return ( + toggleScope(repo.id)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + + + + ) + })} + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index 60d10e33add..95a028b5881 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index fd7fe4eae57..672522ac5e9 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -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({ )}

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

diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts index 489c58a07fe..7bb90f4155a 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts @@ -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 () => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 29e5b489d3a..8780edf021c 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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, diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 2dc64ec91db..f7c763d9ff3 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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; force: boolean; generation: number; lookupHintKey: string } >() const inflightIssueRequests = new Map>() -const inflightChecksRequests = new Map>() +type InflightChecks = { + promise: Promise + force: boolean + noCache: boolean +} +const inflightChecksRequests = new Map() const inflightCommentsRequests = new Map>() type InflightWorkItems = { promise: Promise @@ -818,6 +824,10 @@ function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is return entry !== undefined && Date.now() - entry.fetchedAt < ttl } +function getPRChecksCacheTtl(entry: CacheEntry | 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 = (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 = (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 = (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 = (s prNumber, headSha, prRepo: prRepo ?? null, - noCache: options?.force + noCache: Boolean(options?.force || options?.noCache) })) as PRCheckDetail[]) set((s) => { const nextState: Partial = { @@ -2793,7 +2811,11 @@ export const createGitHubSlice: StateCreator = (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 = (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 = (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 = (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) } } diff --git a/src/shared/task-source-context.test.ts b/src/shared/task-source-context.test.ts index 76b33a52200..5af28c763cb 100644 --- a/src/shared/task-source-context.test.ts +++ b/src/shared/task-source-context.test.ts @@ -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({