mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Continue host context checklist implementation
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user