diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 3f505ca75d9..26de7544dd2 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -894,7 +894,7 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Publish release artifacts (Linux) - if: matrix.platform == 'linux' + if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64' uses: nick-fields/retry@v4 with: timeout_minutes: 30 diff --git a/README.md b/README.md index b11d94924fc..bb86cc4c9e5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) +- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/config/scripts/benchmark-artifact-comparison.test.mjs b/config/scripts/benchmark-artifact-comparison.test.mjs new file mode 100644 index 00000000000..d7700deda04 --- /dev/null +++ b/config/scripts/benchmark-artifact-comparison.test.mjs @@ -0,0 +1,440 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + compareBenchmarkArtifacts, + formatBenchmarkComparisonMarkdown, + parseBenchmarkComparisonArgs +} from './compare-benchmark-artifacts.mjs' + +const scriptPath = 'config/scripts/compare-benchmark-artifacts.mjs' +const tempDirs = [] + +function makeTempDir() { + const dir = mkdtempSync(join(tmpdir(), 'orca-benchmark-comparison-')) + tempDirs.push(dir) + return dir +} + +function writeArtifact(dir, name, artifact) { + const artifactPath = join(dir, name) + writeFileSync(artifactPath, JSON.stringify(artifact)) + return artifactPath +} + +function comparePaths(baselinePath, candidatePath, extra = {}) { + return compareBenchmarkArtifacts({ + baselinePath, + candidatePath, + now: () => new Date('2026-06-21T12:00:00.000Z'), + title: 'Test Compare', + ...extra + }) +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('benchmark artifact comparison', () => { + it('parses required CLI flags and rejects missing paths', () => { + expect(() => parseBenchmarkComparisonArgs(['--baseline'])).toThrow('--baseline requires a path') + expect(() => parseBenchmarkComparisonArgs(['--candidate'])).toThrow( + '--candidate requires a path' + ) + expect(() => parseBenchmarkComparisonArgs(['--candidate', 'candidate.json'])).toThrow( + 'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline --candidate [--title ] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]' + ) + }) + + it('compares startup-style summary median metrics and skips null candidates', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'baseline.json', { + label: 'baseline', + summaryMedianMs: { + missingLater: 5, + spawnToAppReady: 25, + totalToDidFinishLoad: 100 + } + }) + const candidatePath = writeArtifact(dir, 'candidate.json', { + label: 'candidate', + summaryMedianMs: { + missingLater: null, + spawnToAppReady: 30, + totalToDidFinishLoad: 40 + } + }) + + const comparison = comparePaths(baselinePath, candidatePath) + + expect(comparison.schemaVersion).toBe(1) + expect(comparison.createdAt).toBe('2026-06-21T12:00:00.000Z') + expect(comparison.baseline.label).toBe('baseline') + expect(comparison.candidate.label).toBe('candidate') + expect( + comparison.metrics.find((metric) => metric.key === 'totalToDidFinishLoad') + ).toMatchObject({ + absoluteDelta: -60, + baseline: 100, + candidate: 40, + percentDelta: -60, + status: 'improved', + unit: 'ms' + }) + expect(comparison.metrics.find((metric) => metric.key === 'spawnToAppReady')).toMatchObject({ + status: 'regressed' + }) + expect(comparison.skippedMetrics).toContainEqual({ + key: 'missingLater', + reason: 'missing candidate metric' + }) + }) + + it('compares numeric Playwright annotation metrics and omits metadata fields', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'baseline-playwright.json', { + suites: [ + { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale-same-workspace-50', + description: + 'panes=50 frames=60 median=80.0ms worst=120.0ms rendererQueuedChars=1000 samples=1,2' + } + ] + } + ] + } + ] + } + ] + } + ] + }) + const candidatePath = writeArtifact(dir, 'candidate-playwright.json', { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale-same-workspace-50', + description: + 'panes=50 frames=60 median=60.0ms worst=100.0ms rendererQueuedChars=800 samples=1,2' + } + ] + } + ] + } + ] + } + ] + }) + + const comparison = comparePaths(baselinePath, candidatePath) + const metricKeys = comparison.metrics.map((metric) => metric.key) + + expect(metricKeys).toContain('opencode-scale-same-workspace-50.median') + expect(metricKeys).toContain('opencode-scale-same-workspace-50.rendererQueuedChars') + expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.panes') + expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.frames') + expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.samples') + expect( + comparison.metrics.find((metric) => metric.key === 'opencode-scale-same-workspace-50.median') + ).toMatchObject({ status: 'improved', unit: 'ms' }) + }) + + it('aggregates duplicate Playwright scenario metrics before comparison', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'baseline-playwright-duplicates.json', { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-duplicate', + description: 'median=80.0ms rendererQueuedChars=1000' + }, + { + type: 'opencode-duplicate', + description: 'median=100.0ms rendererQueuedChars=1400' + } + ] + } + ] + } + ] + } + ] + }) + const candidatePath = writeArtifact(dir, 'candidate-playwright-duplicates.json', { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-duplicate', + description: 'median=60.0ms rendererQueuedChars=800' + }, + { + type: 'opencode-duplicate', + description: 'median=70.0ms rendererQueuedChars=1000' + } + ] + } + ] + } + ] + } + ] + }) + + const comparison = comparePaths(baselinePath, candidatePath) + const duplicateMedianMetrics = comparison.metrics.filter( + (metric) => metric.key === 'opencode-duplicate.median' + ) + + expect(duplicateMedianMetrics).toHaveLength(1) + expect(duplicateMedianMetrics[0]).toMatchObject({ + absoluteDelta: -25, + baseline: 90, + candidate: 65, + percentDelta: -27.8, + status: 'improved', + unit: 'ms' + }) + expect( + comparison.metrics.filter((metric) => metric.key === 'opencode-duplicate.rendererQueuedChars') + ).toHaveLength(1) + }) + + it('skips unit mismatches instead of comparing incompatible metrics', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'baseline-playwright-units.json', { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-units', + description: 'median=80.0ms rendererQueuedChars=1000' + } + ] + } + ] + } + ] + } + ] + }) + const candidatePath = writeArtifact(dir, 'candidate-playwright-units.json', { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-units', + description: 'median=60 rendererQueuedChars=800' + } + ] + } + ] + } + ] + } + ] + }) + + const comparison = comparePaths(baselinePath, candidatePath) + + expect(comparison.metrics.map((metric) => metric.key)).toEqual([ + 'opencode-units.rendererQueuedChars' + ]) + expect(comparison.skippedMetrics).toContainEqual({ + key: 'opencode-units.median', + reason: 'unit mismatch (ms vs count)' + }) + }) + + it('supports higher-is-better metrics for generic summary artifacts', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'generic-baseline.json', { + summary: { + totalBytes: 2048, + totalCpuPercent: { mean: 80 }, + throughput: 10 + } + }) + const candidatePath = writeArtifact(dir, 'generic-candidate.json', { + summary: { + totalBytes: 1024, + totalCpuPercent: { mean: 70 }, + throughput: 12 + } + }) + + const comparison = comparePaths(baselinePath, candidatePath, { + higherIsBetter: new Set(['summary.throughput']) + }) + + expect(comparison.metrics.find((metric) => metric.key === 'summary.throughput')).toMatchObject({ + direction: 'higher-is-better', + status: 'improved' + }) + expect(comparison.metrics.find((metric) => metric.key === 'summary.totalBytes')).toMatchObject({ + unit: 'bytes' + }) + expect( + comparison.metrics.find((metric) => metric.key === 'summary.totalCpuPercent.mean') + ).toMatchObject({ + unit: '%' + }) + }) + + it('writes Markdown and JSON reports from the CLI while printing Markdown', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'baseline.json', { + label: 'baseline', + summaryMedianMs: { totalToDidFinishLoad: 100 } + }) + const candidatePath = writeArtifact(dir, 'candidate.json', { + label: 'candidate', + summaryMedianMs: { totalToDidFinishLoad: 40 } + }) + const markdownPath = join(dir, 'nested', 'comparison.md') + const jsonPath = join(dir, 'nested', 'comparison.json') + + const result = spawnSync( + process.execPath, + [ + scriptPath, + '--baseline', + baselinePath, + '--candidate', + candidatePath, + '--title', + 'Test Compare', + '--output', + markdownPath, + '--json-output', + jsonPath + ], + { cwd: process.cwd(), encoding: 'utf8' } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('| Metric | Baseline | Candidate | Delta | Delta % | Result |') + expect(result.stdout).toContain( + '| totalToDidFinishLoad | 100.0ms | 40.0ms | -60.0ms | -60.0% | improved |' + ) + expect(existsSync(markdownPath)).toBe(true) + expect(existsSync(jsonPath)).toBe(true) + expect(JSON.parse(readFileSync(jsonPath, 'utf8')).schemaVersion).toBe(1) + }) + + it('redacts absolute input paths from generated reports', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'absolute-baseline.json', { + label: 'baseline', + summaryMedianMs: { totalToDidFinishLoad: 100 } + }) + const candidatePath = writeArtifact(dir, 'absolute-candidate.json', { + label: 'candidate', + summaryMedianMs: { totalToDidFinishLoad: 40 } + }) + const markdownPath = join(dir, 'comparison.md') + const jsonPath = join(dir, 'comparison.json') + + const result = spawnSync( + process.execPath, + [ + scriptPath, + '--baseline', + baselinePath, + '--candidate', + candidatePath, + '--output', + markdownPath, + '--json-output', + jsonPath + ], + { cwd: process.cwd(), encoding: 'utf8' } + ) + const json = JSON.parse(readFileSync(jsonPath, 'utf8')) + const markdown = readFileSync(markdownPath, 'utf8') + + expect(result.status).toBe(0) + expect(json.baseline.path).toBe('absolute-baseline.json') + expect(json.candidate.path).toBe('absolute-candidate.json') + expect(markdown).not.toContain(dir) + expect(result.stdout).not.toContain(dir) + }) + + it('escapes artifact-controlled Markdown fields in reports', () => { + const markdown = formatBenchmarkComparisonMarkdown({ + title: 'Compare\n[Injected](https://example.test)', + createdAt: '2026-06-21T12:00:00.000Z', + baseline: { label: 'base\n<label>', path: 'base|path', kind: 'summary' }, + candidate: { label: 'candidate', path: 'candidate.md', kind: 'summary' }, + metrics: [ + { + key: 'summary.value|with-pipe', + unit: '', + baseline: 1, + candidate: 2, + absoluteDelta: 1, + percentDelta: 100, + status: 'regressed' + } + ], + skippedMetrics: [{ key: 'missing\nmetric', reason: 'missing | candidate' }] + }) + + expect(markdown).toContain('# Compare \\[Injected\\]\\(https://example\\.test\\)') + expect(markdown).toContain('Baseline: base \\<label\\> (base|path)') + expect(markdown).toContain( + '| summary\\.value\\|with\\-pipe | 1.0 | 2.0 | 1.0 | 100.0% | regressed |' + ) + expect(markdown).toContain('- missing metric: missing | candidate') + }) + + it('fails unsupported artifacts in the CLI', () => { + const dir = makeTempDir() + const baselinePath = writeArtifact(dir, 'baseline.json', { hello: 'world' }) + const candidatePath = writeArtifact(dir, 'candidate.json', { hello: 'world' }) + + const result = spawnSync( + process.execPath, + [scriptPath, '--baseline', baselinePath, '--candidate', candidatePath], + { cwd: process.cwd(), encoding: 'utf8' } + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('unsupported benchmark artifact') + }) +}) diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index 6e579e02da1..693822373f0 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -1,5 +1,5 @@ -import { readFileSync } from 'node:fs' import { basename } from 'node:path' +import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs' const reportPaths = process.argv.slice(2) if (reportPaths[0] === '--') { @@ -26,55 +26,6 @@ const BUDGETS = { maxRendererDroppedBacklogs: 0 } -function readJsonReport(path) { - const raw = readFileSync(path, 'utf8') - const start = raw.indexOf('{') - const end = raw.lastIndexOf('}') - if (start === -1 || end <= start) { - throw new Error(`${path}: no JSON object found`) - } - return JSON.parse(raw.slice(start, end + 1)) -} - -function parseAnnotationDescription(description) { - const values = {} - for (const part of description.split(/\s+/)) { - const index = part.indexOf('=') - if (index === -1) { - continue - } - values[part.slice(0, index)] = part.slice(index + 1) - } - return values -} - -function collectTerminalPerfRows(report, source) { - const rows = [] - const visitSuite = (suite) => { - for (const spec of suite.specs ?? []) { - for (const test of spec.tests ?? []) { - for (const annotation of test.annotations ?? []) { - if (!annotation.type.startsWith('opencode-')) { - continue - } - rows.push({ - source, - scenario: annotation.type, - ...parseAnnotationDescription(annotation.description ?? '') - }) - } - } - } - for (const child of suite.suites ?? []) { - visitSuite(child) - } - } - for (const suite of report.suites ?? []) { - visitSuite(suite) - } - return rows -} - function parseMs(value, fieldName, row, failures) { if (value == null || value === '') { return null diff --git a/config/scripts/compare-benchmark-artifacts.mjs b/config/scripts/compare-benchmark-artifacts.mjs new file mode 100644 index 00000000000..3dc29355670 --- /dev/null +++ b/config/scripts/compare-benchmark-artifacts.mjs @@ -0,0 +1,394 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, dirname, isAbsolute, relative } from 'node:path' +import { pathToFileURL } from 'node:url' +import { collectTerminalPerfRows } from './terminal-perf-report-annotations.mjs' + +const USAGE = + 'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]' + +const PLAYWRIGHT_METADATA_FIELDS = new Set(['source', 'scenario', 'panes', 'frames', 'samples']) + +export function parseBenchmarkComparisonArgs(argv = process.argv.slice(2)) { + const args = argv[0] === '--' ? argv.slice(1) : [...argv] + const parsed = { + higherIsBetter: new Set(), + title: 'Benchmark comparison' + } + + for (let index = 0; index < args.length; index += 1) { + const flag = args[index] + if (flag === '--baseline') { + parsed.baselinePath = readRequiredValue(args, ++index, '--baseline requires a path') + continue + } + if (flag === '--candidate') { + parsed.candidatePath = readRequiredValue(args, ++index, '--candidate requires a path') + continue + } + if (flag === '--title') { + parsed.title = readRequiredValue(args, ++index, '--title requires a value') + continue + } + if (flag === '--output') { + parsed.outputPath = readRequiredValue(args, ++index, '--output requires a path') + continue + } + if (flag === '--json-output') { + parsed.jsonOutputPath = readRequiredValue(args, ++index, '--json-output requires a path') + continue + } + if (flag === '--higher-is-better') { + let consumed = 0 + while (args[index + 1] != null && !args[index + 1].startsWith('--')) { + parsed.higherIsBetter.add(args[index + 1]) + index += 1 + consumed += 1 + } + if (consumed === 0) { + throw new Error('--higher-is-better requires a metric key') + } + continue + } + throw new Error(USAGE) + } + + if (!parsed.baselinePath) { + throw new Error(USAGE) + } + if (!parsed.candidatePath) { + throw new Error(USAGE) + } + return parsed +} + +function readRequiredValue(args, index, message) { + const value = args[index] + if (value == null || value.startsWith('--')) { + throw new Error(message) + } + return value +} + +export function readBenchmarkArtifact(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} + +export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) { + if (artifact?.summaryMedianMs != null) { + return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms') + } + if (artifact?.summaryMedian != null) { + return normalizeNumericObject(path, artifact, 'daemon', artifact.summaryMedian, (key) => + key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms' + ) + } + if (artifact?.suites != null) { + return normalizePlaywrightArtifact(path, artifact) + } + if (artifact?.summary != null) { + return normalizeSummaryArtifact(path, artifact) + } + throw new Error( + `${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary` + ) +} + +function artifactLabel(path, artifact) { + return typeof artifact?.label === 'string' && artifact.label.length > 0 + ? artifact.label + : basename(path) +} + +function normalizeNumericObject(path, artifact, kind, values, unitForKey) { + return { + kind, + label: artifactLabel(path, artifact), + metrics: Object.entries(values ?? {}) + .filter(([, value]) => Number.isFinite(value)) + .map(([key, value]) => ({ + direction: 'lower-is-better', + key, + unit: unitForKey(key), + value + })) + } +} + +function normalizePlaywrightArtifact(path, artifact) { + const rows = collectTerminalPerfRows(artifact, basename(path), { typePrefix: 'opencode-' }) + const groupedMetrics = new Map() + for (const row of rows) { + for (const [field, rawValue] of Object.entries(row)) { + if (PLAYWRIGHT_METADATA_FIELDS.has(field)) { + continue + } + const parsed = parseMetricValue(rawValue) + if (parsed == null) { + continue + } + const key = `${row.scenario}.${field}` + const metricGroup = groupedMetrics.get(key) ?? { + unit: parsed.unit, + values: [] + } + metricGroup.values.push(parsed.value) + groupedMetrics.set(key, metricGroup) + } + } + const metrics = [...groupedMetrics.entries()].map(([key, metricGroup]) => ({ + direction: 'lower-is-better', + key, + unit: metricGroup.unit, + value: mean(metricGroup.values) + })) + return { + kind: 'playwright', + label: artifactLabel(path, artifact), + metrics + } +} + +function mean(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +function parseMetricValue(rawValue) { + if (typeof rawValue === 'string') { + const msMatch = rawValue.match(/^(-?\d+(?:\.\d+)?)ms$/) + if (msMatch) { + return { unit: 'ms', value: Number(msMatch[1]) } + } + } + const numericValue = Number(rawValue) + if (!Number.isFinite(numericValue)) { + return null + } + return { unit: 'count', value: numericValue } +} + +function normalizeSummaryArtifact(path, artifact) { + const metrics = [] + flattenSummary(metrics, ['summary'], artifact.summary) + return { + kind: 'summary', + label: artifactLabel(path, artifact), + metrics + } +} + +function flattenSummary(metrics, pathParts, value) { + if (Number.isFinite(value)) { + const key = pathParts.join('.') + metrics.push({ + direction: 'lower-is-better', + key, + unit: unitForSummaryKey(pathParts), + value + }) + return + } + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return + } + for (const [childKey, childValue] of Object.entries(value)) { + flattenSummary(metrics, [...pathParts, childKey], childValue) + } +} + +function unitForSummaryKey(pathParts) { + if (pathParts.some((part) => part.endsWith('CpuPercent'))) { + return '%' + } + if (pathParts.some((part) => part.endsWith('Bytes'))) { + return 'bytes' + } + return '' +} + +export function compareBenchmarkArtifacts({ + baselinePath, + candidatePath, + title = 'Benchmark comparison', + higherIsBetter = new Set(), + now = () => new Date() +}) { + const baseline = normalizeBenchmarkArtifact(baselinePath) + const candidate = normalizeBenchmarkArtifact(candidatePath) + const candidateMetrics = new Map(candidate.metrics.map((metric) => [metric.key, metric])) + const baselineMetrics = new Map(baseline.metrics.map((metric) => [metric.key, metric])) + const metrics = [] + const skippedMetrics = [] + + for (const baselineMetric of baseline.metrics) { + const candidateMetric = candidateMetrics.get(baselineMetric.key) + if (!isComparableMetric(baselineMetric)) { + skippedMetrics.push({ key: baselineMetric.key, reason: 'missing baseline metric' }) + continue + } + if (!isComparableMetric(candidateMetric)) { + skippedMetrics.push({ key: baselineMetric.key, reason: 'missing candidate metric' }) + continue + } + if (baselineMetric.unit !== candidateMetric.unit) { + skippedMetrics.push({ + key: baselineMetric.key, + reason: `unit mismatch (${formatUnitLabel(baselineMetric.unit)} vs ${formatUnitLabel(candidateMetric.unit)})` + }) + continue + } + const direction = higherIsBetter.has(baselineMetric.key) + ? 'higher-is-better' + : baselineMetric.direction + metrics.push(compareMetric(baselineMetric, candidateMetric, direction)) + } + + for (const candidateMetric of candidate.metrics) { + if (!baselineMetrics.has(candidateMetric.key)) { + skippedMetrics.push({ key: candidateMetric.key, reason: 'missing baseline metric' }) + } + } + + if (metrics.length === 0) { + throw new Error('No comparable benchmark metrics found.') + } + + return { + schemaVersion: 1, + createdAt: now().toISOString(), + title, + baseline: { + path: benchmarkDisplayPath(baselinePath), + label: baseline.label, + kind: baseline.kind + }, + candidate: { + path: benchmarkDisplayPath(candidatePath), + label: candidate.label, + kind: candidate.kind + }, + metrics, + skippedMetrics + } +} + +function benchmarkDisplayPath(path, cwd = process.cwd()) { + if (!isAbsolute(path)) { + return path + } + const relativePath = relative(cwd, path) + if (relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)) { + return relativePath + } + return basename(path) +} + +function isComparableMetric(metric) { + return metric != null && Number.isFinite(metric.value) +} + +function compareMetric(baselineMetric, candidateMetric, direction) { + const rawDelta = candidateMetric.value - baselineMetric.value + const absoluteDelta = roundOneDecimal(rawDelta) + const percentDelta = + baselineMetric.value === 0 + ? null + : roundOneDecimal((rawDelta / Math.abs(baselineMetric.value)) * 100) + return { + key: baselineMetric.key, + unit: baselineMetric.unit, + direction, + baseline: baselineMetric.value, + candidate: candidateMetric.value, + absoluteDelta, + percentDelta, + status: metricStatus(absoluteDelta, direction) + } +} + +function formatUnitLabel(unit) { + return unit === '' ? 'none' : unit +} + +function roundOneDecimal(value) { + return Math.round(value * 10) / 10 +} + +function metricStatus(absoluteDelta, direction) { + if (absoluteDelta === 0) { + return 'unchanged' + } + if (direction === 'higher-is-better') { + return absoluteDelta > 0 ? 'improved' : 'regressed' + } + return absoluteDelta < 0 ? 'improved' : 'regressed' +} + +export function formatBenchmarkComparisonMarkdown(comparison) { + const lines = [ + `# ${markdownText(comparison.title)}`, + '', + `Baseline: ${markdownText(comparison.baseline.label)} (${markdownText(comparison.baseline.path)})`, + `Candidate: ${markdownText(comparison.candidate.label)} (${markdownText(comparison.candidate.path)})`, + `Generated: ${comparison.createdAt}`, + '', + '| Metric | Baseline | Candidate | Delta | Delta % | Result |', + '|---|---:|---:|---:|---:|---|' + ] + for (const metric of comparison.metrics) { + lines.push( + `| ${markdownTableCell(metric.key)} | ${formatMetricValue(metric.baseline, metric.unit)} | ${formatMetricValue(metric.candidate, metric.unit)} | ${formatMetricValue(metric.absoluteDelta, metric.unit)} | ${formatPercent(metric.percentDelta)} | ${metric.status} |` + ) + } + if (comparison.skippedMetrics.length > 0) { + lines.push('', '## Skipped metrics') + for (const skippedMetric of comparison.skippedMetrics) { + lines.push(`- ${markdownText(skippedMetric.key)}: ${markdownText(skippedMetric.reason)}`) + } + } + return `${lines.join('\n')}\n` +} + +function markdownText(value) { + return String(value ?? '') + .replace(/\s*\r?\n\s*/g, ' ') + .replace(/[\\`*_{}<>()#+.!-]|\[|\]/g, '\\$&') +} + +function markdownTableCell(value) { + return markdownText(value).replaceAll('|', '\\|') +} + +function formatMetricValue(value, unit) { + return `${Number(value).toFixed(1)}${unit}` +} + +function formatPercent(value) { + return value == null ? '' : `${value.toFixed(1)}%` +} + +export function runBenchmarkComparisonCli(args = {}) { + const argv = args.argv ?? process.argv.slice(2) + const parsed = parseBenchmarkComparisonArgs(argv) + const comparison = compareBenchmarkArtifacts(parsed) + const markdown = formatBenchmarkComparisonMarkdown(comparison) + if (parsed.outputPath) { + mkdirSync(dirname(parsed.outputPath), { recursive: true }) + writeFileSync(parsed.outputPath, markdown) + } + if (parsed.jsonOutputPath) { + mkdirSync(dirname(parsed.jsonOutputPath), { recursive: true }) + writeFileSync(parsed.jsonOutputPath, `${JSON.stringify(comparison, null, 2)}\n`) + } + const stdout = args.stdout ?? process.stdout + stdout.write(markdown) + return comparison +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + runBenchmarkComparisonCli() + } catch (error) { + console.error(error.message) + process.exit(1) + } +} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index f24685635c2..54c7314706d 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -86,6 +86,32 @@ describe('Electron runtime package contract', () => { ) }) + it('publishes both Linux release matrix entries', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const publishLinuxStep = parsedWorkflow.jobs.build.steps.find( + (step) => step.name === 'Publish release artifacts (Linux)' + ) + + expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-x64'") + expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-arm64'") + expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}') + }) + + it('keeps Linux postinstall repairing Chromium sandbox permissions', () => { + const afterInstallScript = readFileSync( + join(projectDir, 'resources/linux/packaging/after-install.sh'), + 'utf8' + ) + + expect(afterInstallScript).toContain('chrome-sandbox') + expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') + expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') + }) + it('lets release-cut tag a version that is already present on main', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs b/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs new file mode 100644 index 00000000000..a4e3fec42ed --- /dev/null +++ b/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process' + +const extraArgs = process.argv.slice(2) +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const env = { + ...process.env, + ORCA_E2E_SSH_DOCKER: '1' +} + +const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], { + stdio: 'inherit', + env +}) + +if (runtime.error) { + throw runtime.error +} +if (runtime.status !== 0) { + process.exit(runtime.status ?? 1) +} + +const result = spawnSync( + pnpm, + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...extraArgs + ], + { + stdio: 'inherit', + env + } +) + +if (result.error) { + throw result.error +} +process.exit(result.status ?? 1) diff --git a/config/scripts/summarize-terminal-perf-report.mjs b/config/scripts/summarize-terminal-perf-report.mjs index 04a80d81dc4..6d2adea54f9 100644 --- a/config/scripts/summarize-terminal-perf-report.mjs +++ b/config/scripts/summarize-terminal-perf-report.mjs @@ -1,5 +1,5 @@ -import { readFileSync } from 'node:fs' import { basename } from 'node:path' +import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs' const reportPaths = process.argv.slice(2) if (reportPaths[0] === '--') { @@ -13,55 +13,6 @@ if (reportPaths.length === 0) { process.exit(1) } -function readJsonReport(path) { - const raw = readFileSync(path, 'utf8') - const start = raw.indexOf('{') - const end = raw.lastIndexOf('}') - if (start === -1 || end <= start) { - throw new Error(`${path}: no JSON object found`) - } - return JSON.parse(raw.slice(start, end + 1)) -} - -function parseAnnotationDescription(description) { - const values = {} - for (const part of description.split(/\s+/)) { - const index = part.indexOf('=') - if (index === -1) { - continue - } - values[part.slice(0, index)] = part.slice(index + 1) - } - return values -} - -function collectTerminalPerfRows(report, source) { - const rows = [] - const visitSuite = (suite) => { - for (const spec of suite.specs ?? []) { - for (const test of spec.tests ?? []) { - for (const annotation of test.annotations ?? []) { - if (!annotation.type.startsWith('opencode-')) { - continue - } - rows.push({ - source, - scenario: annotation.type, - ...parseAnnotationDescription(annotation.description ?? '') - }) - } - } - } - for (const child of suite.suites ?? []) { - visitSuite(child) - } - } - for (const suite of report.suites ?? []) { - visitSuite(suite) - } - return rows -} - function markdownCell(value) { return String(value ?? '').replaceAll('|', '\\|') } diff --git a/config/scripts/summarize-terminal-perf-report.test.mjs b/config/scripts/summarize-terminal-perf-report.test.mjs new file mode 100644 index 00000000000..2a1f16ec1f3 --- /dev/null +++ b/config/scripts/summarize-terminal-perf-report.test.mjs @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const scriptPath = 'config/scripts/summarize-terminal-perf-report.mjs' +const tempDirs = [] + +function writeReport() { + const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-summary-')) + tempDirs.push(dir) + const reportPath = join(dir, 'report.json') + writeFileSync( + reportPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale', + description: 'panes=50 frames=60 median=12.3ms rendererQueuedChars=1000' + }, + { + type: 'browser-unrelated', + description: 'panes=1 median=999.0ms' + } + ] + } + ] + } + ] + } + ] + }) + ) + return reportPath +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('summarize-terminal-perf-report', () => { + it('prints only OpenCode terminal perf annotation rows', () => { + const output = execFileSync(process.execPath, [scriptPath, writeReport()], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('| Source | Scenario | Panes | Frames | Median |') + expect(output).toContain('| report.json | opencode-scale | 50 | 60 | 12.3ms |') + expect(output).toContain('1000') + expect(output).not.toContain('browser-unrelated') + expect(output).not.toContain('999.0ms') + }) +}) diff --git a/config/scripts/terminal-perf-report-annotations.mjs b/config/scripts/terminal-perf-report-annotations.mjs new file mode 100644 index 00000000000..aca4eca6026 --- /dev/null +++ b/config/scripts/terminal-perf-report-annotations.mjs @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs' + +export function readJsonReport(path) { + const raw = readFileSync(path, 'utf8') + const start = raw.indexOf('{') + const end = raw.lastIndexOf('}') + if (start === -1 || end <= start) { + throw new Error(`${path}: no JSON object found`) + } + return JSON.parse(raw.slice(start, end + 1)) +} + +export function parseAnnotationDescription(description) { + const values = {} + for (const part of description.split(/\s+/)) { + const index = part.indexOf('=') + if (index === -1) { + continue + } + values[part.slice(0, index)] = part.slice(index + 1) + } + return values +} + +export function collectTerminalPerfRows(report, source, options = {}) { + const { typePrefix = 'opencode-' } = options + const rows = [] + const visitSuite = (suite) => { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const annotation of test.annotations ?? []) { + if (!annotation.type.startsWith(typePrefix)) { + continue + } + rows.push({ + ...parseAnnotationDescription(annotation.description ?? ''), + // Why: annotation descriptions are artifact-controlled; keep the + // trusted report source and annotation type from being relabeled. + source, + scenario: annotation.type + }) + } + } + } + for (const child of suite.suites ?? []) { + visitSuite(child) + } + } + for (const suite of report.suites ?? []) { + visitSuite(suite) + } + return rows +} diff --git a/config/scripts/terminal-perf-report-annotations.test.mjs b/config/scripts/terminal-perf-report-annotations.test.mjs new file mode 100644 index 00000000000..82f4be5ea10 --- /dev/null +++ b/config/scripts/terminal-perf-report-annotations.test.mjs @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + collectTerminalPerfRows, + parseAnnotationDescription, + readJsonReport +} from './terminal-perf-report-annotations.mjs' + +const tempDirs = [] + +function makeReportPath(content) { + const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-annotations-')) + tempDirs.push(dir) + const reportPath = join(dir, 'report.json') + writeFileSync(reportPath, content) + return reportPath +} + +function makeNestedReport() { + return { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'browser-unrelated', + description: 'median=999.0ms' + } + ] + } + ] + } + ], + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale', + description: 'panes=50 median=12.3ms ignored-token rendererQueuedChars=1000' + }, + { + type: 'terminal-scale', + description: 'panes=25 median=7.0ms' + } + ] + } + ] + } + ] + } + ] + } + ] + } +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('terminal perf report annotations', () => { + it('parses key-value annotation description segments only', () => { + expect(parseAnnotationDescription('panes=50 no-equals median=12.3ms name=a=b')).toEqual({ + median: '12.3ms', + name: 'a=b', + panes: '50' + }) + }) + + it('reads JSON reports surrounded by noisy process output', () => { + const reportPath = makeReportPath( + `noise before\n${JSON.stringify({ suites: [] })}\nnoise after` + ) + + expect(readJsonReport(reportPath)).toEqual({ suites: [] }) + }) + + it('collects nested OpenCode annotations by default', () => { + expect(collectTerminalPerfRows(makeNestedReport(), 'report.json')).toEqual([ + { + median: '12.3ms', + panes: '50', + rendererQueuedChars: '1000', + scenario: 'opencode-scale', + source: 'report.json' + } + ]) + }) + + it('keeps trusted source and scenario fields when descriptions contain matching keys', () => { + const report = { + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale', + description: 'source=spoofed.json scenario=spoofed median=12.3ms' + } + ] + } + ] + } + ] + } + ] + } + + expect(collectTerminalPerfRows(report, 'report.json')).toEqual([ + { + median: '12.3ms', + scenario: 'opencode-scale', + source: 'report.json' + } + ]) + }) + + it('supports a custom annotation type prefix', () => { + expect( + collectTerminalPerfRows(makeNestedReport(), 'report.json', { typePrefix: 'terminal-' }) + ).toEqual([ + { + median: '7.0ms', + panes: '25', + scenario: 'terminal-scale', + source: 'report.json' + } + ]) + }) +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 1c96641d6bd..16f8e74a95d 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.4m"> - <title>downloads: 1.4m + + downloads: 1.5m @@ -15,7 +15,7 @@ downloads downloads - 1.4m - 1.4m + 1.5m + 1.5m diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 6bedfaa2637..b15598c68bb 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -35,7 +35,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index f591da62b2f..665bebbfe3f 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -35,7 +35,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 105dd118bb3..5771cf35b49 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -35,7 +35,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index c1f3343ca56..05c3f462019 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -35,7 +35,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -226,7 +226,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) --- diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7be0e3894ea..542dbf53a47 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4915,12 +4915,14 @@ export default function SessionScreen() { autoCorrect={false} spellCheck={false} smartInsertDelete={false} + // Why: iOS textContentType wins over autoComplete and can + // narrow the keyboard surface; keep IME switching available. + autoComplete="off" keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)} returnKeyType="default" blurOnSubmit={false} editable={canSend} importantForAutofill="no" - textContentType="none" /> ) : ( @@ -4946,6 +4948,9 @@ export default function SessionScreen() { autoCorrect={autocompleteEnabled} spellCheck={autocompleteEnabled} smartInsertDelete={false} + // Why: terminal commands are not autofill content, but the + // keyboard must stay default so non-Latin IMEs remain selectable. + autoComplete="off" keyboardType={getTerminalCommandKeyboardType( Platform.OS, autocompleteEnabled diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts index 412e9db664a..ac6ee0ff91f 100644 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts @@ -11,4 +11,9 @@ describe('terminal iOS IME keyboard', () => { expect(sessionRouteSource).not.toContain("'ascii-capable'") expect(sessionRouteSource).not.toContain('"ascii-capable"') }) + + it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { + expect(sessionRouteSource).not.toContain('textContentType="none"') + expect(sessionRouteSource).toContain('autoComplete="off"') + }) }) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 86ea4dbc37e..da6c4d34cda 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -1508,6 +1508,7 @@ private final class TreeRenderer { var focusedElementId: Int? var truncated = false var maxDepthReached = false + private let reader = AXSnapshotReader() private var nextIndex = 0 static let maxNodes = 1200 static let maxDepth = 64 @@ -1530,22 +1531,26 @@ private final class TreeRenderer { } guard !ancestors.contains(where: { CFEqual($0, element) }) else { return } - let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown" - let children = primaryChildren(element, role: role, windowBounds: windowBounds) - let value = valueString(element) - let placeholder = placeholderString(element) - let rawActions = actions(element) - let rowSummary = rowTextSummary(element, role: role) - let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil + let role = reader.stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown" + let children = reader.primaryChildren(element, role: role, windowBounds: windowBounds) + let value = reader.valueString(element, role: role) + let placeholder = reader.placeholderString(element) + let rawActions = reader.actions(element) + let rowSummary = reader.rowTextSummary(element, role: role) + let roleDescription = reader.stringAttribute(element, kAXRoleDescriptionAttribute as String) + let title = reader.stringAttribute(element, kAXTitleAttribute as String) + let label = reader.stringAttribute(element, kAXDescriptionAttribute as String) + let url = reader.stringAttribute(element, kAXURLAttribute as String) + let linkText = role == "AXLink" ? reader.descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil let baseNode = SnapshotRenderNode( role: role, - roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), - title: stringAttribute(element, kAXTitleAttribute as String), - label: stringAttribute(element, kAXDescriptionAttribute as String), + roleDescription: roleDescription, + title: title, + label: label, linkText: linkText, value: value, placeholder: placeholder, - url: stringAttribute(element, kAXURLAttribute as String), + url: url, traits: [], rawActions: rawActions, childCount: children.count, @@ -1553,19 +1558,19 @@ private final class TreeRenderer { ) let name = SnapshotRenderHeuristics.displayName(baseNode) let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role) - let localFrame = frame(element, windowBounds: windowBounds) - let traits = traitsFor(element, role: role) - let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors) - let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits) + let localFrame = reader.frame(element, windowBounds: windowBounds) + let traits = reader.traitsFor(element, role: role) + let webAreaDepth = reader.webAreaDepth(role: role, ancestors: ancestors) + let summary = reader.genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits) let node = SnapshotRenderNode( role: role, - roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), - title: stringAttribute(element, kAXTitleAttribute as String), - label: stringAttribute(element, kAXDescriptionAttribute as String), + roleDescription: roleDescription, + title: title, + label: label, linkText: linkText, value: value, placeholder: placeholder, - url: stringAttribute(element, kAXURLAttribute as String), + url: url, traits: traits, rawActions: rawActions, childCount: children.count, @@ -1617,6 +1622,23 @@ private final class TreeRenderer { } } + private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? { + let childNodes = children.map { child in + let role = reader.stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown" + return SnapshotRenderNode( + role: role, + roleDescription: reader.stringAttribute(child, kAXRoleDescriptionAttribute as String), + title: reader.stringAttribute(child, kAXTitleAttribute as String), + label: reader.stringAttribute(child, kAXDescriptionAttribute as String), + value: reader.valueString(child, role: role), + traits: reader.traitsFor(child, role: role) + ) + } + // Why: browsers expose every open tab through AX; retaining only the active + // tab keeps snapshots focused on the current page instead of stale tab titles. + return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes) + } + private func compactRenderedBrowserTabs(parent: SnapshotRenderNode, startLine: Int, depth: Int) { guard SnapshotRenderHeuristics.roleText(parent) == "scroll area" else { return } let indent = String(repeating: "\t", count: depth) @@ -1647,44 +1669,301 @@ private final class TreeRenderer { } } -private func valueString(_ element: AXUIElement) -> String? { - if isSecureTextElement(element) { - return "[redacted]" +private final class AXSnapshotReader { + private enum CachedAttribute { + case missing + case found(CFTypeRef) } - if let string = stringAttribute(element, kAXValueAttribute as String) { - return string + + private final class ElementCache { + let element: AXUIElement + var loadedAttributeNames = false + var advertisedAttributes: Set? + var attributes: [String: CachedAttribute] = [:] + var actions: [String]? + var settable: [String: Bool] = [:] + + init(element: AXUIElement) { + self.element = element + } } - if let number = numberAttribute(element, kAXValueAttribute as String) { - return number.stringValue + + private var elementsByHash: [CFHashCode: [ElementCache]] = [:] + + func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { + guard let value = copyAttribute(element, attribute) else { return nil } + if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL { + return url.absoluteString + } + return nil } - return nil -} -private func isSecureTextElement(_ element: AXUIElement) -> Bool { - let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? "" - let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? "" - let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? "" - let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? "" - let placeholder = placeholderString(element)?.lowercased() ?? "" - let haystack = [role, subrole, title, description, placeholder].joined(separator: " ") - return haystack.contains("secure") || - haystack.contains("password") || - haystack.contains("passcode") || - haystack.contains("verification code") || - haystack.contains("one-time code") -} + func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? { + copyAttribute(element, attribute) as? Bool + } -private func placeholderString(_ element: AXUIElement) -> String? { - stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder") -} + func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? { + copyAttribute(element, attribute) as? NSNumber + } -private func traitsFor(_ element: AXUIElement, role: String) -> [String] { - var traits: [String] = [] - if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") } - if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") } - if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") } - if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") } - return traits + func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? { + copyAttribute(element, attribute) as? [AXUIElement] + } + + func actions(_ element: AXUIElement) -> [String] { + let cache = cache(for: element) + if let actions = cache.actions { + return actions + } + var value: CFArray? + let actions = AXUIElementCopyActionNames(element, &value) == .success ? value as? [String] ?? [] : [] + cache.actions = actions + return actions + } + + func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool { + let cache = cache(for: element) + if let cached = cache.settable[attribute] { + return cached + } + var settable = DarwinBoolean(false) + let value = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue + cache.settable[attribute] = value + return value + } + + func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? { + guard let absolute = absoluteFrame(element) else { return nil } + return CGRect( + x: absolute.minX - windowBounds.minX, + y: absolute.minY - windowBounds.minY, + width: absolute.width, + height: absolute.height + ) + } + + func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] { + if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty { + return visibleRows(rows, parent: element, windowBounds: windowBounds) + } + return copyArray(element, kAXChildrenAttribute as String) ?? [] + } + + func valueString(_ element: AXUIElement, role: String) -> String? { + if isSecureTextElement(element, role: role) { + return "[redacted]" + } + if let string = stringAttribute(element, kAXValueAttribute as String) { + return string + } + if let number = numberAttribute(element, kAXValueAttribute as String) { + return number.stringValue + } + return nil + } + + func placeholderString(_ element: AXUIElement) -> String? { + stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder") + } + + func traitsFor(_ element: AXUIElement, role: String) -> [String] { + var traits: [String] = [] + if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") } + if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") } + if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") } + if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") } + return traits + } + + func genericTextSummary( + _ element: AXUIElement, + role: String, + name: String?, + actions: [String], + traits: [String] + ) -> String? { + guard (role == kAXGroupRole as String || role == kAXUnknownRole as String), + name == nil, + actions.isEmpty, + traits.isEmpty, + isPlainTextSubtree(element, maxDepth: 4) + else { + return nil + } + let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4) + guard texts.count >= 2 else { return nil } + let summary = texts.joined(separator: " ") + guard summary.count <= 220 else { return nil } + return summary + } + + func rowTextSummary(_ element: AXUIElement, role: String) -> String? { + guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil } + let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3) + guard !texts.isEmpty else { return nil } + return texts.joined(separator: " ") + } + + func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] { + var values: [String] = [] + var seen = Set() + + func collect(_ node: AXUIElement, depth: Int) { + guard values.count < limit, depth <= maxDepth else { return } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "" + if role == kAXStaticTextRole as String || role == "AXLink" { + for candidate in [ + stringAttribute(node, kAXValueAttribute as String), + stringAttribute(node, kAXTitleAttribute as String), + stringAttribute(node, kAXDescriptionAttribute as String), + ] { + guard let candidate else { continue } + let text = preview(candidate, maxLength: 80) + guard !text.isEmpty, seen.insert(text).inserted else { continue } + values.append(text) + if values.count >= limit { return } + } + } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + collect(child, depth: depth + 1) + if values.count >= limit { return } + } + } + + collect(element, depth: 0) + return values + } + + func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? { + if role == "AXWebArea" { return 0 } + guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else { + return nil + } + return ancestors.count - index + } + + private func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? { + let cache = cache(for: element) + if let cached = cache.attributes[attribute] { + switch cached { + case .missing: + return nil + case let .found(value): + return value + } + } + if let advertisedAttributes = advertisedAttributes(cache), + !SnapshotRenderHeuristics.supportsAttribute(attribute, advertisedAttributes: advertisedAttributes) { + cache.attributes[attribute] = .missing + return nil + } + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else { + cache.attributes[attribute] = .missing + return nil + } + cache.attributes[attribute] = .found(value) + return value + } + + private func advertisedAttributes(_ cache: ElementCache) -> Set? { + if cache.loadedAttributeNames { + return cache.advertisedAttributes + } + cache.loadedAttributeNames = true + var value: CFArray? + guard AXUIElementCopyAttributeNames(cache.element, &value) == .success, let attributes = value as? [String] else { + return nil + } + cache.advertisedAttributes = Set(attributes) + return cache.advertisedAttributes + } + + private func absoluteFrame(_ element: AXUIElement) -> CGRect? { + guard let positionValue = copyAttribute(element, kAXPositionAttribute as String), + let sizeValue = copyAttribute(element, kAXSizeAttribute as String) + else { + return nil + } + var point = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) + else { + return nil + } + return CGRect(origin: point, size: size) + } + + private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] { + guard let parentFrame = frame(parent, windowBounds: windowBounds) else { + return Array(rows.prefix(20)) + } + let visible = rows.filter { row in + guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false } + return rowFrame.intersects(parentFrame) + } + return Array((visible.isEmpty ? rows : visible).prefix(20)) + } + + private func isSecureTextElement(_ element: AXUIElement, role: String) -> Bool { + guard SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: role) else { + return false + } + let haystack = [ + role, + stringAttribute(element, kAXSubroleAttribute as String) ?? "", + stringAttribute(element, kAXTitleAttribute as String) ?? "", + stringAttribute(element, kAXDescriptionAttribute as String) ?? "", + placeholderString(element) ?? "", + ].joined(separator: " ").lowercased() + return haystack.contains("secure") || + haystack.contains("password") || + haystack.contains("passcode") || + haystack.contains("verification code") || + haystack.contains("one-time code") + } + + private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool { + var sawText = false + let allowedContainerRoles: Set = [ + kAXGroupRole as String, + kAXUnknownRole as String, + kAXStaticTextRole as String, + "AXLink", + "AXImage", + ] + + func visit(_ node: AXUIElement, depth: Int) -> Bool { + guard depth <= maxDepth else { return false } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown" + guard allowedContainerRoles.contains(role) else { return false } + if role == kAXStaticTextRole as String || role == "AXLink" { + sawText = true + } + guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + guard visit(child, depth: depth + 1) else { return false } + } + return true + } + + return visit(element, depth: 0) && sawText + } + + private func cache(for element: AXUIElement) -> ElementCache { + let hash = CFHash(element) + if let cache = elementsByHash[hash]?.first(where: { CFEqual($0.element, element) }) { + return cache + } + let cache = ElementCache(element: element) + elementsByHash[hash, default: []].append(cache) + return cache + } } private let valueSettableRoles: Set = [ @@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set = [ kAXTextFieldRole as String, ] -private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] { - if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty { - return visibleRows(rows, parent: element, windowBounds: windowBounds) - } - return copyArray(element, kAXChildrenAttribute as String) ?? [] -} - -private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] { - guard let parentFrame = frame(parent, windowBounds: windowBounds) else { - return Array(rows.prefix(20)) - } - let visible = rows.filter { row in - guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false } - return rowFrame.intersects(parentFrame) - } - return Array((visible.isEmpty ? rows : visible).prefix(20)) -} - private func usesRowsAsPrimaryChildren(role: String) -> Bool { [ kAXBrowserRole as String, @@ -1724,23 +1985,6 @@ private func usesRowsAsPrimaryChildren(role: String) -> Bool { ].contains(role) } -private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? { - let childNodes = children.map { child in - let role = stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown" - return SnapshotRenderNode( - role: role, - roleDescription: stringAttribute(child, kAXRoleDescriptionAttribute as String), - title: stringAttribute(child, kAXTitleAttribute as String), - label: stringAttribute(child, kAXDescriptionAttribute as String), - value: valueString(child), - traits: traitsFor(child, role: role) - ) - } - // Why: browsers expose every open tab through AX; retaining only the active - // tab keeps snapshots focused on the current page instead of stale tab titles. - return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes) -} - private func isDirectRenderedBrowserTabLine(_ line: String, indent: String) -> Bool { guard line.hasPrefix(indent), !line.dropFirst(indent.count).hasPrefix("\t") else { return false @@ -1761,100 +2005,6 @@ private func renderedElementIndex(_ line: String, indent: String) -> Int? { return Int(digits) } -private func genericTextSummary( - _ element: AXUIElement, - role: String, - name: String?, - actions: [String], - traits: [String] -) -> String? { - guard (role == kAXGroupRole as String || role == kAXUnknownRole as String), - name == nil, - actions.isEmpty, - traits.isEmpty, - isPlainTextSubtree(element, maxDepth: 4) - else { - return nil - } - let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4) - guard texts.count >= 2 else { return nil } - let summary = texts.joined(separator: " ") - guard summary.count <= 220 else { return nil } - return summary -} - -private func rowTextSummary(_ element: AXUIElement, role: String) -> String? { - guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil } - let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3) - guard !texts.isEmpty else { return nil } - return texts.joined(separator: " ") -} - -private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool { - var sawText = false - let allowedContainerRoles: Set = [ - kAXGroupRole as String, - kAXUnknownRole as String, - kAXStaticTextRole as String, - "AXLink", - "AXImage", - ] - - func visit(_ node: AXUIElement, depth: Int) -> Bool { - guard depth <= maxDepth else { return false } - let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown" - guard allowedContainerRoles.contains(role) else { return false } - if role == kAXStaticTextRole as String || role == "AXLink" { - sawText = true - } - guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false } - for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { - guard visit(child, depth: depth + 1) else { return false } - } - return true - } - - return visit(element, depth: 0) && sawText -} - -private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] { - var values: [String] = [] - var seen = Set() - - func collect(_ node: AXUIElement, depth: Int) { - guard values.count < limit, depth <= maxDepth else { return } - let role = stringAttribute(node, kAXRoleAttribute as String) ?? "" - if role == kAXStaticTextRole as String || role == "AXLink" { - for candidate in [ - stringAttribute(node, kAXValueAttribute as String), - stringAttribute(node, kAXTitleAttribute as String), - stringAttribute(node, kAXDescriptionAttribute as String), - ] { - guard let candidate else { continue } - let text = preview(candidate, maxLength: 80) - guard !text.isEmpty, seen.insert(text).inserted else { continue } - values.append(text) - if values.count >= limit { return } - } - } - for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { - collect(child, depth: depth + 1) - if values.count >= limit { return } - } - } - - collect(element, depth: 0) - return values -} - -private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? { - if role == "AXWebArea" { return 0 } - guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else { - return nil - } - return ancestors.count - index -} - private func sanitize(_ value: String) -> String { value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ") } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift index 275738028cd..53205adf5d3 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift @@ -60,6 +60,20 @@ public struct SnapshotTabStripCompaction: Equatable { } public enum SnapshotRenderHeuristics { + public static func shouldProbeSecureTextMetadata(role: String) -> Bool { + let normalized = role.lowercased() + return normalized.contains("text") || + normalized.contains("field") || + normalized.contains("password") || + normalized.contains("search") || + normalized.contains("combo") + } + + public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set?) -> Bool { + guard let advertisedAttributes else { return true } + return advertisedAttributes.contains(attribute) + } + public static func displayName(_ node: SnapshotRenderNode) -> String? { if let title = clean(node.title) { return title diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift index b85e70cc801..dce1bdc304c 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift @@ -2,6 +2,24 @@ import XCTest @testable import OrcaComputerUseMacOSCore final class SnapshotRenderingTests: XCTestCase { + func testSkipsUnsupportedAdvertisedAttributes() { + let advertised: Set = ["AXRole", "AXChildren"] + + XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXRole", advertisedAttributes: advertised)) + XCTAssertFalse(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: advertised)) + } + + func testUnknownAttributeAdvertisementsStayPermissive() { + XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: nil)) + } + + func testSecureTextMetadataOnlyProbesTextLikeRoles() { + XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXTextField")) + XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXSearchField")) + XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXGroup")) + XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXButton")) + } + func testElidesAnonymousWrappers() { let node = SnapshotRenderNode(role: "AXGroup", childCount: 1) diff --git a/package.json b/package.json index 35762ce1269..6b08ed1e086 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.92-rc.0", + "version": "1.4.94", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -70,6 +70,7 @@ "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", + "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", @@ -78,9 +79,13 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", + "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", - "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs" + "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs", + "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", + "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" }, "dependencies": { "@electron-toolkit/preload": "^3.0.2", diff --git a/resources/linux/packaging/after-install.sh b/resources/linux/packaging/after-install.sh index 393bfb6a89c..a5b598e2bf2 100755 --- a/resources/linux/packaging/after-install.sh +++ b/resources/linux/packaging/after-install.sh @@ -12,6 +12,13 @@ set -e link="/usr/bin/orca-ide" for dir in /opt/Orca /opt/orca-ide /opt/orca; do + sandbox="$dir/chrome-sandbox" + if [ -f "$sandbox" ]; then + # Why: packaged Linux installs must leave Chromium's sandbox helper usable + # on hosts where unprivileged user namespaces are unavailable. + chmod 4755 "$sandbox" || true + fi + shim="$dir/resources/bin/orca-ide" if [ -x "$shim" ]; then # Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide. diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index b36b2fd2109..8370ab87ca4 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -4,7 +4,7 @@ description: >- Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts, terminals, repos, automations, worktree comments, and the browser embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli", - "Orca worktree", "child worktree", "spawn codex/claude in a worktree", + "Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree", "read/wait/send Orca terminal", "terminal send", "Orca browser", or "control the browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs, Playwright, or Computer Use when the task touches Orca-managed state. Use @@ -61,6 +61,7 @@ orca worktree create --name child-task --agent codex --prompt "hi" --json orca worktree create --name independent-task --no-parent --json orca worktree set --worktree id: --display-name "My Task" --json orca worktree set --worktree active --comment "reproduced bug; testing fix" --json +orca worktree set --worktree active --workspace-status in-review --json orca worktree rm --worktree id: --force --json ``` @@ -108,6 +109,8 @@ orca worktree set --worktree active --comment "fix implemented; running integrat Update after meaningful state changes such as repro, fix, validation, handoff, or blocker. Keep comments short/current; failures are best-effort unless Orca state was requested. +Card status uses `--workspace-status `; defaults are `todo`, `in-progress`, `in-review`, `completed`. + ## Terminals Common commands: diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 57c79285712..5b0fc73648f 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -14,7 +14,18 @@ const { } = vi.hoisted(() => ({ applyAgentStatusHooksEnabledMock: vi.fn(), callMock: vi.fn(), - getCliStatusMock: vi.fn(() => Promise.resolve({ result: { runtime: { reachable: false } } })), + getCliStatusMock: vi.fn(() => + Promise.resolve({ + id: 'test-status', + ok: true, + result: { + app: { running: false, pid: null }, + runtime: { state: 'not_running', reachable: false, runtimeId: null }, + graph: { state: 'not_running' } + }, + _meta: { runtimeId: 'test' } + }) + ), getDefaultUserDataPathMock: vi.fn(), getManagedAgentHookStatusesMock: vi.fn() })) diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index f7ca2d3ea40..43c08542c60 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -20,6 +20,7 @@ import { hookDefinitionHasManagedCommand, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeManagedScript, writeHooksJson, type HooksConfig @@ -167,6 +168,11 @@ describe('createManagedCommandMatcher', () => { ).toBe(true) }) + it('matches encoded Windows launcher commands by decoding their script path', () => { + const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd') + expect(match(command)).toBe(true) + }) + it('matches the legacy per-userData script path AND the new shared ~/.orca path', () => { // Why: install() must sweep old per-userData commands when migrating to // the shared ~/.orca script path, or stale launchers keep failing. @@ -332,6 +338,57 @@ describe('wrapPosixHookCommand', () => { ) }) +describe('wrapWindowsHookCommand', () => { + function decodeWindowsHookCommand(command: string): string { + const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] + expect(encodedCommand).toBeTruthy() + return Buffer.from(encodedCommand!, 'base64').toString('utf16le') + } + + it('invokes the .cmd through an encoded PowerShell command', () => { + const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd') + expect(command).toMatch(/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/) + expect(decodeWindowsHookCommand(command)).toBe( + "& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" + ) + }) + + // Why: a user profile path like `C:\Users\Jane Doe` is the regression from + // #6078 — the raw path used to be split at the space. The wrapper must keep + // the whole path inside the encoded command so shells do not split it. + it('preserves spaces in the script path (user profile with space case)', () => { + const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd') + expect(decodeWindowsHookCommand(cmd)).toBe( + "& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" + ) + }) + + it('keeps cmd.exe percent expansion and caret escapes out of the command line', () => { + const cmd = wrapWindowsHookCommand('C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd') + expect(cmd).not.toContain('%ORCA_TEST%') + expect(cmd).not.toContain('^') + expect(decodeWindowsHookCommand(cmd)).toBe( + "& 'C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd'; exit $LASTEXITCODE" + ) + }) + + it.skipIf(process.platform !== 'win32')( + 'executes a script path containing a cmd.exe caret literally', + () => { + const scriptDir = join(tmpDir, 'home with ^ caret', '.orca', 'agent-hooks') + mkdirSync(scriptDir, { recursive: true }) + const scriptPath = join(scriptDir, 'codex-hook.cmd') + writeFileSync(scriptPath, '@echo off\r\nexit /b 7\r\n', 'utf-8') + + const result = spawnSync('cmd.exe', ['/d', '/c', wrapWindowsHookCommand(scriptPath)], { + env: { ...process.env, ORCA_WRAP_TEST: 'expanded' } + }) + + expect(result.status).toBe(7) + } + ) +}) + describe('buildWindowsAgentHookPostCommand', () => { it('forces UTF-8 for redirected hook stdin and POST bodies', () => { const command = buildWindowsAgentHookPostCommand('codex') diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 07f8e834d9d..a533fb0ed1a 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -75,11 +75,25 @@ export function createManagedCommandMatcher( if (!command) { return false } - const normalizedCommand = command.replaceAll('\\', '/') + const decodedCommand = decodePowerShellEncodedCommand(command) + const searchText = decodedCommand ? `${command}\n${decodedCommand}` : command + const normalizedCommand = searchText.replaceAll('\\', '/') return needles.some((needle) => normalizedCommand.includes(needle)) } } +function decodePowerShellEncodedCommand(command: string): string | null { + const match = command.match(/\s-EncodedCommand\s+(\S+)/i) + if (!match) { + return null + } + try { + return Buffer.from(match[1], 'base64').toString('utf16le') + } catch { + return null + } +} + // Why: prod, dev, and parallel Orca instances must write the same managed // settings entry instead of racing between per-userData script paths. export function getSharedManagedScriptPath(scriptFileName: string): string { @@ -106,6 +120,21 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record# candidate path. Legacy file-based sessions use + // real filesystem paths and fall through to the JSON parser. + const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path) + if (sqliteCandidate) { + return parseOpenCodeSqliteSession({ + dbPath: sqliteCandidate.dbPath, + sessionId: sqliteCandidate.sessionId, + platform + }) + } return parseOpenCodeSessionFile(candidate.file, platform) + } case 'grok': return parseGrokSessionFile(candidate.file, platform) case 'hermes': diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts new file mode 100644 index 00000000000..19581fc8761 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseCodexSessionFile } from './session-scanner-codex-parser' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('parseCodexSessionFile', () => { + it('does not double-count usage when token count formats switch', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '06', '18', 'rollout-token-switch.jsonl') + await mkdir(dirname(sessionPath), { recursive: true }) + + await writeFile( + sessionPath, + jsonLines([ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'token-format-switch', cwd: '/repo/app' } + }, + { + timestamp: '2026-06-18T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Measure Codex token totals' }] + } + }, + { + timestamp: '2026-06-18T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 70, + cached_input_tokens: 20, + output_tokens: 30, + total_tokens: 100 + } + } + } + }, + { + timestamp: '2026-06-18T10:00:03.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 90, + cached_input_tokens: 25, + output_tokens: 60, + total_tokens: 150 + } + } + } + } + ]) + ) + + const sessionStat = await stat(sessionPath) + const session = await parseCodexSessionFile( + { + path: sessionPath, + mtimeMs: sessionStat.mtimeMs, + modifiedAt: sessionStat.mtime.toISOString() + }, + 'darwin', + root + ) + + expect(session?.totalTokens).toBe(150) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts new file mode 100644 index 00000000000..5435b2eaf1c --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -0,0 +1,273 @@ +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types' +import { + asRecord, + extractContentText, + extractGitBranch, + extractModel, + extractString, + normalizeCodexUsage, + normalizeTitleText, + parseJsonObject, + subtractCodexUsage +} from './session-scanner-values' + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +type CodexSessionIndexTitleCacheEntry = { + signature: string + titles: Map +} + +const codexSessionIndexTitleCache = new Map>() + +export async function parseCodexSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + codexHome: string | null = null +): Promise { + const accumulator = createAccumulator({ + agent: 'codex', + file, + sessionId: sessionIdFromFileName(file.path) + }) + let previousTotals: CodexUsageSnapshot | null = null + + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + + updateTimeline(accumulator, extractString(record.timestamp)) + + const payload = asRecord(record.payload) + if (record.type === 'session_meta' && payload) { + if (isCodexWorkerSession(payload)) { + // Why: Codex writes internal worker/sub-agent transcripts into the same + // history tree; AI Vault should show user-started sessions only. + return null + } + const sessionId = extractString(payload.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + const indexedTitle = + extractCodexSessionMetadataTitle(payload) ?? + (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId)) + if (indexedTitle) { + accumulator.title = indexedTitle + } + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch + continue + } + + if (record.type === 'turn_context' && payload) { + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + continue + } + + if (!payload) { + continue + } + + if (record.type === 'response_item' && payload.type === 'message') { + accumulator.messageCount++ + if (payload.role === 'user' && !accumulator.title) { + accumulator.title = extractContentText(payload.content) + } + addPreviewContent( + accumulator, + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', + payload.content, + record.timestamp + ) + continue + } + + if (record.type !== 'event_msg') { + continue + } + + if (payload.type === 'user_message') { + accumulator.messageCount++ + if (!accumulator.title) { + accumulator.title = extractContentText(payload.message) + } + addPreviewContent(accumulator, 'user', payload.message, record.timestamp) + continue + } + + if (payload.type === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + continue + } + + if (payload.type !== 'token_count') { + continue + } + + const info = asRecord(payload.info) + if (!info) { + continue + } + const totalUsage = normalizeCodexUsage(info.total_token_usage) + const lastUsage = normalizeCodexUsage(info.last_token_usage) + let delta: CodexUsageSnapshot | null = null + if (totalUsage) { + delta = subtractCodexUsage(totalUsage, previousTotals) + previousTotals = totalUsage + } else if (lastUsage) { + delta = lastUsage + previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage + } + if (delta) { + accumulator.totalTokens += delta.totalTokens + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + } + + return finalizeSession(accumulator, platform, { codexHome }) +} + +function addCodexUsage( + base: CodexUsageSnapshot, + increment: CodexUsageSnapshot +): CodexUsageSnapshot { + return { + inputTokens: base.inputTokens + increment.inputTokens, + cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens, + outputTokens: base.outputTokens + increment.outputTokens, + reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens, + totalTokens: base.totalTokens + increment.totalTokens + } +} + +function extractCodexThreadSource(payload: Record): string | null { + return extractString(payload.thread_source) ?? extractString(payload.threadSource) +} + +function isCodexWorkerSession(payload: Record): boolean { + const threadSource = extractCodexThreadSource(payload) + if (threadSource) { + return threadSource.toLowerCase() !== 'user' + } + + const source = asRecord(payload.source) + return Boolean(asRecord(source?.subagent)) +} + +function extractCodexSessionMetadataTitle(payload: Record): string | null { + return ( + normalizeTitleText(extractString(payload.title) ?? '') ?? + normalizeTitleText(extractString(payload.thread_name) ?? '') ?? + normalizeTitleText(extractString(payload.threadName) ?? '') + ) +} + +async function readCodexSessionIndexTitle( + sessionFilePath: string, + codexHome: string | null, + sessionId: string +): Promise { + const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath) + if (!resolvedCodexHome) { + return null + } + const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) + return titleBySessionId.get(sessionId) ?? null +} + +function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { + let currentDir = dirname(sessionFilePath) + while (currentDir && dirname(currentDir) !== currentDir) { + if (basename(currentDir) === 'sessions') { + return dirname(currentDir) + } + currentDir = dirname(currentDir) + } + return null +} + +async function readCodexSessionIndexTitles(codexHome: string): Promise> { + const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE) + let signature: string + try { + const indexStat = await stat(indexPath) + signature = `${indexStat.size}:${indexStat.mtimeMs}` + } catch { + return new Map() + } + + const cached = codexSessionIndexTitleCache.get(codexHome) + if (cached) { + const entry = await cached + if (entry.signature === signature) { + return entry.titles + } + } + + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({ + signature, + titles + })) + codexSessionIndexTitleCache.set(codexHome, pending) + return (await pending).titles +} + +async function readCodexSessionIndexTitlesFromDisk( + indexPath: string +): Promise> { + const titleBySessionId = new Map() + try { + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const sessionId = extractString(record.id) + const title = normalizeTitleText(extractString(record.thread_name) ?? '') + if (sessionId && title) { + titleBySessionId.set(sessionId, title) + } + } + } catch { + // Codex creates the index opportunistically; older homes may only have raw transcripts. + } + return titleBySessionId +} diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index b40b5f591ca..6f99151f059 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts new file mode 100644 index 00000000000..24069c7a0f2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -0,0 +1,73 @@ +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { dirname, join } from 'path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { listOpenCodeDatabases } from '../opencode-usage/scanner' +import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const OPENCODE_STORAGE_DIR = join( + process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), + 'storage' +) + +export function opencodeDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const storageDirs = opencodeStorageDirs(options, wslHomeDirs) + return storageDirs.map(async (storageDir, index) => + discoverOpenCodeSessions({ + storageDir, + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), + limitPerAgent: limit, + issues + }) + ) +} + +function opencodeStorageDirs( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[] +): string[] { + return [ + options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage')) + ] +} + +async function opencodeDbPathsForSource( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + storageDir: string, + sourceIndex: number +): Promise { + if (options.opencodeDbPaths) { + return sourceIndex === 0 ? options.opencodeDbPaths : [] + } + // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. + if (sourceIndex === 0 && options.opencodeStorageDir) { + return listOpenCodeDatabasesInDirectory(dirname(storageDir)) + } + if (sourceIndex === 0) { + return listOpenCodeDatabases() + } + const wslHomeDir = wslHomeDirs[sourceIndex - 1] + return wslHomeDir + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) + : [] +} + +async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { + try { + const entries = await readdir(dataDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(dataDir, entry.name)) + .sort() + } catch { + return [] + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts new file mode 100644 index 00000000000..4ae4ea22284 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -0,0 +1,215 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import Database from '../sqlite/sync-database' + +let tempRoots: string[] = [] +let tempDbDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + for (const dir of tempDbDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempRoots = [] + tempDbDirs = [] +}) + +function isolatedScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [] as readonly string[], + grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') + } +} + +function createTempOpenCodeDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-')) + tempDbDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER, + model TEXT, + agent TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `) +} + +describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => { + it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const opencodeDataDir = join(root, 'custom-opencode') + const opencodeStorageDir = join(opencodeDataDir, 'storage') + await mkdir(opencodeStorageDir, { recursive: true }) + + const dbPath = join(opencodeDataDir, 'opencode.db') + const db = new Database(dbPath) + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode', + 'Custom SQLite session', '1.0.0', + 1777634010000, 1777634011000, NULL, 'build', 0, + 8, 13, 21, 34, 0)` + ).run() + db.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeStorageDir, + opencodeDbPaths: undefined, + platform: 'darwin', + limit: 50 + }) + + const session = result.sessions.find((s) => s.sessionId === 'custom-db-session') + expect(session).toBeDefined() + expect(session!.agent).toBe('opencode') + expect(session!.title).toBe('Custom SQLite session') + expect(session!.filePath).toBe(dbPath) + expect(session!.totalTokens).toBe(42) + }) + + it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + // Legacy file session under storage/session//.json + await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true }) + await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true }) + await writeFile( + join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'), + JSON.stringify({ + id: 'legacy-session', + directory: '/tmp/legacy', + title: 'Legacy file session', + time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 } + }) + ) + await writeFile( + join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'), + JSON.stringify({ + role: 'user', + summary: { title: 'Legacy file session' }, + time: { created: 1_777_634_000_000 }, + tokens: { input: 5, output: 2 } + }) + ) + + // SQLite session — same sessionId as the legacy file (dedup should keep SQLite) + const { db, path: dbPath } = createTempOpenCodeDb() + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0', + 1777634002000, 1777634003000, ?, 'build', 0, + 100, 40, 10, 5, 0)` + ).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' })) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } })) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ type: 'text', text: 'sqlite hello' })) + db.close() + + // A second SQLite-only session + const { db: db2, path: dbPath2 } = createTempOpenCodeDb() + applyOpenCodeSchema(db2) + db2 + .prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0', + 1777634004000, 1777634005000, NULL, 'build', 0, + 50, 20, 0, 0, 0)` + ) + .run() + db2.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeDbPaths: [dbPath, dbPath2], + platform: 'darwin', + limit: 50 + }) + + const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode') + const sessionIds = opencodeSessions.map((s) => s.sessionId).sort() + expect(sessionIds).toEqual(['legacy-session', 'sqlite-only']) + + // Why: dedup keeps the SQLite entry (newer time_updated, source of truth) + const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session') + expect(legacyEntry).toBeDefined() + expect(legacyEntry!.title).toBe('SQLite session') + expect(legacyEntry!.cwd).toBe('/tmp/sqlite') + expect(legacyEntry!.filePath).toBe(dbPath) + expect(legacyEntry!.totalTokens).toBe(150) + expect(legacyEntry!.resumeCommand).toBe( + "cd '/tmp/sqlite' && opencode --session 'legacy-session'" + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts new file mode 100644 index 00000000000..3c465007992 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts @@ -0,0 +1,242 @@ +import { basename, extname, join } from 'path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { discoverFiles } from './session-scanner-discovery' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import type { + FileWithMtime, + SessionFileCandidate, + SessionFileDiscovery +} from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: keep the SQLite discovery + dedup layer separate from the parser so +// each file stays under the max-lines lint rule and the discovery layer can +// be tested in isolation. + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionListQuery(db: SyncDatabase): string { + const modelSelect = sessionColumnSelect(db, 'model') + const agentSelect = sessionColumnSelect(db, 'agent') + const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'] + const tokenSelects = tokenColumns + .map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`) + .join(', ') + const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0' + const parentIdPredicate = columnExists(db, 'session', 'parent_id') + ? 'AND s.parent_id IS NULL' + : '' + const archivedPredicate = columnExists(db, 'session', 'time_archived') + ? 'AND s.time_archived IS NULL' + : '' + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${modelSelect} AS model_json, ${agentSelect} AS agent, + ${tokenSelects}, ${costSelect} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} + ORDER BY s.time_updated DESC + LIMIT ?` +} + +function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + return { + agent: 'opencode' as AiVaultAgent, + file: { + path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + codexHome: null + } +} + +function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] { + const candidatesBySessionId = new Map() + for (const candidate of candidates) { + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + if (!parsed) { + continue + } + const previous = candidatesBySessionId.get(parsed.sessionId) + if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) { + candidatesBySessionId.set(parsed.sessionId, candidate) + } + } + return [...candidatesBySessionId.values()].sort((left, right) => { + return right.file.mtimeMs - left.file.mtimeMs + }) +} + +/** + * List OpenCode sessions from one or more SQLite databases as synthetic + * `SessionFileCandidate` entries. Each candidate's file path is a synthetic + * `#` string that the parser dispatcher routes to + * `parseOpenCodeSqliteSession`. Databases that lack the `session` table are + * silently skipped; errors are recorded as scan issues. + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limit - Maximum number of sessions to return per database. + * @param args.issues - Collected scan issues to append errors to. + * @returns Array of synthetic candidates sorted by `time_updated` DESC. + */ +export async function listOpenCodeSqliteSessions(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const candidates: SessionFileCandidate[] = [] + for (const dbPath of args.dbPaths) { + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + continue + } + const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[] + for (const row of rows) { + candidates.push(rowToCandidate(row, dbPath)) + } + } catch (err) { + args.issues.push({ + agent: 'opencode', + path: dbPath, + message: errorMessage(err) + }) + } finally { + db?.close() + } + } + return dedupeAndSortSqliteCandidates(candidates) +} + +// Why: extract the sessionId from a legacy file path like +// storage/session//.json. Falls back to the filename +// without extension when the opencode id format doesn't match a UUID. +function sessionIdFromLegacyFilePath(filePath: string): string { + return basename(filePath, extname(filePath)) +} + +/** + * Discover OpenCode sessions from both the legacy file layout and the SQLite + * DB, deduplicating at the file level before parsing. On mixed installs the + * same session may appear once via a stale legacy JSON file and once via the + * SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries + * whose sessionId matches a SQLite entry are dropped. Legacy installs without + * the `session` table fall through to the file scanner unchanged. + * @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`). + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limitPerAgent - Maximum number of candidates per source. + * @param args.issues - Collected scan issues to append errors to. + * @returns A `SessionFileDiscovery` with deduplicated file entries. + */ +export async function discoverOpenCodeSessions(args: { + storageDir: string + dbPaths: readonly string[] + limitPerAgent: number + issues: AiVaultScanIssue[] +}): Promise { + const [fileDiscovery, sqliteCandidates] = await Promise.all([ + discoverFiles({ + rootDir: join(args.storageDir, 'session'), + limit: args.limitPerAgent, + agent: 'opencode', + issues: args.issues, + extensions: ['.json'] + }), + listOpenCodeSqliteSessions({ + dbPaths: args.dbPaths, + limit: args.limitPerAgent, + issues: args.issues + }) + ]) + + const sqliteFiles = sqliteCandidates.map((c) => c.file) + // Why: on mixed installs the same OpenCode session may appear once via the + // SQLite DB and once via a stale legacy JSON file. SQLite is the source of + // truth on 1.17.x, so drop file-based duplicates when a SQLite entry with + // the same sessionId already exists. Deduping at the file level also avoids + // parsing the same session twice. + if (sqliteFiles.length === 0) { + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: fileDiscovery.files + } + } + const sqliteSessionIds = new Set() + for (const file of sqliteFiles) { + const parsed = splitOpenCodeSqliteCandidate(file.path) + if (parsed) { + sqliteSessionIds.add(parsed.sessionId) + } + } + const dedupedFileDiscovery: FileWithMtime[] = [] + for (const file of fileDiscovery.files) { + if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) { + dedupedFileDiscovery.push(file) + } + } + + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: [...dedupedFileDiscovery, ...sqliteFiles] + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts new file mode 100644 index 00000000000..db9c60f0b70 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + buildOpenCodeSqliteCandidatePath, + looksLikeOpenCodeSqliteCandidate, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' + +describe('splitOpenCodeSqliteCandidate', () => { + it('splits a synthetic db#sessionId path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc') + expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' }) + }) + + it('splits a stable-db path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz') + expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' }) + }) + + it('rejects a path whose db basename is not opencode*.db', () => { + expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull() + expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull() + }) + + it('rejects a path without a separator', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull() + }) + + it('rejects an empty sessionId', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull() + }) +}) + +describe('looksLikeOpenCodeSqliteCandidate', () => { + it('returns true for a synthetic path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true) + }) + + it('returns false for a real filesystem path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false) + }) +}) + +describe('buildOpenCodeSqliteCandidatePath', () => { + it('joins dbPath and sessionId with #', () => { + expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts new file mode 100644 index 00000000000..fb1415af04e --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts @@ -0,0 +1,56 @@ +import { basename } from 'path' + +// Why: keep the synthetic candidate-path helpers separate from the SQLite +// discovery/parser so both the scanner and the agent-parser dispatcher can +// import them without pulling in the SyncDatabase dependency. + +const OPENCODE_SQLITE_PATH_SEPARATOR = '#' + +/** + * Build a synthetic candidate path that encodes the SQLite DB path and session ID + * as `#`. Used by the discovery layer so SQLite-backed + * sessions flow through the same FileWithMtime pipeline as file-backed ones. + * @param dbPath - Absolute path to the opencode.db file. + * @param sessionId - The OpenCode session ID (primary key in the session table). + * @returns The synthetic candidate path string. + */ +export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string { + return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}` +} + +/** + * Parse a synthetic candidate path back into its DB path and session ID parts. + * Validates that the DB basename matches `opencode*.db` so real filesystem paths + * that happen to contain `#` are never misrouted to the SQLite parser. + * @param candidatePath - The synthetic path to parse. + * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise. + */ +export function splitOpenCodeSqliteCandidate( + candidatePath: string +): { dbPath: string; sessionId: string } | null { + const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) + if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { + return null + } + const dbPath = candidatePath.slice(0, separatorIndex) + const sessionId = candidatePath.slice(separatorIndex + 1) + if (!dbPath || !sessionId) { + return null + } + // Why: OpenCode DB files are named opencode*.db; reject anything else so we + // never misroute a real filesystem path that happens to contain '#'. + if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) { + return null + } + return { dbPath, sessionId } +} + +/** + * Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite + * candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null). + * @param candidatePath - The path to test. + * @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise. + */ +export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean { + return splitOpenCodeSqliteCandidate(candidatePath) !== null +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts new file mode 100644 index 00000000000..96ca6ea361a --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -0,0 +1,499 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + workspace_id TEXT, + path TEXT, + agent TEXT, + model TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT, + icon_url_override TEXT + ); + `) +} + +function applyMinimalOpenCodeSchema(db: Database.Database): void { + db.exec(`CREATE TABLE session ( + id TEXT PRIMARY KEY, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + );`) +} + +function insertSession( + db: Database.Database, + args: { + id: string + title?: string + directory?: string + timeCreated: number + timeUpdated: number + parentId?: string | null + timeArchived?: number | null + model?: string | null + agent?: string | null + tokensInput?: number + tokensOutput?: number + tokensReasoning?: number + tokensCacheRead?: number + cost?: number + } +): void { + db.prepare( + `INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, + time_created, time_updated, time_archived, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0', + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, 0)` + ).run( + args.id, + args.parentId ?? null, + `slug-${args.id}`, + args.directory ?? '/tmp/opencode', + args.title ?? 'OpenCode title', + args.timeCreated, + args.timeUpdated, + args.timeArchived ?? null, + args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }), + args.agent ?? 'build', + args.cost ?? 0, + args.tokensInput ?? 100, + args.tokensOutput ?? 40, + args.tokensReasoning ?? 10, + args.tokensCacheRead ?? 5 + ) +} + +function insertMessage( + db: Database.Database, + args: { + id: string + sessionId: string + role: 'user' | 'assistant' + timeCreated: number + summaryTitle?: string | null + summaryBody?: string | null + } +): void { + const data = JSON.stringify({ + role: args.role, + time: { created: args.timeCreated }, + agent: 'build', + summary: + args.summaryTitle || args.summaryBody + ? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null } + : undefined + }) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?)` + ).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data) +} + +function insertPart( + db: Database.Database, + args: { + id: string + messageId: string + sessionId: string + timeCreated: number + type?: 'text' | 'tool' | 'reasoning' + text?: string + } +): void { + const data = JSON.stringify({ + type: args.type ?? 'text', + text: args.text ?? 'hello world' + }) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data) +} +describe('listOpenCodeSqliteSessions', () => { + it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_old', + title: 'Old', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_new', + title: 'New', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(2) + expect(candidates[0].agent).toBe('opencode') + expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new')) + expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old')) + }) + + it('dedups matching session ids across databases and keeps the newest row', async () => { + const { db: oldDb, path: oldPath } = createTempDb() + applyOpenCodeSchema(oldDb) + insertSession(oldDb, { + id: 'ses_duplicate', + title: 'Old duplicate', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + oldDb.close() + + const { db: newDb, path: newPath } = createTempDb() + applyOpenCodeSchema(newDb) + insertSession(newDb, { + id: 'ses_duplicate', + title: 'New duplicate', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + newDb.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [oldPath, newPath], + limit: 10, + issues: [] + }) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate')) + }) + it('excludes archived and child sessions', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_normal', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_archived', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_002_000, + timeArchived: 1_777_634_002_500 + }) + insertSession(db, { + id: 'ses_child', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_003_000, + parentId: 'ses_normal' + }) + db.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates.map((c) => c.file.path)).toEqual([ + buildOpenCodeSqliteCandidatePath(path, 'ses_normal') + ]) + }) + + it('returns [] when the session table is missing (legacy install)', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates).toEqual([]) + }) + + it('records an issue when the DB file does not exist', async () => { + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: ['/nonexistent/opencode.db'], + limit: 10, + issues + }) + expect(candidates).toEqual([]) + expect(issues).toHaveLength(1) + expect(issues[0].agent).toBe('opencode') + expect(issues[0].path).toBe('/nonexistent/opencode.db') + }) + + it('lists sessions from a minimal readable session table', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal')) + }) +}) + +describe('parseOpenCodeSqliteSession', () => { + it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_1', + title: 'OpenCode title', + directory: '/tmp/opencode', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + tokensInput: 100, + tokensOutput: 40, + tokensReasoning: 10, + tokensCacheRead: 5, + cost: 0.01 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_1', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryTitle: 'OpenCode title' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_1', + timeCreated: 1_777_634_000_500, + text: 'Plan the work' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_1', + role: 'assistant', + timeCreated: 1_777_634_000_900 + }) + insertPart(db, { + id: 'prt_2', + messageId: 'msg_2', + sessionId: 'ses_1', + timeCreated: 1_777_634_001_000, + text: 'Done' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.agent).toBe('opencode') + expect(session!.sessionId).toBe('ses_1') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode title') + expect(session!.cwd).toBe('/tmp/opencode') + expect(session!.model).toBe('glm-5.2') + expect(session!.totalTokens).toBe(150) + expect(session!.messageCount).toBe(2) + expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString()) + expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString()) + expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'") + expect(session!.previewMessages).toHaveLength(2) + expect(session!.previewMessages[0].text).toBe('Plan the work') + expect(session!.previewMessages[0].role).toBe('user') + expect(session!.previewMessages[1].text).toBe('Done') + expect(session!.previewMessages[1].role).toBe('assistant') + }) + + it('falls back to summary.body for title when session.title is empty', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_2', + title: '', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_2', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryBody: 'fallback title from summary' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_2', + timeCreated: 1_777_634_000_500, + text: 'hello' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_2', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.title).toBe('fallback title from summary') + }) + + it('returns null when the session id is not found', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_real', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_missing', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('returns null when the DB has no session table', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('parses a minimal readable session table without optional columns or messages', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_minimal', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.sessionId).toBe('ses_minimal') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode ses_mini') + expect(session!.cwd).toBeNull() + expect(session!.model).toBeNull() + expect(session!.messageCount).toBe(0) + expect(session!.totalTokens).toBe(0) + expect(session!.previewMessages).toEqual([]) + }) + + it('extracts model from older modelID schema', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_3', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + model: JSON.stringify({ modelID: 'claude-sonnet-4-5' }) + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_3', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.model).toBe('claude-sonnet-4-5') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts new file mode 100644 index 00000000000..f3a6dbcd055 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -0,0 +1,263 @@ +import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import { normalizeTitleText } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: OpenCode 1.17.x migrated session storage from per-session JSON files +// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module +// parses individual sessions from the DB into AiVaultSession objects. The +// discovery layer (listing candidates) lives in +// session-scanner-opencode-sqlite-discovery.ts. + +const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +type PreviewRow = { + role: string | null + part_data: string + time_created: number + summary_title: string | null + summary_body: string | null +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + // Why: belt-and-suspenders guard so a bug in the SELECT list can never + // mutate the user's opencode.db. + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionQuery(db: SyncDatabase): string { + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${sessionColumnSelect(db, 'model')} AS model_json, + ${sessionColumnSelect(db, 'agent')} AS agent, + ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, + ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, + ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, + ${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read, + ${sessionNumberColumnSelect(db, 'cost')} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE s.id = ? + LIMIT 1` +} + +function extractModelId(modelJson: string | null): string | null { + if (!modelJson) { + return null + } + try { + const parsed = JSON.parse(modelJson) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + // Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}. + // Older schemas used {"modelID":"..."}; accept both. + return ( + (typeof record.id === 'string' && record.id.trim()) || + (typeof record.modelID === 'string' && record.modelID.trim()) || + null + ) + } catch { + return null + } +} + +function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] { + if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') { + return role + } + return 'unknown' +} + +function extractPartText(partData: string): string | null { + try { + const parsed = JSON.parse(partData) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + if (typeof record.text === 'string') { + return record.text + } + return null + } catch { + return null + } +} + +function buildPreviewQuery(db: SyncDatabase): string | null { + if ( + !canCountOpenCodeMessages(db) || + !tableExists(db, 'part') || + !columnExists(db, 'message', 'id') || + !columnExists(db, 'part', 'message_id') || + !columnExists(db, 'part', 'time_created') || + !columnExists(db, 'part', 'data') + ) { + return null + } + return `SELECT json_extract(m.data, '$.role') AS role, + p.data AS part_data, + p.time_created, + json_extract(m.data, '$.summary.title') AS summary_title, + json_extract(m.data, '$.summary.body') AS summary_body + FROM message m + JOIN part p ON p.message_id = m.id + WHERE m.session_id = ? + AND json_extract(m.data, '$.role') IN ('user','assistant') + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created DESC + LIMIT ?` +} + +/** + * Parse a single OpenCode session from the SQLite database into an + * `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost) + * and up to 5 preview messages by joining the `message` and `part` tables. + * The database is opened read-only with `PRAGMA query_only = ON` as a + * belt-and-suspenders guard against mutations. + * @param args.dbPath - Absolute path to the opencode.db file. + * @param args.sessionId - The session ID (primary key in the `session` table). + * @param args.platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if the session does not exist + * or the database lacks the required schema. + */ +export async function parseOpenCodeSqliteSession(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): Promise { + const { dbPath, sessionId, platform } = args + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + return null + } + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } + + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + // Why: discovery uses a synthetic db#session path only for parser routing. + // The UI's log open/reveal actions need a real filesystem path. + const accumulator = createAccumulator({ + agent: 'opencode', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model_json) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) + + const previewSql = buildPreviewQuery(db) + if (previewSql) { + const previewRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[] + // Why: query returns newest-first; push in chronological order so the + // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT + // messages. + for (let i = previewRows.length - 1; i >= 0; i--) { + const previewRow = previewRows[i] + if (!previewRow) { + continue + } + const text = extractPartText(previewRow.part_data) + if (!text) { + continue + } + addPreviewMessage(accumulator, { + role: mapPreviewRole(previewRow.role), + text, + timestamp: previewRow.time_created + }) + if (previewRow.role === 'user' && !accumulator.title) { + accumulator.title = + normalizeTitleText(previewRow.summary_title ?? '') || + normalizeTitleText(previewRow.summary_body ?? '') + } + } + } + + return finalizeSession(accumulator, platform) + } finally { + db?.close() + } +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index fced9ab1f6a..dc062f5ab3e 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'fs' import { readFile } from 'fs/promises' import { createInterface } from 'readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, createAccumulator, @@ -16,14 +16,10 @@ import { asRecord, claudeUsageTotal, extractContentText, - extractGitBranch, extractMessageText, - extractModel, extractString, - normalizeCodexUsage, normalizeTitleText, parseJsonObject, - subtractCodexUsage, tokenTotal } from './session-scanner-values' @@ -99,139 +95,6 @@ export async function parseClaudeSessionFile( return finalizeSession(accumulator, platform) } -export async function parseCodexSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform, - codexHome: string | null = null -): Promise { - const accumulator = createAccumulator({ - agent: 'codex', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let previousTotals: CodexUsageSnapshot | null = null - - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - - updateTimeline(accumulator, extractString(record.timestamp)) - - const payload = asRecord(record.payload) - if (record.type === 'session_meta' && payload) { - if (isCodexWorkerSession(payload)) { - // Why: Codex writes internal worker/sub-agent transcripts into the same - // history tree; AI Vault should show user-started sessions only. - return null - } - const sessionId = extractString(payload.id) - if (sessionId) { - accumulator.sessionId = sessionId - } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch - continue - } - - if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - continue - } - - if (!payload) { - continue - } - - if (record.type === 'response_item' && payload.type === 'message') { - accumulator.messageCount++ - if (payload.role === 'user' && !accumulator.title) { - accumulator.title = extractContentText(payload.content) - } - addPreviewContent( - accumulator, - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', - payload.content, - record.timestamp - ) - continue - } - - if (record.type !== 'event_msg') { - continue - } - - if (payload.type === 'user_message') { - accumulator.messageCount++ - if (!accumulator.title) { - accumulator.title = extractContentText(payload.message) - } - addPreviewContent(accumulator, 'user', payload.message, record.timestamp) - continue - } - - if (payload.type === 'agent_message') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) - continue - } - - if (payload.type !== 'token_count') { - continue - } - - const info = asRecord(payload.info) - if (!info) { - continue - } - const totalUsage = normalizeCodexUsage(info.total_token_usage) - const lastUsage = normalizeCodexUsage(info.last_token_usage) - const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage - if (totalUsage) { - previousTotals = totalUsage - } - if (delta) { - accumulator.totalTokens += delta.totalTokens - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - } - - return finalizeSession(accumulator, platform, { codexHome }) -} - -function extractCodexThreadSource(payload: Record): string | null { - return extractString(payload.thread_source) ?? extractString(payload.threadSource) -} - -function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractCodexThreadSource(payload) - if (threadSource) { - return threadSource.toLowerCase() !== 'user' - } - - const source = asRecord(payload.source) - return Boolean(asRecord(source?.subagent)) -} - export async function parseGeminiSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index e5b5f7f371b..b4e376e606d 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' +import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' import { normalizePiSessionsDir } from './session-scanner-values' @@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const OPENCODE_STORAGE_DIR = join( - process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), - 'storage' -) const GROK_SESSIONS_DIR = join( process.env.GROK_HOME?.trim() || join(homedir(), '.grok'), 'sessions' @@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: { ...(options.additionalCodexSessionsDirs ?? []) ]) - return Promise.all([ + return Promise.all([ + // Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a + // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) + // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. + ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues), ...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues), @@ -109,7 +110,6 @@ function standardDiscoveries( discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] }) ), ...cursorDiscoveries(options, wslHomeDirs, limit, issues), - ...opencodeDiscoveries(options, wslHomeDirs, limit, issues), ...grokDiscoveries(options, wslHomeDirs, limit, issues), ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), @@ -139,21 +139,6 @@ function cursorDiscoveries( ) } -function opencodeDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs( - join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'), - wslHomeDirs, - ['.local', 'share', 'opencode', 'storage', 'session'] - ).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] }) - ) -} - function grokDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 8bd58ad6407..88eae73eb2e 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -5,7 +5,7 @@ const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024 const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512 const FIELD_BLANK_SCAN_LIMIT = 1024 -const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for' +const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions' const XML_INSTRUCTIONS_PREFIX = '' const HIDDEN_TEXT_BLOCKS = [ diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index e5330391f0e..fc7cf389b7d 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,9 @@ export type AiVaultScanOptions = { copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string + // Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB + // here so they don't depend on the real ~/.local/share/opencode. + opencodeDbPaths?: readonly string[] grokSessionsDir?: string devinTranscriptsDir?: string hermesSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index ae0bc7ce106..a4c5cf6540f 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + // Why: prevent the SQLite scanner from picking up the real + // ~/.local/share/opencode/opencode.db during tests. + opencodeDbPaths: [] as readonly string[], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), @@ -110,7 +113,7 @@ describe('scanAiVaultSessions', () => { type: 'message', role: 'user', content: [ - { type: 'text', text: '# AGENTS.md instructions for /repo/app ' } + { type: 'text', text: '# AGENTS.md instructions\n\nrepo policy' } ] } }), @@ -162,6 +165,15 @@ describe('scanAiVaultSessions', () => { }) ].join('\n') ) + await writeFile( + join(root, 'session_index.jsonl'), + jsonLines([ + { + id: '019f0000-1111-7222-8333-444444444444', + thread_name: 'Indexed Codex resume picker title' + } + ]) + ) const result = await scanAiVaultSessions({ ...roots, @@ -171,7 +183,7 @@ describe('scanAiVaultSessions', () => { expect(result.issues).toEqual([]) expect(result.sessions).toHaveLength(2) expect(result.sessions.map((session) => session.title).sort()).toEqual([ - 'Fix the resume picker filters', + 'Indexed Codex resume picker title', 'Vault polish pass' ]) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f07a769562d..484dbbc6339 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000 const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 const SESSION_PARSE_CONCURRENCY = 8 +/** + * Scan all supported AI agent session stores and return a unified, sorted, + * deduplicated list of sessions for the AI Vault panel. Discovers sessions + * from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based + * stores (OpenCode 1.17.x). Results are sorted by session sort time DESC + * and truncated to `limit`. + * @param options - Optional scan configuration (limits, custom dirs, platform). + * @returns The list of sessions, scan issues, and a timestamp. + */ export async function scanAiVaultSessions( options: AiVaultScanOptions = {} ): Promise { diff --git a/src/main/app-icon.test.ts b/src/main/app-icon.test.ts index ccef8667279..520fcbeb150 100644 --- a/src/main/app-icon.test.ts +++ b/src/main/app-icon.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { browserWindowGetAllWindowsMock, @@ -36,11 +37,37 @@ vi.mock('../../resources/app-icons/orca-watercolor.png?asset', () => ({ default: 'watercolor-icon' })) +vi.mock('../../resources/app-icons/orca-watercolor.png?asset&asarUnpack', () => ({ + default: 'watercolor-icon-unpacked' +})) + vi.mock('../../resources/app-icons/orca-blue.png?asset', () => ({ default: 'blue-icon' })) -import { applyAppIcon, getAppIconPath } from './app-icon' +vi.mock('../../resources/app-icons/orca-blue.png?asset&asarUnpack', () => ({ + default: 'blue-icon-unpacked' +})) + +import { applyAppIcon, getAppIconPath, persistMacDockIcon } from './app-icon' + +function waitForQueuedPersistence(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +async function waitForQueuedPersistenceMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function createMockChildProcess(): EventEmitter & { kill: ReturnType } { + const childProcess = new EventEmitter() as EventEmitter & { kill: ReturnType } + childProcess.kill = vi.fn(() => { + childProcess.emit('exit') + return true + }) + return childProcess +} describe('app icon selection', () => { beforeEach(() => { @@ -51,6 +78,10 @@ describe('app icon selection', () => { isMock.dev = false }) + afterEach(() => { + vi.useRealTimers() + }) + it('resolves classic, watercolor, blue, and invalid icon ids', () => { expect(getAppIconPath('classic')).toBe('classic-icon') expect(getAppIconPath('watercolor')).toBe('watercolor-icon') @@ -76,4 +107,345 @@ describe('app icon selection', () => { } expect(windowSetIconMock).toHaveBeenCalledWith(image) }) + + it('persists a custom macOS dock icon to the app bundle for inactive Dock pins', async () => { + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(null) + } + ) + + persistMacDockIcon('watercolor', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/osascript', + expect.arrayContaining(['-e', expect.stringContaining('setIcon:image forFile:appPath')]), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app', + ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked' + }) + }), + expect.any(Function) + ) + }) + + it('clears the AppKit icon and Finder metadata when switching macOS back to classic', async () => { + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(null) + } + ) + + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenNthCalledWith( + 1, + '/usr/bin/osascript', + expect.arrayContaining([ + '-e', + expect.stringContaining('setIcon:(missing value) forFile:appPath') + ]), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app' + }), + timeout: 10_000 + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/xattr', + ['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/xattr', + ['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + }) + + it('warns for non-benign failures when clearing Finder custom icon metadata', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const execFile = vi.fn( + ( + file: string, + args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + if (file !== '/usr/bin/xattr') { + onComplete?.(null) + return + } + onComplete?.(new Error(args[1] === 'com.apple.FinderInfo' ? 'No such xattr' : 'EACCES')) + } + ) + + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + '[app-icon] failed to clear macOS dock icon metadata com.apple.ResourceFork:', + expect.any(Error) + ) + + warnSpy.mockRestore() + }) + + it('warns when the AppKit classic icon reset fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const execFile = vi.fn( + ( + file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(file === '/usr/bin/osascript' ? new Error('reset denied') : null) + } + ) + + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + expect(warnSpy).toHaveBeenCalledWith( + '[app-icon] failed to clear macOS dock icon:', + expect.any(Error) + ) + + warnSpy.mockRestore() + }) + + it('serializes rapid macOS dock icon persistence so the last icon request wins', async () => { + const pendingCallbacks: (() => void)[] = [] + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + pendingCallbacks.push(() => onComplete?.(null)) + } + ) + + persistMacDockIcon('watercolor', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistence() + + persistMacDockIcon('blue', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + persistMacDockIcon('classic', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + + expect(execFile).toHaveBeenCalledTimes(1) + expect(execFile).toHaveBeenCalledWith( + '/usr/bin/osascript', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked' + }) + }), + expect.any(Function) + ) + + pendingCallbacks.shift()?.() + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenCalledTimes(2) + expect(execFile).not.toHaveBeenCalledWith( + '/usr/bin/osascript', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_ICON_PATH: 'blue-icon-unpacked' + }) + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenNthCalledWith( + 2, + '/usr/bin/osascript', + expect.arrayContaining([ + '-e', + expect.stringContaining('setIcon:(missing value) forFile:appPath') + ]), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app' + }), + timeout: 10_000 + }), + expect.any(Function) + ) + pendingCallbacks.shift()?.() + await waitForQueuedPersistence() + + expect(execFile).toHaveBeenCalledTimes(4) + expect(execFile).toHaveBeenNthCalledWith( + 3, + '/usr/bin/xattr', + ['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + expect(execFile).toHaveBeenNthCalledWith( + 4, + '/usr/bin/xattr', + ['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'], + expect.objectContaining({ + timeout: 10_000 + }), + expect.any(Function) + ) + + for (const completeCommand of pendingCallbacks) { + completeCommand() + } + await waitForQueuedPersistence() + }) + + it('continues macOS dock icon persistence when a command never completes', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hungChildProcess = createMockChildProcess() + const execFile = vi.fn( + ( + _file: string, + _args: string[], + optionsOrCallback: unknown, + callback?: (error: Error | null) => void + ) => { + if (execFile.mock.calls.length === 1) { + return hungChildProcess + } + const onComplete = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as (error: Error | null) => void) + : callback + onComplete?.(null) + return undefined + } + ) + + persistMacDockIcon('watercolor', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + await waitForQueuedPersistenceMicrotasks() + + persistMacDockIcon('blue', { + appBundlePath: '/Applications/Orca.app', + execFile, + isDevApp: false, + platform: 'darwin' + }) + + expect(execFile).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10_000) + await waitForQueuedPersistenceMicrotasks() + + expect(hungChildProcess.kill).not.toHaveBeenCalled() + expect(execFile).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1_000) + await waitForQueuedPersistenceMicrotasks() + + expect(warnSpy).toHaveBeenCalledWith('[app-icon] timed out persisting macOS dock icon') + expect(hungChildProcess.kill).toHaveBeenCalledTimes(1) + expect(execFile).toHaveBeenCalledTimes(2) + expect(execFile).toHaveBeenNthCalledWith( + 2, + '/usr/bin/osascript', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_APP_ICON_PATH: 'blue-icon-unpacked' + }), + timeout: 10_000 + }), + expect.any(Function) + ) + + warnSpy.mockRestore() + }) }) diff --git a/src/main/app-icon.ts b/src/main/app-icon.ts index aa52133c6dc..8964b9eeb50 100644 --- a/src/main/app-icon.ts +++ b/src/main/app-icon.ts @@ -1,9 +1,17 @@ +import { + execFile as execFileChildProcess, + type ChildProcess, + type ExecFileOptions +} from 'node:child_process' +import { dirname, resolve } from 'node:path' import { app, BrowserWindow, nativeImage } from 'electron' import { is } from '@electron-toolkit/utils' import classicIcon from '../../resources/icon.png?asset' import classicDevIcon from '../../resources/icon-dev.png?asset' import watercolorIcon from '../../resources/app-icons/orca-watercolor.png?asset' +import watercolorMacDockIcon from '../../resources/app-icons/orca-watercolor.png?asset&asarUnpack' import blueIcon from '../../resources/app-icons/orca-blue.png?asset' +import blueMacDockIcon from '../../resources/app-icons/orca-blue.png?asset&asarUnpack' import { normalizeAppIconId, type AppIconId } from '../shared/app-icon' const APP_ICON_PATHS = { @@ -12,6 +20,59 @@ const APP_ICON_PATHS = { blue: blueIcon } satisfies Record +const MAC_DOCK_ICON_PATHS = { + watercolor: watercolorMacDockIcon, + blue: blueMacDockIcon +} satisfies Record, string> + +type ExecFile = ( + file: string, + args: string[], + optionsOrCallback: ExecFileOptions | ((error: Error | null) => void), + callback?: (error: Error | null) => void +) => unknown + +type MacDockIconChildProcess = Pick + +type PersistMacDockIconOptions = { + appBundlePath?: string + execFile?: ExecFile + isDevApp?: boolean + platform?: NodeJS.Platform +} + +const MAC_DOCK_ICON_SCRIPT = [ + 'use framework "AppKit"', + 'use scripting additions', + 'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"', + 'set iconPath to system attribute "ORCA_APP_ICON_PATH"', + "set image to current application's NSImage's alloc()'s initWithContentsOfFile:iconPath", + 'if image is missing value then error "Orca app icon image could not be loaded"', + "set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:image forFile:appPath options:0", + 'if ok is false then error "Orca app icon could not be persisted"' +] + +const MAC_DOCK_ICON_CLEAR_SCRIPT = [ + 'use framework "AppKit"', + 'use scripting additions', + 'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"', + "set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:(missing value) forFile:appPath options:0", + 'if ok is false then error "Orca app icon could not be cleared"' +] + +const MAC_DOCK_ICON_COMMAND_TIMEOUT_MS = 10_000 +const MAC_DOCK_ICON_COMMAND_FALLBACK_MS = 1_000 + +const defaultExecFile: ExecFile = (file, args, optionsOrCallback, callback) => { + if (typeof optionsOrCallback === 'function') { + return execFileChildProcess(file, args, optionsOrCallback) + } + return execFileChildProcess(file, args, optionsOrCallback, callback ?? (() => {})) +} + +let macDockIconPersistenceGeneration = 0 +let macDockIconPersistenceQueue = Promise.resolve() + export function getAppIconPath(value: unknown): string { return APP_ICON_PATHS[normalizeAppIconId(value)] } @@ -20,6 +81,212 @@ export function createAppIconImage(value: unknown): Electron.NativeImage { return nativeImage.createFromPath(getAppIconPath(value)) } +function getMacAppBundlePath(): string | undefined { + const appBundlePath = resolve(dirname(app.getPath('exe')), '..', '..') + return appBundlePath.endsWith('.app') ? appBundlePath : undefined +} + +function runMacCustomIconCommand( + execFile: ExecFile, + appBundlePath: string, + iconPath: string +): Promise { + return runBoundedMacDockIconCommand({ + args: MAC_DOCK_ICON_SCRIPT.flatMap((line) => ['-e', line]), + execFile, + file: '/usr/bin/osascript', + onError: (error) => { + console.warn('[app-icon] failed to persist macOS dock icon:', error) + }, + options: { + env: { + ...process.env, + ORCA_APP_BUNDLE_PATH: appBundlePath, + ORCA_APP_ICON_PATH: iconPath + } + }, + timeoutWarning: '[app-icon] timed out persisting macOS dock icon' + }) +} + +type BoundedMacDockIconCommandOptions = { + args: string[] + execFile: ExecFile + file: string + onError: (error: Error) => void + options?: ExecFileOptions + timeoutWarning: string +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +function isMacDockIconChildProcess(value: unknown): value is MacDockIconChildProcess { + return ( + typeof value === 'object' && + value !== null && + 'kill' in value && + typeof value.kill === 'function' && + 'once' in value && + typeof value.once === 'function' + ) +} + +function runBoundedMacDockIconCommand({ + args, + execFile, + file, + onError, + options, + timeoutWarning +}: BoundedMacDockIconCommandOptions): Promise { + return new Promise((resolve) => { + let childProcess: MacDockIconChildProcess | undefined + let settled = false + let forceFallback: NodeJS.Timeout | undefined + + const finish = (error: Error | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(fallback) + if (forceFallback) { + clearTimeout(forceFallback) + } + if (error) { + onError(error) + } + resolve() + } + + const finishAfterFallback = (): void => { + if (settled) { + return + } + settled = true + if (forceFallback) { + clearTimeout(forceFallback) + } + console.warn(timeoutWarning) + resolve() + } + + const fallback = setTimeout(() => { + if (settled) { + return + } + if (!childProcess) { + finishAfterFallback() + return + } + // Why: the queue must not release while an older icon process can still win. + childProcess.once('exit', finishAfterFallback) + childProcess.once('close', finishAfterFallback) + forceFallback = setTimeout(finishAfterFallback, MAC_DOCK_ICON_COMMAND_FALLBACK_MS) + childProcess.kill() + }, MAC_DOCK_ICON_COMMAND_TIMEOUT_MS + MAC_DOCK_ICON_COMMAND_FALLBACK_MS) + + try { + const maybeChildProcess = execFile( + file, + args, + { + ...options, + timeout: MAC_DOCK_ICON_COMMAND_TIMEOUT_MS + }, + finish + ) + if (isMacDockIconChildProcess(maybeChildProcess)) { + childProcess = maybeChildProcess + } + } catch (error) { + finish(toError(error)) + } + }) +} + +function handleMacDockIconQueueError(error: unknown): void { + console.warn('[app-icon] failed to persist macOS dock icon:', error) +} + +function enqueueMacDockIconPersistence(work: () => Promise): void { + macDockIconPersistenceQueue = macDockIconPersistenceQueue + .catch(handleMacDockIconQueueError) + .then(work) + .catch(handleMacDockIconQueueError) +} + +function clearMacCustomIconMetadata(execFile: ExecFile, appBundlePath: string): Promise { + const clearAppKitIcon = (): Promise => { + return runBoundedMacDockIconCommand({ + args: MAC_DOCK_ICON_CLEAR_SCRIPT.flatMap((line) => ['-e', line]), + execFile, + file: '/usr/bin/osascript', + onError: (error) => { + console.warn('[app-icon] failed to clear macOS dock icon:', error) + }, + options: { + env: { + ...process.env, + ORCA_APP_BUNDLE_PATH: appBundlePath + } + }, + timeoutWarning: '[app-icon] timed out clearing macOS dock icon' + }) + } + + const clearAttribute = (attribute: string): Promise => { + return runBoundedMacDockIconCommand({ + args: ['-d', attribute, appBundlePath], + execFile, + file: '/usr/bin/xattr', + onError: (error) => { + if (!error.message.includes('No such xattr')) { + console.warn(`[app-icon] failed to clear macOS dock icon metadata ${attribute}:`, error) + } + }, + timeoutWarning: `[app-icon] timed out clearing macOS dock icon metadata ${attribute}` + }) + } + + return clearAppKitIcon().then(() => + Promise.all([ + clearAttribute('com.apple.FinderInfo'), + clearAttribute('com.apple.ResourceFork') + ]).then(() => {}) + ) +} + +export function persistMacDockIcon(value: unknown, options: PersistMacDockIconOptions = {}): void { + const platform = options.platform ?? process.platform + const isDevApp = options.isDevApp ?? (is.dev || !app.isPackaged) + if (platform !== 'darwin' || isDevApp) { + return + } + const appBundlePath = options.appBundlePath ?? getMacAppBundlePath() + if (!appBundlePath) { + return + } + const execFile = options.execFile ?? defaultExecFile + const iconId = normalizeAppIconId(value) + const generation = ++macDockIconPersistenceGeneration + enqueueMacDockIconPersistence(async () => { + // Why: stale queued writes must not reapply an older Dock pin icon. + if (generation !== macDockIconPersistenceGeneration) { + return + } + if (iconId === 'classic') { + await clearMacCustomIconMetadata(execFile, appBundlePath) + return + } + // Why: a stopped app's Dock tile is resolved from Finder metadata, not + // Electron's live app.dock.setIcon state. + await runMacCustomIconCommand(execFile, appBundlePath, MAC_DOCK_ICON_PATHS[iconId]) + }) +} + export function applyAppIcon(value: unknown): void { const image = createAppIconImage(value) if (image.isEmpty()) { @@ -33,4 +300,5 @@ export function applyAppIcon(value: unknown): void { window.setIcon(image) } } + persistMacDockIcon(value) } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 3b280183bfe..e1dea4d4538 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -161,14 +161,22 @@ describe('ClaudeHookService.install', () => { ) expect(legacyCommands).toContain('/usr/local/bin/user-hook') expect( - legacyCommands.some((command: string) => command.includes(CLAUDE_SCRIPT_FILE_NAME)) + legacyCommands.some((command: string) => + process.platform === 'win32' + ? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ') + : command.includes(CLAUDE_SCRIPT_FILE_NAME) + ) ).toBe(true) expect( legacyCommands.some((command: string) => command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') ) ).toBe(false) - expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain(CLAUDE_SCRIPT_FILE_NAME) + expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : new RegExp(CLAUDE_SCRIPT_FILE_NAME) + ) expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('DEVIN_PROJECT_DIR') @@ -177,6 +185,36 @@ describe('ClaudeHookService.install', () => { rmSync(tmpHome, { recursive: true, force: true }) } }) + + // Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path + // with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed + // command must use an encoded launcher so Git Bash/cmd.exe never splits or + // expands the raw path before invoking the managed .cmd. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces ')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse( + readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') + ) as { hooks: Record } + + for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { + const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + } + ) }) describe('ClaudeHookService.installRemote', () => { @@ -287,7 +325,14 @@ describe('OpenClaudeHookService-compatible install', () => { const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8')) for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const command = parsed.hooks[event][0].hooks[0].command as string - expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME) + if (process.platform === 'win32') { + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } else { + expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME) + expect(command).toMatch(/^if \[ -x /) + } } expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index f6a7a2a0f73..fc94d6e2c6a 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -5,6 +5,7 @@ import { getSharedManagedScriptPath, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' @@ -74,9 +75,11 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S export function getManagedCommand(scriptPath: string): string { if (process.platform === 'win32') { - // Why: Claude Code runs hooks through Git Bash on Windows; forward slashes - // survive that shell layer while native Windows APIs still accept them. - return scriptPath.replaceAll('\\', '/') + // Why: Claude Code runs hooks through Git Bash on Windows. Forward slashes + // alone don't survive a path with spaces — bash splits at the space and + // tries to execute `C:/Users/Jorge` as a command. Wrapping in + // `cmd.exe /d /c call "..."` keeps the path as one argument. #6078. + return wrapWindowsHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 20e3e85e56c..fd09a86fc08 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -60,6 +60,9 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalFontFamily: 'JetBrains Mono', terminalFontWeight: 500, terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 3, terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index f8acfcf1e1c..3d899b25eae 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -64,6 +64,9 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalFontFamily: 'JetBrains Mono', terminalFontWeight: 500, terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 3, terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index dc0dc060a62..94f365a213a 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -13,7 +13,7 @@ import { import { tmpdir } from 'os' import type * as Os from 'os' import { join } from 'path' -import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils' import { upsertHookTrustEntriesInContent } from './config-toml-trust' const { getPathMock, homedirMock } = vi.hoisted(() => ({ @@ -39,6 +39,11 @@ import { CodexHookService } from './hook-service' let tmpHome: string let userDataDir: string + +function isCodexManagedCommand(command: string | undefined): boolean { + const scriptFileName = process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + return createManagedCommandMatcher(scriptFileName)(command) +} let previousUserDataPath: string | undefined beforeEach(() => { @@ -118,8 +123,9 @@ describe('CodexHookService', () => { 'UserPromptSubmit' ].sort() ) - expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks') - expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + expect( + isCodexManagedCommand(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command) + ).toBe(true) const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(trustConfig).toContain('model = "gpt-5.2-codex"') @@ -127,6 +133,40 @@ describe('CodexHookService', () => { expect(trustConfig).toContain(':permission_request:0:0') }) + // Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to + // be written verbatim as the hook command, so Codex split it at the space and + // the hook exited with code 1. The managed command uses an encoded launcher + // so the path never appears raw on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command in an encoded launcher when the profile path contains a space (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + const systemCodexHome = join(spaceHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + + const status = new CodexHookService().install() + expect(status.state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const hooksConfig = JSON.parse( + readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8') + ) as { hooks: Record } + + for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => { const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') @@ -177,12 +217,12 @@ describe('CodexHookService', () => { ).toBe(true) expect( devHooks.hooks.Stop?.some((definition) => - definition.hooks?.[0]?.command?.includes('codex-hook') + isCodexManagedCommand(definition.hooks?.[0]?.command) ) ).toBe(true) expect( prodHooks.hooks.Stop?.some((definition) => - definition.hooks?.[0]?.command?.includes('codex-hook') + isCodexManagedCommand(definition.hooks?.[0]?.command) ) ).toBe(true) expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks) @@ -296,7 +336,9 @@ describe('CodexHookService', () => { hooks: Record } - expect(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + expect(isCodexManagedCommand(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command)).toBe( + true + ) expect(runtimeHooks.hooks.PostToolUse?.[1]?.hooks?.[0]?.command).toBe( 'slow-user-post-tool-hook' ) @@ -430,7 +472,7 @@ describe('CodexHookService', () => { ) ?? [] expect(stopCommands).toContain(userCommand) - expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) + expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true) expect(runtimeHooks.hooks.PreCompact).toBeUndefined() for (const command of pluginCommands) { expect(runtimeHooksText).not.toContain(command) @@ -953,8 +995,10 @@ describe('CodexHookService', () => { (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] ) ?? [] expect(stopCommands).toContain(userCommand) - expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) - expect(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true) + expect( + isCodexManagedCommand(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command) + ).toBe(true) const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain('[features]\nhooks = true') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index f53a180a15f..99638a787bc 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -11,6 +11,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookCommandConfig, @@ -111,7 +112,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getSystemConfigPath(): string { diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index c6b8bf1b8b9..7dd2abcb9a7 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -48,15 +48,46 @@ describe('CommandCodeHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('.*') expect(config.hooks.PostToolUse[0].matcher).toBe('.*') expect(config.hooks.Stop[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook') - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) - if (process.platform === 'win32') { - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook.cmd') - } else { + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /command-code-hook/ + ) + if (process.platform !== 'win32') { + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + } + if (process.platform !== 'win32') { expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /) } }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca command-code home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new CommandCodeHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.commandcode', 'settings.json'), 'utf8') + ) as { hooks: Record } + + const command = config.hooks.PreToolUse[0].hooks[0].command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('installs a hook script that can recover the endpoint when Command Code strips token env', () => { new CommandCodeHookService().install() diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 435344bd97c..a5a34a30512 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -10,6 +10,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -45,7 +46,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index 6a1cee03498..4f310d595b8 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -58,8 +58,14 @@ describe('CursorHookService', () => { expect(Object.keys(config.hooks).sort()).toEqual([...CURSOR_EVENTS].sort()) for (const eventName of CURSOR_EVENTS) { const definition = config.hooks[eventName]?.[0] - expect(definition?.command).toContain('cursor-hook') - expect(definition?.command).toContain(join(homeDir, '.orca')) + expect(definition?.command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /cursor-hook/ + ) + if (process.platform !== 'win32') { + expect(definition?.command).toContain(join(homeDir, '.orca')) + } expect(definition?.hooks).toBeUndefined() } @@ -75,6 +81,35 @@ describe('CursorHookService', () => { } }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca cursor home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new CursorHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.cursor', 'hooks.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['beforeSubmitPrompt', 'stop']) { + const command = config.hooks[eventName]?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('preserves user-authored Cursor hook entries and removes stale managed entries', () => { const configPath = join(homeDir, '.cursor', 'hooks.json') mkdirSync(dirname(configPath), { recursive: true }) @@ -107,7 +142,11 @@ describe('CursorHookService', () => { const promptCommands = config.hooks.beforeSubmitPrompt.map((definition) => definition.command) expect(promptCommands).toContain('/usr/local/bin/user-hook') expect( - promptCommands.filter((command) => command?.includes(CURSOR_SCRIPT_FILE_NAME)) + promptCommands.filter((command) => + process.platform === 'win32' + ? command?.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ') + : command?.includes(CURSOR_SCRIPT_FILE_NAME) + ) ).toHaveLength(1) expect(config.hooks.retiredEvent.map((definition) => definition.command)).toEqual([ '/usr/local/bin/retired-user-hook' diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 3b74d2538d3..fb57ce1b8fd 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -9,6 +9,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -59,7 +60,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index e2b8c155caa..75afb290310 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1424,6 +1424,38 @@ describe('createPtySubprocess', () => { expect(spawnMock).not.toHaveBeenCalled() }) + it('normalizes MSYS drive cwd before spawning daemon PowerShell on Windows', () => { + const proc = mockPtyProcess() + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + spawnMock.mockImplementation((_shell, _args, options) => { + if (options.cwd === '/c/Users/alice/project') { + throw new Error('Cannot create process, error code: 267') + } + return proc + }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/c/Users/alice/project', + shellOverride: 'powershell.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + POWERSHELL_OSC133_COMMAND_ARGS, + expect.objectContaining({ cwd: 'C:\\Users\\alice\\project' }) + ) + }) + it('validates the requested Windows cwd before launching WSL on Windows', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index 5608cc10dd8..65ac1538302 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -70,11 +70,46 @@ describe('DroidHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('*') expect(config.hooks.PermissionRequest[0].matcher).toBe('*') expect(config.hooks.UserPromptSubmit[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('droid-hook') - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /droid-hook/ + ) + if (process.platform !== 'win32') { + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + } expect(config.hooks.PreToolUse[0].hooks[0].command).not.toContain(userDataDir) }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca droid home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new DroidHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.factory', 'settings.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('reports partial when Factory has hooks disabled globally', () => { const configPath = join(homeDir, '.factory', 'settings.json') mkdirSync(dirname(configPath), { recursive: true }) diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 9b444d0d11a..f8e07cec3ab 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -8,6 +8,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -54,9 +55,12 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - // Why: Factory invokes the .cmd directly via cmd.exe (no bash), so native - // backslashes are correct on Windows. Matches the codex/cursor pattern. - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + // Why: Factory invokes the .cmd via cmd.exe, but the raw path still splits at + // whitespace when the user profile contains a space (e.g. `C:\Users\Jane Doe`). + // The shared Windows wrapper keeps the path out of cmd.exe's raw command line. #6078. + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(): string { diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index 7490a1b8434..587762de95a 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -100,12 +100,46 @@ describe('GeminiHookService', () => { expect(config.hooks.PreToolUse).toBeUndefined() expect(config.hooks.BeforeAgent).toHaveLength(2) expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent') - expect(config.hooks.BeforeAgent[1].hooks[0].command).toContain(managedHookPath) - expect(config.hooks.AfterAgent[0].hooks[0].command).toContain(managedHookPath) - expect(config.hooks.AfterTool[0].hooks[0].command).toContain(managedHookPath) - expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookPath) + const managedCommandPattern = + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : new RegExp(managedHookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + expect(config.hooks.BeforeAgent[1].hooks[0].command).toMatch(managedCommandPattern) + expect(config.hooks.AfterAgent[0].hooks[0].command).toMatch(managedCommandPattern) + expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(managedCommandPattern) + expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(managedCommandPattern) }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca gemini home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new GeminiHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.gemini', 'settings.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool']) { + const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + homedirMock.mockReturnValue(homeDir) + } + } + ) + it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => { const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' const staleManagedHookPath = @@ -147,6 +181,10 @@ describe('GeminiHookService', () => { expect(status.state).toBe('installed') expect(preToolCommands).toEqual(['echo user-authored']) - expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookFileName) + expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + ) }) }) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index ac687f6c6be..889e7a467f8 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -9,6 +9,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -43,7 +44,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 8493317a4c0..9e6e5eb92bf 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -110,7 +110,8 @@ import { updatePRTitle, _getMergeQueueCacheSizeForTests, _resetOwnerRepoCache, - _resetMergeQueueCacheForTests + _resetMergeQueueCacheForTests, + __resetTrackedUpstreamBranchCacheForTests } from './client' describe('checkOrcaStarred', () => { @@ -179,6 +180,7 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetTrackedUpstreamBranchCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { @@ -1160,6 +1162,104 @@ describe('getPRForBranch', () => { }) }) + it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('coalesces concurrent missing tracked-upstream probes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockImplementation(async () => { + await Promise.resolve() + throw new Error("fatal: no upstream configured for branch 'no-pr-branch'") + }) + + await Promise.all([ + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch') + ]) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(trackedUpstreamCalls[1][1]).toEqual({ + cwd: '/repo-root', + wslDistro: 'Ubuntu' + }) + }) + + it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => { + vi.useFakeTimers() + try { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'")) + .mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root', 'feature') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('feature@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('uses the tracked upstream remote owner for fork branch lookup', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6bf0976bac3..158c28ef4d6 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2273,6 +2273,20 @@ type TrackedUpstreamBranch = { branchName: string } +const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000 + +type TrackedUpstreamNullCacheEntry = { + expiresAt: number +} + +const trackedUpstreamNullCache = new Map() +const trackedUpstreamInFlight = new Map>() + +export function __resetTrackedUpstreamBranchCacheForTests(): void { + trackedUpstreamNullCache.clear() + trackedUpstreamInFlight.clear() +} + function parseTrackedUpstreamBranch( upstreamRef: string, branchName: string @@ -2289,6 +2303,65 @@ async function getTrackedUpstreamBranch( branchName: string, connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} +): Promise { + // Why: branches without configured upstreams are stable misses during PR + // polling; cache only nulls so positive PR discovery stays fresh. + const cacheKey = getTrackedUpstreamBranchCacheKey( + repoPath, + branchName, + connectionId, + localGitOptions + ) + const now = Date.now() + const cachedNull = trackedUpstreamNullCache.get(cacheKey) + if (cachedNull && cachedNull.expiresAt > now) { + return null + } + if (cachedNull) { + trackedUpstreamNullCache.delete(cacheKey) + } + + const inFlight = trackedUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + + const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions) + trackedUpstreamInFlight.set(cacheKey, probe) + try { + const result = await probe + if (result) { + trackedUpstreamNullCache.delete(cacheKey) + } else { + trackedUpstreamNullCache.set(cacheKey, { + expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS + }) + } + return result + } finally { + if (trackedUpstreamInFlight.get(cacheKey) === probe) { + trackedUpstreamInFlight.delete(cacheKey) + } + } +} + +function getTrackedUpstreamBranchCacheKey( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): string { + const runtimeKey = connectionId + ? `ssh:${connectionId}` + : `local:${localGitOptions.wslDistro ?? 'host'}` + return [runtimeKey, repoPath, branchName].join('\0') +} + +async function probeTrackedUpstreamBranch( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} ): Promise { const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`] try { diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index fd5467b66b3..52d46fbcf19 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -59,8 +59,14 @@ describe('GrokHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('*') expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*') expect(config.hooks.Notification[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook') - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + : /grok-hook/ + ) + if (process.platform !== 'win32') { + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + } const script = readFileSync( join(homeDir, '.orca', 'agent-hooks', GROK_SCRIPT_FILE_NAME), @@ -74,6 +80,35 @@ describe('GrokHookService', () => { } }) + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. The + // managed command must use an encoded launcher so the path never appears raw + // on the cmd.exe command line. + it.skipIf(process.platform !== 'win32')( + 'wraps the managed hook command to survive spaces in the profile path (#6078)', + () => { + const spaceHome = join(tmpdir(), 'orca grok home with spaces') + mkdirSync(spaceHome, { recursive: true }) + homedirMock.mockReturnValue(spaceHome) + try { + expect(new GrokHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(spaceHome, '.grok', 'hooks', 'orca-status.json'), 'utf8') + ) as { hooks: Record } + + for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command + expect(command).toMatch( + /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + ) + } + } finally { + rmSync(spaceHome, { recursive: true, force: true }) + } + } + ) + it('preserves user-authored hook entries in the Orca Grok config file', () => { const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') mkdirSync(dirname(configPath), { recursive: true }) @@ -99,6 +134,12 @@ describe('GrokHookService', () => { definition.hooks.map((hook) => hook.command) ) expect(commands).toContain('/usr/local/bin/user-hook') - expect(commands.some((command) => command.includes(GROK_SCRIPT_FILE_NAME))).toBe(true) + expect( + commands.some((command) => + process.platform === 'win32' + ? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ') + : command.includes(GROK_SCRIPT_FILE_NAME) + ) + ).toBe(true) }) }) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index eefc6dae5fa..e8a81952a59 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -9,6 +9,7 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, + wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -55,7 +56,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { diff --git a/src/main/ipc/agent-detection-shell-path.ts b/src/main/ipc/agent-detection-shell-path.ts new file mode 100644 index 00000000000..42569755037 --- /dev/null +++ b/src/main/ipc/agent-detection-shell-path.ts @@ -0,0 +1,16 @@ +import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' + +export async function hydrateShellPathForAgentDetection( + context?: PreflightRuntimeContext +): Promise { + if (getPreflightWslTarget(context)) { + return + } + // Why: remote runtime servers may inherit a sparse daemon/SSH PATH even + // though the user's shell can run the agents. + const hydration = await hydrateShellPath() + if (hydration.ok) { + mergePathSegments(hydration.segments) + } +} diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 68c3403c843..1718134d4bf 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,14 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock, destroySystemTrayMock } = - vi.hoisted(() => ({ - handlers: new Map unknown>(), - appExitMock: vi.fn(), - appQuitMock: vi.fn(), - appRelaunchMock: vi.fn(), - execFileMock: vi.fn(), - destroySystemTrayMock: vi.fn() - })) +const { + handlers, + appExitMock, + appQuitMock, + appRelaunchMock, + execFileMock, + destroySystemTrayMock, + showOpenDialogMock, + grantFloatingWorkspaceDirectoryMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + appExitMock: vi.fn(), + appQuitMock: vi.fn(), + appRelaunchMock: vi.fn(), + execFileMock: vi.fn(), + destroySystemTrayMock: vi.fn(), + showOpenDialogMock: vi.fn(), + grantFloatingWorkspaceDirectoryMock: vi.fn() +})) vi.mock('node:child_process', () => ({ execFile: execFileMock @@ -26,7 +36,7 @@ vi.mock('electron', () => ({ fromWebContents: vi.fn(() => null) }, dialog: { - showOpenDialog: vi.fn() + showOpenDialog: showOpenDialogMock }, ipcMain: { handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { @@ -43,6 +53,12 @@ vi.mock('../tray/system-tray', () => ({ destroySystemTray: destroySystemTrayMock })) +vi.mock('./floating-workspace-directory', () => ({ + ensureDefaultFloatingWorkspacePath: vi.fn(), + grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock, + resolveFloatingTerminalCwd: vi.fn() +})) + import { registerAppHandlers } from './app' describe('registerAppHandlers', () => { @@ -56,6 +72,8 @@ describe('registerAppHandlers', () => { appRelaunchMock.mockReset() execFileMock.mockReset() destroySystemTrayMock.mockReset() + showOpenDialogMock.mockReset() + grantFloatingWorkspaceDirectoryMock.mockReset() Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) @@ -177,4 +195,21 @@ describe('registerAppHandlers', () => { await expect(resultPromise).resolves.toBeNull() expect(killMock).toHaveBeenCalled() }) + + it('picks an existing floating workspace directory without enabling native directory creation', async () => { + const store = {} + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ['/Users/kaylee/notes'] + }) + registerAppHandlers(store as never) + + await expect( + handlers.get('app:pickFloatingWorkspaceDirectory')?.({ sender: {} }) + ).resolves.toBe('/Users/kaylee/notes') + expect(showOpenDialogMock).toHaveBeenCalledWith({ + properties: ['openDirectory'] + }) + expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') + }) }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index e16b70b401a..be33ef389f0 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -57,7 +57,9 @@ async function pickFloatingWorkspaceDirectory( ): Promise { const parentWindow = BrowserWindow.fromWebContents(event.sender) const options = { - properties: ['openDirectory', 'createDirectory'] + // Why: this picker grants access to an existing workspace directory. + // Creation belongs to explicit file/write actions, not typeahead input. + properties: ['openDirectory'] } satisfies Electron.OpenDialogOptions const result = parentWindow ? await dialog.showOpenDialog(parentWindow, options) diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 94b969848b2..d3ffef854ef 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -76,6 +76,7 @@ vi.mock('../gitea/client', () => ({ import { _resetPreflightCache, detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, registerPreflightHandlers, runPreflightCheck } from './preflight' @@ -105,6 +106,7 @@ describe('preflight', () => { handleMock.mockReset() execFileAsyncMock.mockReset() hydrateShellPathMock.mockReset() + hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' }) mergePathSegmentsMock.mockReset() getActiveMultiplexerMock.mockReset() getBitbucketAuthStatusMock.mockReset() @@ -575,6 +577,63 @@ describe('preflight', () => { await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor']) }) + it('hydrates shell PATH before user-facing agent detection', async () => { + const originalPath = process.env.PATH + process.env.PATH = '/usr/bin' + hydrateShellPathMock.mockResolvedValueOnce({ + segments: ['/home/test/.local/bin'], + ok: true, + failureReason: 'none' + }) + mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => { + process.env.PATH = [...segments, '/usr/bin'].join(':') + return segments + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) { + return { stdout: '/home/test/.local/bin/codex\n' } + } + throw new Error('not found') + }) + + try { + await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex']) + } finally { + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + } + expect(hydrateShellPathMock).toHaveBeenCalledWith() + expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin']) + }) + + it('does not run host shell hydration for WSL agent detection', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect( + detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' }) + ).resolves.toEqual(['claude']) + expect(hydrateShellPathMock).not.toHaveBeenCalled() + }) + it('detects Mistral Vibe from the installed vibe executable', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 8d65e0acc4b..fbe00db4605 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -15,6 +15,7 @@ import { runPreflightCommandInWsl } from './preflight-wsl-command' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' import { buildLocalPreflightEnv } from './preflight-local-env' import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' +import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' const execFileAsync = promisify(execFile) const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 @@ -195,6 +196,13 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id)) } +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} + export type RefreshAgentsResult = { /** Agents detected after hydrating PATH from the user's login shell. */ agents: string[] @@ -352,11 +360,8 @@ export function registerPreflightHandlers(): void { } ) - ipcMain.handle( - 'preflight:detectAgents', - async (_event, args?: PreflightRuntimeContext): Promise => { - return detectInstalledAgents(args) - } + ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) => + detectInstalledAgentsWithShellPathHydration(args) ) ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index 5d454bb1aa3..fb713ef6a91 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -64,6 +64,14 @@ describe('repos folder pickers', () => { return handler(null, undefined) as Promise } + const callPickDirectory = (): Promise => { + const handler = handlers.get('repos:pickDirectory') + if (!handler) { + throw new Error('repos:pickDirectory handler was never registered') + } + return handler(null, undefined) as Promise + } + beforeEach(() => { handlers.clear() handleMock.mockReset() @@ -101,4 +109,18 @@ describe('repos folder pickers', () => { await expect(callPickFolders()).resolves.toEqual([]) }) + + it('picks an existing directory without enabling native directory creation', async () => { + const parentDir = join(sep, 'projects') + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: [parentDir] + }) + + await expect(callPickDirectory()).resolves.toBe(parentDir) + + expect(showOpenDialogMock).toHaveBeenCalledWith(mockWindow, { + properties: ['openDirectory'] + }) + }) }) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 55827fe1bcd..43d048a2290 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -2070,7 +2070,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // destination directory that may not be a git repo yet. ipcMain.handle('repos:pickDirectory', async () => { const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openDirectory', 'createDirectory'] + // Why: macOS can materialize typed partial paths when directory creation + // is enabled; clone/create actions already create the final path on submit. + properties: ['openDirectory'] }) if (result.canceled || result.filePaths.length === 0) { return null diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index ad7b03e06c8..24ac41e2deb 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -132,6 +132,22 @@ describe('registerShellHandlers', () => { await expect(handler({})).resolves.toBeNull() }) + it('picks an existing directory without enabling native directory creation', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ['/Users/kaylee/projects'] + }) + + const handler = getHandler('shell:pickDirectory') + await expect(handler({}, { defaultPath: '/Users/kaylee' })).resolves.toBe( + '/Users/kaylee/projects' + ) + expect(showOpenDialogMock).toHaveBeenCalledWith({ + defaultPath: '/Users/kaylee', + properties: ['openDirectory'] + }) + }) + describe('shell:openPath', () => { it('ignores relative paths', async () => { const handler = getHandler('shell:openPath') diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 85cccbc7c16..84e7fc7318c 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -205,7 +205,9 @@ export function registerShellHandlers(): void { async (_event, args: { defaultPath?: string }): Promise => { const result = await dialog.showOpenDialog({ defaultPath: args.defaultPath, - properties: ['openDirectory', 'createDirectory'] + // Why: callers only need an existing folder grant; enabling native + // creation can leave typed prefix directories behind on macOS. + properties: ['openDirectory'] }) if (result.canceled || result.filePaths.length === 0) { return null diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 148d532716d..00a7bf20dc5 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -57,6 +57,7 @@ const { onExit: vi.fn(), onReplay: vi.fn(), attach: vi.fn(), + attachForReconnect: vi.fn().mockResolvedValue({}), shutdown: vi.fn() }, mockFsProvider: {}, @@ -285,6 +286,7 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onData.mockReset() mockPtyProvider.onExit.mockReset() mockPtyProvider.onReplay.mockReset() + mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) mockPtyProvider.shutdown.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() diff --git a/src/main/ipc/worktree-change-invalidators.ts b/src/main/ipc/worktree-change-invalidators.ts new file mode 100644 index 00000000000..a43657743b4 --- /dev/null +++ b/src/main/ipc/worktree-change-invalidators.ts @@ -0,0 +1,25 @@ +const worktreeChangeInvalidators = new Set<(repoId: string) => void>() + +export function registerWorktreeChangeInvalidator( + invalidator: (repoId: string) => void +): () => void { + worktreeChangeInvalidators.add(invalidator) + return () => { + worktreeChangeInvalidators.delete(invalidator) + } +} + +export function runWorktreeChangeInvalidators(repoId: string): void { + const invalidators = Array.from(worktreeChangeInvalidators) + for (const invalidator of invalidators) { + try { + invalidator(repoId) + } catch (error) { + console.warn('[worktrees] worktree change invalidator failed:', error) + } + } +} + +export function __resetWorktreeChangeInvalidatorsForTests(): void { + worktreeChangeInvalidators.clear() +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 50fb87e58fb..74dfb7db9f8 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -57,6 +57,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' +import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -1328,6 +1329,9 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( } export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { + // Why: invalidate detected-worktree caches before renderer observers react, + // so follow-up listDetected reads post-change state. + runWorktreeChangeInvalidators(repoId) if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('worktrees:changed', { repoId }) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 3b7130d040d..0f043c7c6fe 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join, resolve } from 'path' -import type { CreateWorktreeResult } from '../../shared/types' +import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types' const ORIGINAL_PLATFORM = process.platform @@ -226,9 +226,12 @@ vi.mock('./pty', () => ({ getLocalPtyProvider: getLocalPtyProviderMock })) -import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote' +import { + __resetSshWorktreeCreateFetchCacheForTests, + notifyWorktreesChanged +} from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { registerWorktreeHandlers } from './worktrees' +import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' type HandlerMap = Record unknown> @@ -272,6 +275,7 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { setPlatform(ORIGINAL_PLATFORM) __resetSshWorktreeCreateFetchCacheForTests() + __resetDetectedWorktreeScanCacheForTests() invalidateAuthorizedRootsCache() for (const m of [ handleMock, @@ -2033,6 +2037,313 @@ describe('registerWorktreeHandlers', () => { }) }) + it('reuses a recent authoritative detected worktree scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + + expect(first).toEqual(second) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('coalesces concurrent authoritative detected worktree scans', async () => { + listWorktreesMock.mockImplementation(async () => { + await Promise.resolve() + return [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + }) + + await Promise.all([ + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + ]) + + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rechecks detected worktree metadata while reusing a cached raw scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + let currentMeta = makeWorktreeMeta({ isPinned: false }) + store.getWorktreeMeta.mockImplementation(() => currentMeta) + store.setWorktreeMeta.mockImplementation(() => currentMeta) + const first = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + currentMeta = makeWorktreeMeta({ isPinned: true }) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(first.worktrees[0].isPinned).toBe(false) + expect(second.worktrees[0].isPinned).toBe(true) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rescans detected worktrees after the scan cache TTL expires', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(5_001) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/new-worktree' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('starts the detected scan cache TTL after a slow scan completes', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]), + 6_000 + ) + }) + ) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(6_000) + await first + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo']) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('invalidates the detected scan cache before worktree change notifications', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees).toHaveLength(2) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + }) + + it('rescans detected worktrees after the local create flow notifies worktree changes', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + const detected = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/improve-dashboard' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(3) + }) + + it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/new-worktree': { + worktreeId: 'repo-1::/workspace/new-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await pendingList + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 564d369d914..f550d11079a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -77,6 +77,7 @@ import { cleanupUnusedWorktreePushTargetRemoteSsh, notifyWorktreesChanged } from './worktree-remote' +import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators' import { invalidateAuthorizedRootsCache, isENOENT, @@ -387,6 +388,83 @@ function getPreservedBranchCleanupTarget( const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() +// Why: absorb renderer polling bursts while keeping external worktree-change +// lag bounded to one short refresh window. +const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 + +type DetectedWorktreeScanCacheEntry = { + expiresAt: number + worktrees: GitWorktreeInfo[] +} + +type DetectedWorktreeScanResult = { + gitWorktrees: GitWorktreeInfo[] + fresh: boolean +} + +const detectedWorktreeScanCache = new Map() +const detectedWorktreeScanInFlight = new Map>() +const detectedWorktreeScanGenerations = new Map() + +function invalidateDetectedWorktreeScanCache(repoId: string): void { + detectedWorktreeScanCache.delete(repoId) + detectedWorktreeScanInFlight.delete(repoId) + detectedWorktreeScanGenerations.set( + repoId, + (detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1 + ) +} + +registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) + +export function __resetDetectedWorktreeScanCacheForTests(): void { + detectedWorktreeScanCache.clear() + detectedWorktreeScanInFlight.clear() + detectedWorktreeScanGenerations.clear() +} + +async function listDetectedGitWorktrees( + store: Store, + repo: Repo +): Promise { + if (repo.connectionId || isFolderRepo(repo)) { + return { + gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)), + fresh: true + } + } + + const cached = detectedWorktreeScanCache.get(repo.id) + if (cached && cached.expiresAt > Date.now()) { + return { gitWorktrees: cached.worktrees, fresh: false } + } + + const inFlight = detectedWorktreeScanInFlight.get(repo.id) + if (inFlight) { + return { gitWorktrees: await inFlight, fresh: false } + } + + const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)) + const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0 + detectedWorktreeScanInFlight.set(repo.id, scan) + try { + const gitWorktrees = await scan + // Why: a create/remove notification can invalidate while the git scan is + // still running. Do not let that stale scan repopulate the cache afterward. + const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation + if (isCurrentGeneration) { + detectedWorktreeScanCache.set(repo.id, { + worktrees: gitWorktrees, + expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + }) + } + return { gitWorktrees, fresh: isCurrentGeneration } + } finally { + if (detectedWorktreeScanInFlight.get(repo.id) === scan) { + detectedWorktreeScanInFlight.delete(repo.id) + } + } +} function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { if (keySet.has(key)) { @@ -946,6 +1024,7 @@ export function registerWorktreeHandlers( try { let gitWorktrees: GitWorktreeInfo[] + let freshScan = true if (isFolderRepo(repo)) { return { repoId: repo.id, @@ -966,13 +1045,14 @@ export function registerWorktreeHandlers( } gitWorktrees = await provider.listWorktrees(repo.path) } else { - gitWorktrees = await listRepoWorktrees( - repo, - getLocalProjectWorktreeGitOptions(store, repo) - ) + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return { repoId: repo.id, diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index befbbeed95c..49a49a46076 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -18,10 +18,10 @@ vi.mock('./client', () => ({ jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) })) -function makeEntry(): JiraClientForSite { +function makeEntry(id = 'site-1'): JiraClientForSite { return { site: { - id: 'site-1', + id, siteUrl: 'https://example.atlassian.net', email: 'ada@example.com', displayName: 'Example Jira', @@ -60,6 +60,71 @@ describe('Jira issue operations', () => { ).rejects.toThrow(error.message) }) + it('rejects single-site search failures so the UI can surface them', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow('Forbidden') + }) + + it('includes Jira status codes in surfaced single-site search failures', async () => { + const error = Object.assign(new Error('Forbidden'), { status: 403 }) + getClientsMock.mockReturnValue([makeEntry('site-1')]) + jiraRequestMock.mockRejectedValueOnce(error) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow( + 'Error 403: Forbidden' + ) + }) + + it('keeps healthy sites when one site fails under an "all" search', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({ + issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }] + }) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).resolves.toMatchObject([ + { key: 'BRV-1', title: 'Healthy' } + ]) + }) + + it('keeps healthy sites when the saved selection fans out without an explicit site', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({ + issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }] + }) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20)).resolves.toMatchObject([ + { key: 'BRV-1', title: 'Healthy' } + ]) + }) + + it('surfaces an error when every site fails under an "all" search', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock + .mockRejectedValueOnce(new Error('Forbidden')) + .mockRejectedValueOnce(new Error('Service Unavailable')) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Forbidden') + }) + + it('prefers operational failures when every "all" search site fails', async () => { + const authError = new Error('Unauthorized') + const operationalError = new Error('Service Unavailable') + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + isAuthErrorMock.mockImplementation((error) => error === authError) + jiraRequestMock.mockRejectedValueOnce(authError).mockRejectedValueOnce(operationalError) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Service Unavailable') + expect(clearTokenMock).toHaveBeenCalledWith('site-1') + }) + it('paginates Jira project search results before sorting them', async () => { jiraRequestMock .mockResolvedValueOnce({ diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 60a7dc49f8e..3bf8237349c 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -68,8 +68,38 @@ function clampLimit(limit: number | undefined, fallback = 30): number { return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) } -function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean { - return selection !== 'all' +type JiraIssueSearchFailure = { + error: unknown + auth: boolean +} + +function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) { + return null + } + const status = (error as { status?: unknown }).status + return typeof status === 'number' && Number.isFinite(status) ? status : null +} + +function toIssueSearchFailureError(error: unknown): unknown { + const status = getErrorStatus(error) + if ( + status === null || + !(error instanceof Error) || + error.message.startsWith(`Error ${status}:`) + ) { + return error + } + return new Error(`Error ${status}: ${error.message}`) +} + +function shouldSurfaceSiteFailure( + selection: JiraSiteSelection | null | undefined, + entryCount: number +): boolean { + // getClients can resolve an omitted selection to the persisted 'all' choice; + // multi-entry reads need the same resilient fan-out policy as explicit 'all'. + return selection !== 'all' && entryCount <= 1 } function asRecord(value: unknown): JiraRecord { @@ -345,26 +375,37 @@ export async function searchIssues( return [] } const safeLimit = clampLimit(limit) + const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) + const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) const results = await Promise.all( - entries.map(async (entry) => { + entries.map(async (entry, index) => { await acquire() try { return await searchIssuesForClient(entry, jql.trim(), safeLimit) } catch (error) { - if (isAuthError(error)) { + const authFailure = isAuthError(error) + if (authFailure) { clearToken(entry.site.id) - if (shouldThrowAuthError(siteId)) { - throw error - } - } else { - console.warn('[jira] searchIssues failed:', error) } - return [] + if (surfaceSiteFailure) { + throw toIssueSearchFailureError(error) + } + console.warn('[jira] searchIssues failed:', error) + failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } + return [] as JiraIssue[] } finally { release() } }) ) + // 'all' fan-out: only surface an error when every connected site failed, so a + // partial success (or a genuinely empty result) is not reported as an error. + const recordedFailures = failures.filter( + (failure): failure is JiraIssueSearchFailure => failure !== undefined + ) + if (recordedFailures.length === entries.length) { + throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error + } return entries.length === 1 ? results.flat().slice(0, safeLimit) : sortAndLimitIssues(results.flat(), safeLimit) @@ -388,7 +429,7 @@ export async function getIssue( } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) - if (shouldThrowAuthError(siteId)) { + if (shouldSurfaceSiteFailure(siteId, entries.length)) { throw error } } else { @@ -590,7 +631,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise + readonly exit: { readonly _tag: string; readonly cause?: string } +} + +type CapturedSink = TracerSink & { + readonly records: SpanRecord[] +} + +function isSpanRecord(record: unknown): record is SpanRecord { + return ( + record !== null && + typeof record === 'object' && + 'name' in record && + typeof record.name === 'string' && + 'durationMs' in record && + typeof record.durationMs === 'number' && + 'attributes' in record && + record.attributes !== null && + typeof record.attributes === 'object' && + 'exit' in record && + record.exit !== null && + typeof record.exit === 'object' && + '_tag' in record.exit && + typeof record.exit._tag === 'string' + ) +} + +function makeCapturingSink(): CapturedSink { + const records: SpanRecord[] = [] + return { + records, + push(record) { + if (!isSpanRecord(record)) { + throw new Error('expected span record') + } + records.push(record) + }, + flush() { + /* no-op */ + }, + close() { + /* no-op */ + } + } +} + +let sink: CapturedSink +let nowMs = 1_700_000_000_000 + +async function runGitSpan( + meta: { args: readonly string[]; cwd?: string }, + durationMs: number, + fail = false +) { + vi.setSystemTime(nowMs) + const promise = withGitSpan(meta, async () => { + vi.setSystemTime(nowMs + durationMs) + if (fail) { + throw new Error('git failed') + } + return 'ok' + }) + nowMs += durationMs + 1 + return await promise +} + +beforeEach(() => { + vi.useFakeTimers() + nowMs = 1_700_000_000_000 + _resetGitSpanSamplingForTests() + sink = makeCapturingSink() + setActiveSink(sink) +}) + +afterEach(() => { + vi.useRealTimers() + _resetGitSpanSamplingForTests() + _resetTracerForTests() +}) + +describe('withGitSpan sampling', () => { + it('bounds fast successful repeated git spans by subcommand and cwd while preserving important spans', async () => { + for (let i = 0; i < 10_000; i++) { + await runGitSpan({ args: ['status', '--short'], cwd: '/repo' }, 5) + } + + const repeatedFastSuccesses = sink.records.filter( + (record) => + record.name === 'git.exec' && + record.exit._tag === 'Success' && + record.attributes['git.subcommand'] === 'status' && + record.attributes.cwd === '/repo' && + record.durationMs < 250 + ) + expect(repeatedFastSuccesses.length).toBeGreaterThan(0) + expect(repeatedFastSuccesses.length).toBeLessThan(200) + + await expect(runGitSpan({ args: ['status'], cwd: '/repo' }, 5, true)).rejects.toThrow( + 'git failed' + ) + await runGitSpan({ args: ['status'], cwd: '/repo' }, 275) + await runGitSpan({ args: ['branch'], cwd: '/repo' }, 5) + await runGitSpan({ args: ['status'], cwd: '/other-repo' }, 5) + + expect( + sink.records.some( + (record) => + record.exit._tag === 'Failure' && + record.attributes['git.subcommand'] === 'status' && + record.attributes.cwd === '/repo' + ) + ).toBe(true) + expect( + sink.records.some( + (record) => + record.exit._tag === 'Success' && + record.durationMs >= 250 && + record.attributes['git.subcommand'] === 'status' && + record.attributes.cwd === '/repo' + ) + ).toBe(true) + expect( + sink.records.some( + (record) => + record.exit._tag === 'Success' && + record.attributes['git.subcommand'] === 'branch' && + record.attributes.cwd === '/repo' + ) + ).toBe(true) + expect( + sink.records.some( + (record) => + record.exit._tag === 'Success' && + record.attributes['git.subcommand'] === 'status' && + record.attributes.cwd === '/other-repo' + ) + ).toBe(true) + }) + it('parses git subcommands after global options without changing arg count', async () => { + await runGitSpan({ args: ['-c', 'core.quotePath=false', 'status', '--short'], cwd: '/repo' }, 5) + + expect(sink.records[0]?.attributes['git.subcommand']).toBe('status') + expect(sink.records[0]?.attributes['git.arg_count']).toBe(4) + }) + + it('prunes stale git sampling buckets and caps unique cwd buckets', async () => { + for (let i = 0; i < 700; i++) { + await runGitSpan({ args: ['status'], cwd: `/repo-${i}` }, 5) + } + + expect(_gitSpanSamplingBucketCountForTests()).toBeLessThanOrEqual(512) + + nowMs += 60_000 + await runGitSpan({ args: ['status'], cwd: '/fresh-repo' }, 5) + + expect(_gitSpanSamplingBucketCountForTests()).toBe(1) + }) +}) diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index 3651a355e66..6030c8c777a 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -23,29 +23,164 @@ import { withSpan, type ActiveSpan } from './tracer' +const GIT_FAST_SUCCESS_THRESHOLD_MS = 250 +const GIT_FAST_SUCCESS_WINDOW_MS = 60_000 +const GIT_FAST_SUCCESS_BUDGET_PER_WINDOW = 60 +const GIT_SAMPLING_MAX_BUCKETS = 512 + +// Why: trace captures showed `git status --short` bursts dominating payloads. +// Keep enough fast successes for timing shape while bounding memory and volume. +const GIT_GLOBAL_OPTIONS_WITH_OPERAND = new Set([ + '-c', + '-C', + '--git-dir', + '--work-tree', + '--config-env', + '--namespace', + '--exec-path', + '--super-prefix', + '--pathspec-from-file' +]) +const GIT_GLOBAL_FLAGS = new Set([ + '--bare', + '--no-pager', + '--paginate', + '--literal-pathspecs', + '--glob-pathspecs', + '--noglob-pathspecs', + '--icase-pathspecs', + '--no-optional-locks', + '--pathspec-file-nul' +]) + +type GitSamplingBucket = { + windowStartMs: number + emitted: number +} + +const gitSamplingBuckets = new Map() + +function gitSubcommandFromArgs(args: readonly string[]): string { + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (!arg) { + continue + } + if (arg === '--') { + return '' + } + if (GIT_GLOBAL_OPTIONS_WITH_OPERAND.has(arg)) { + index += 1 + continue + } + if ( + arg.startsWith('--git-dir=') || + arg.startsWith('--work-tree=') || + arg.startsWith('--config-env=') || + arg.startsWith('--namespace=') || + arg.startsWith('--exec-path=') || + arg.startsWith('--super-prefix=') || + arg.startsWith('--pathspec-from-file=') || + (arg.startsWith('-c') && arg.length > 2) || + (arg.startsWith('-C') && arg.length > 2) + ) { + continue + } + if (GIT_GLOBAL_FLAGS.has(arg)) { + continue + } + if (arg.startsWith('-')) { + continue + } + return arg + } + return '' +} + +function pruneGitSamplingBuckets(nowMs: number): void { + for (const [key, bucket] of gitSamplingBuckets) { + if (nowMs - bucket.windowStartMs >= GIT_FAST_SUCCESS_WINDOW_MS) { + gitSamplingBuckets.delete(key) + } + } + while (gitSamplingBuckets.size > GIT_SAMPLING_MAX_BUCKETS) { + let oldestKey: string | undefined + let oldestWindowStartMs = Number.POSITIVE_INFINITY + for (const [key, bucket] of gitSamplingBuckets) { + if (bucket.windowStartMs < oldestWindowStartMs) { + oldestKey = key + oldestWindowStartMs = bucket.windowStartMs + } + } + if (oldestKey === undefined) { + return + } + gitSamplingBuckets.delete(oldestKey) + } +} + +function gitSamplingKey(meta: GitSpanArgs): string { + return `${gitSubcommandFromArgs(meta.args)}\u0000${meta.cwd ?? ''}` +} + +function shouldRecordGitSpan( + meta: GitSpanArgs, + record: { durationMs: number; startTimeUnixNano: string; exit: { _tag: string } } +): boolean { + if (record.exit._tag !== 'Success' || record.durationMs >= GIT_FAST_SUCCESS_THRESHOLD_MS) { + return true + } + + const nowMs = Number(BigInt(record.startTimeUnixNano) / 1_000_000n) + pruneGitSamplingBuckets(nowMs) + const key = gitSamplingKey(meta) + const bucket = gitSamplingBuckets.get(key) + if (!bucket) { + gitSamplingBuckets.set(key, { windowStartMs: nowMs, emitted: 1 }) + pruneGitSamplingBuckets(nowMs) + return true + } + if (bucket.emitted < GIT_FAST_SUCCESS_BUDGET_PER_WINDOW) { + bucket.emitted += 1 + return true + } + return false +} + +function addGitAttributes(span: ActiveSpan, meta: GitSpanArgs): void { + span.setAttribute('git.subcommand', gitSubcommandFromArgs(meta.args)) + // Why: git args can contain commit messages, branch names, remotes, or + // paths. Keep cardinality without copying user-authored content. + span.setAttribute('git.arg_count', meta.args.length) + if (meta.cwd) { + span.setAttribute('cwd', meta.cwd) + } +} + +export function _resetGitSpanSamplingForTests(): void { + gitSamplingBuckets.clear() +} + +export function _gitSpanSamplingBucketCountForTests(): number { + return gitSamplingBuckets.size +} + export type GitSpanArgs = { readonly args: readonly string[] readonly cwd?: string } -/** Wrap a git execution in a `git.exec` span. The first argument typically - * is the subcommand (`status`, `clone`, `pull`); promoting it to its own - * attribute makes it grep-friendly without pulling the full args array - * into a single comma-joined string in dashboards. */ +/** Wrap a git execution in a `git.exec` span. Git accepts global options before + * the subcommand; promoting the parsed command to its own attribute makes it + * grep-friendly without copying the full args array into dashboards. */ export async function withGitSpan(meta: GitSpanArgs, fn: () => Promise): Promise { return withSpan( 'git.exec', async (span) => { - span.setAttribute('git.subcommand', meta.args[0] ?? '') - // Why: git args can contain commit messages, branch names, remotes, or - // paths. Keep cardinality without copying user-authored content. - span.setAttribute('git.arg_count', meta.args.length) - if (meta.cwd) { - span.setAttribute('cwd', meta.cwd) - } + addGitAttributes(span, meta) return await fn() }, - { attributes: { kind: 'git' } } + { attributes: { kind: 'git' }, shouldRecord: (record) => shouldRecordGitSpan(meta, record) } ) } diff --git a/src/main/observability/tracer.ts b/src/main/observability/tracer.ts index bb1739acf93..3ef93449cf0 100644 --- a/src/main/observability/tracer.ts +++ b/src/main/observability/tracer.ts @@ -128,10 +128,16 @@ export function getActiveSpanContext(): SpanContext | undefined { * This is the function 90% of call sites should reach for: it keeps span * lifetime scoped to the async work it measures. */ +type SpanRecordDecision = (record: RedactableSpan) => boolean + export async function withSpan( name: string, fn: (span: ActiveSpan) => Promise | T, - options?: { kind?: string; attributes?: Record } + options?: { + kind?: string + attributes?: Record + shouldRecord?: SpanRecordDecision + } ): Promise { const span = startSpan(name, options) try { @@ -157,7 +163,11 @@ export async function withSpan( */ export function startSpan( name: string, - options?: { kind?: string; attributes?: Record } + options?: { + kind?: string + attributes?: Record + shouldRecord?: SpanRecordDecision + } ): ActiveSpan { if (!activeSink) { return noopSpan @@ -203,6 +213,10 @@ export function startSpan( exit } + if (options?.shouldRecord && !options.shouldRecord(record)) { + return + } + const redacted = redactSpan(record, 'client') // Wrap in a `type: 'effect-span'` envelope so the NDJSON file is // compatible with Effect-style span output. Effect-oriented consumers diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 83cc7f742d6..aaf998b3ca4 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' +import { columnExists, tableExists } from './schema-helpers' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { OpenCodeUsageAttributedEvent, @@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } -function tableExists(db: Database.Database, tableName: string): boolean { - const row = db - .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(tableName) as { found?: number } | undefined - return row?.found === 1 -} - -function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] - return rows.some((row) => row.name === columnName) -} - function getProjectJoin(db: Database.Database): string { return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') ? 'LEFT JOIN project p ON p.id = s.project_id' diff --git a/src/main/opencode-usage/schema-helpers.ts b/src/main/opencode-usage/schema-helpers.ts new file mode 100644 index 00000000000..d392eb58d7d --- /dev/null +++ b/src/main/opencode-usage/schema-helpers.ts @@ -0,0 +1,31 @@ +import type SyncDatabase from '../sqlite/sync-database' + +// Why: OpenCode's usage scanner and the AI Vault session scanner both need to +// probe the opencode.db schema shape across multiple DB generations. Centralizing +// the probes here avoids two private copies and keeps the contract testable. +type Database = SyncDatabase.Database + +/** + * Check whether a table exists in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table name to look up in sqlite_master. + * @returns `true` if the table exists, `false` otherwise. + */ +export function tableExists(db: Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +/** + * Check whether a column exists on a table in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table to inspect via PRAGMA table_info. + * @param columnName - The column name to find. + * @returns `true` if the column exists on the table, `false` otherwise. + */ +export function columnExists(db: Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index fc67b98f78d..4109cb2b2ab 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -478,6 +478,9 @@ describe('Store', () => { expect(settings.editorAutoSaveDelayMs).toBe(1000) expect(settings.terminalFontSize).toBe(14) expect(settings.terminalFontWeight).toBe(500) + expect(settings.terminalScrollSensitivity).toBe(1.15) + expect(settings.terminalFastScrollSensitivity).toBe(5) + expect(settings.terminalTuiScrollSensitivity).toBe(3) expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index ba4a47be8e3..9de9aa20b17 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -227,6 +227,18 @@ describe('SshPtyProvider', () => { expect(mux.request).toHaveBeenCalledWith('pty.attach', { id: 'pty-1' }) }) + it('attachForReconnect returns replay without relay notification', async () => { + mux.request.mockResolvedValue({ replay: 'restored output' }) + + const result = await provider.attachForReconnect(scopedPty1) + + expect(result).toEqual({ replay: 'restored output' }) + expect(mux.request).toHaveBeenCalledWith('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + }) + it('write sends pty.data notification', () => { provider.write(scopedPty1, 'hello') expect(mux.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 327bbb9d7e5..56c4b94c737 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -177,6 +177,16 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) }) } + async attachForReconnect(id: string): Promise<{ replay?: string }> { + // Why: reconnect owns replay delivery so stale/duplicate attach results can + // be filtered before they reach the renderer. + const result = (await this.mux.request('pty.attach', { + id: this.toRelayPtyId(id), + suppressReplayNotification: true + })) as { replay?: string } | undefined + return result ?? {} + } + write(id: string, data: string): void { this.mux.notify('pty.data', { id: this.toRelayPtyId(id), data }) } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0801f002ed5..e7e8770a89c 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -89,6 +89,17 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(command).not.toContain('`e]133') }) + it('normalizes MSYS drive cwd before spawning native PowerShell', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice' + ) + + expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { const result = resolveWindowsShellLaunchArgs( 'powershell.exe', @@ -189,6 +200,33 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) + it('translates MSYS drive cwd to /mnt//... for wsl.exe', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice', + undefined, + 'codex' + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project')) + expect(result.effectiveCwd).toBe('C:\\Users\\alice') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + + it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice', + { distro: 'Ubuntu', treatPosixCwdAsWsl: true } + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu')) + expect(result.effectiveCwd).toBe('C:\\Users\\alice') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('escapes single quotes when translating a WSL cwd', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') // The injected sh cmd must not break out of the surrounding single quotes diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 9944b127745..0e3d181fdd4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -105,6 +105,19 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { return distro ? ['-d', distro, ...shellArgs] : shellArgs } +function normalizeMsysDrivePath(cwd: string): string { + const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/) + if (!match) { + return cwd + } + + // Why: Git Bash/MSYS launch contexts can hand Windows terminals `/d/repo`; + // ConPTY requires the equivalent native drive path as its cwd. + const driveLetter = match[1].toUpperCase() + const rest = match[2]?.replace(/\//g, '\\') ?? '' + return rest ? `${driveLetter}:\\${rest}` : `${driveLetter}:\\` +} + /** Build the argv + effective cwd for a Windows shell launch. * * - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly. @@ -122,6 +135,8 @@ export function resolveWindowsShellLaunchArgs( startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() + const nativeCwd = normalizeMsysDrivePath(cwd) + const isMsysDriveCwd = nativeCwd !== cwd if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) @@ -133,8 +148,8 @@ export function resolveWindowsShellLaunchArgs( : CMD_UTF8_SETUP_COMMAND ], ...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}), - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } @@ -147,16 +162,16 @@ export function resolveWindowsShellLaunchArgs( ...(powerShellCommand.startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } if (isWindowsGitBashShellPath(shellPath)) { return { shellArgs: ['--login', '-i'], - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } @@ -169,25 +184,25 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, validationCwd: toWindowsWslPath(cwd, wslContext.distro) } } - const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) - const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c' + const driveMatch = nativeCwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) + const linuxCwd = driveMatch ? toLinuxPath(nativeCwd) : '/mnt/c' return { shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro), effectiveCwd: defaultCwd, - validationCwd: cwd + validationCwd: nativeCwd } } return { shellArgs: [], - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index b3c5dc54c20..5c984c607e8 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -169,7 +169,7 @@ describe('fetchClaudeRateLimits', () => { ) }) - it('uses legacy Keychain credentials for host system default without an explicit config dir', async () => { + it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { configDir, @@ -178,22 +178,16 @@ describe('fetchClaudeRateLimits', () => { stripAuthEnv: false, provenance: 'system' } - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-scoped-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'legacy-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', @@ -202,8 +196,9 @@ describe('fetchClaudeRateLimits', () => { weekly: { usedPercent: 34 } }) - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(undefined) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ @@ -214,6 +209,46 @@ describe('fetchClaudeRateLimits', () => { ) }) + it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + runtime: 'host', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'scoped-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer scoped-oauth-token' + }) + }) + ) + }) + it('falls back to the credentials file when Keychain access fails', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 2c8db5194cf..94f725c2366 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -249,13 +249,11 @@ function resolveOAuthCredentialReadOptions( if (!authPreparation) { return undefined } + // Why: Claude Code 2.1+ can scope even the default config dir's macOS + // Keychain item. Try scoped first, with legacy still handled as fallback. const readOptions: OAuthCredentialReadOptions = { - credentialsFileConfigDir: authPreparation.configDir - } - // Why: host system-default launches do not inject CLAUDE_CONFIG_DIR, so - // their Keychain lookup must mirror Claude's legacy service ordering. - if (authPreparation.envPatch.CLAUDE_CONFIG_DIR) { - readOptions.keychainConfigDir = authPreparation.configDir + credentialsFileConfigDir: authPreparation.configDir, + keychainConfigDir: authPreparation.configDir } return readOptions } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index dc48755b1e5..76bcc8672d6 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -353,7 +353,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: undefined, - allowPtyFallback: true + allowPtyFallback: false }) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) @@ -464,6 +464,67 @@ describe('RateLimitService', () => { expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('does not use Claude PTY fallback for system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/tmp/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'system' }), + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => { + const service = new RateLimitService() + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: undefined, + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), + allowPtyFallback: false + }) + }) + it('does not cache host Codex usage under an outgoing WSL account', async () => { const service = new RateLimitService() const wslCodexHome = diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 6b6e3c064db..822dfa37101 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -72,6 +72,18 @@ function normalizePollingInterval(ms: number): number { return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) } +function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles classify missing Claude auth as system-default; keep the + // PTY fallback gate aligned so background refresh cannot trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -792,10 +804,18 @@ export class RateLimitService { return process.platform !== 'win32' } - private shouldAllowClaudePtyFallback(): boolean { + private shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { // Why: automatic recovery uses Claude CLI as the next source, but Windows // hidden PTY support remains less reliable than host/WSL shells. - return process.platform !== 'win32' + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude is not an Orca-managed account. Background + // quota refresh may read existing OAuth, but must not launch Claude and + // trigger auth/browser flows for users who never configured Claude in Orca. + return !isSystemDefaultClaudeAuth(authPreparation) } private withFetchingStatus( @@ -861,7 +881,7 @@ export class RateLimitService { await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }), missingWslCodexHome ?? fetchCodexRateLimits({ @@ -1036,7 +1056,7 @@ export class RateLimitService { const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }).catch( (err): ProviderRateLimits => ({ provider: 'claude', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 096c3ecf771..25dfa727a77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -164,7 +164,7 @@ const { addGitHubIssueCommentMock, listGitHubLabelsMock, listGitHubAssignableUsersMock, - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, listGitLabMergeRequestsMock, listGitLabWorkItemsMock, @@ -255,7 +255,7 @@ const { addGitHubIssueCommentMock: vi.fn(), listGitHubLabelsMock: vi.fn(), listGitHubAssignableUsersMock: vi.fn(), - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), listGitLabMergeRequestsMock: vi.fn(), listGitLabWorkItemsMock: vi.fn(), @@ -319,7 +319,7 @@ vi.mock('../ipc/ssh', () => ({ })) vi.mock('../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) @@ -620,8 +620,8 @@ function resetRuntimeTestMocks(): void { listGitHubLabelsMock.mockResolvedValue([]) listGitHubAssignableUsersMock.mockReset() listGitHubAssignableUsersMock.mockResolvedValue([]) - detectInstalledAgentsMock.mockReset() - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockReset() + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) detectRemoteAgentsMock.mockReset() detectRemoteAgentsMock.mockResolvedValue([]) listGitLabMergeRequestsMock.mockReset() @@ -17898,7 +17898,7 @@ describe('OrcaRuntimeService', () => { }) it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -17959,7 +17959,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18215,7 +18215,7 @@ describe('OrcaRuntimeService', () => { }) it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18276,7 +18276,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/tmp/workspaces/runtime-fallback-draft', @@ -18405,7 +18405,7 @@ describe('OrcaRuntimeService', () => { }) it('lets explicit startup draft agents override the desktop default', async () => { - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) const metaById: Record = {} const runtimeStore = { ...store, @@ -18467,7 +18467,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18485,7 +18485,7 @@ describe('OrcaRuntimeService', () => { }) it('does not auto-launch an agent for startup drafts when the default is blank', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude', 'codex']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18545,7 +18545,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined() @@ -18638,7 +18638,7 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-startup-draft', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a0ca7efcadf..bd81e59fcc7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -172,7 +172,7 @@ import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' -import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight' +import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' import { markCodexProjectTrusted, markCopilotFolderTrusted, @@ -11436,9 +11436,10 @@ export class OrcaRuntimeService { if (!agent) { let detected: string[] = [] try { + // Why: startup-draft fallback can run from sparse runtime launch envs too. detected = repo.connectionId ? await detectRemoteAgents({ connectionId: repo.connectionId }) - : await detectInstalledAgents() + : await detectInstalledAgentsWithShellPathHydration() } catch { detected = [] } diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index 51a1231caa3..d28b97b8e53 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -5,19 +5,19 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { PREFLIGHT_METHODS } from './preflight' const { - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), runPreflightCheckMock: vi.fn() })) vi.mock('../../../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock, runPreflightCheck: runPreflightCheckMock @@ -46,7 +46,7 @@ describe('preflight RPC methods', () => { }) it('detects agents and refreshes PATH on the server through runtime RPC', async () => { - detectInstalledAgentsMock.mockResolvedValueOnce(['codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex']) refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({ agents: ['codex', 'claude'], addedPathSegments: ['/opt/bin'], @@ -60,7 +60,7 @@ describe('preflight RPC methods', () => { const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents')) const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents')) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled() expect(detected).toMatchObject({ ok: true, result: ['codex'] }) expect(refreshed).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 720a823cc22..4e0f06201e9 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, - detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../ipc/preflight' @@ -23,7 +23,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ defineMethod({ name: 'preflight.detectAgents', params: null, - handler: async () => detectInstalledAgents() + handler: async () => detectInstalledAgentsWithShellPathHydration() }), defineMethod({ name: 'preflight.detectRemoteAgents', diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 66c8650221e..9131375d58b 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -48,6 +48,7 @@ vi.mock('../providers/ssh-pty-provider', () => ({ onReplay = vi.fn().mockReturnValue(() => {}) onExit = vi.fn().mockReturnValue(() => {}) attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) dispose = vi.fn() } })) @@ -67,7 +68,8 @@ vi.mock('../ipc/pty', () => ({ unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined) + attach: vi.fn().mockResolvedValue(undefined), + attachForReconnect: vi.fn().mockResolvedValue({}) }), getPtyIdsForConnection: vi.fn().mockReturnValue([]), clearPtyOwnershipForConnection: vi.fn(), @@ -355,7 +357,7 @@ describe('SshRelaySession', () => { const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockResolvedValue(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2']) @@ -366,12 +368,60 @@ describe('SshRelaySession', () => { expect(mockAttach).toHaveBeenCalledWith('pty-2') }) + it('forwards reconnect replay after the attach attempt is still current', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.clearAllMocks() + mockDeploySuccess() + + const { getSshPtyProvider } = await import('../ipc/pty') + const mockAttach = vi.fn().mockResolvedValue({ replay: 'restored-output' }) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: mockAttach, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) + + await session.reconnect(mockConn) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { + id: 'ssh:target-1@@pty-1', + data: 'restored-output' + }) + }) + + it('drops identical reconnect replay payloads inside one reconnect burst', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.clearAllMocks() + mockDeploySuccess() + + const { getSshPtyProvider } = await import('../ipc/pty') + const mockAttach = vi.fn().mockResolvedValue({ replay: 'same-output' }) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: mockAttach, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) + + await session.reconnect(mockConn) + await session.reconnect(mockConn) + + const replaySends = vi + .mocked(mockWindow.webContents.send) + .mock.calls.filter(([channel]) => channel === 'pty:replay') + expect(mockAttach).toHaveBeenCalledTimes(2) + expect(replaySends).toHaveLength(1) + }) + it('establish re-attaches owned PTYs after explicit disconnect', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockResolvedValue(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1']) @@ -390,7 +440,7 @@ describe('SshRelaySession', () => { const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockResolvedValue(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue([]) @@ -419,7 +469,7 @@ describe('SshRelaySession', () => { }) ) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) @@ -454,7 +504,7 @@ describe('SshRelaySession', () => { }) ) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) @@ -486,7 +536,7 @@ describe('SshRelaySession', () => { .mockRejectedValueOnce(new Error('PTY "pty-stale" not found')) .mockResolvedValueOnce(undefined) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-stale', 'pty-live']) @@ -515,7 +565,7 @@ describe('SshRelaySession', () => { const { getSshPtyProvider } = await import('../ipc/pty') const mockAttach = vi.fn().mockRejectedValue(new Error('Multiplexer disposed')) vi.mocked(getSshPtyProvider).mockReturnValue({ - attach: mockAttach, + attachForReconnect: mockAttach, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-live']) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index ecbf8b280c1..d124aca8263 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -73,6 +73,14 @@ type RemoteCliBridgeEnv = { pathDelimiter?: ':' | ';' } +type ForwardedReplayFingerprint = { + fingerprint: string + deliveredAt: number +} + +const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 +const REPLAY_FINGERPRINT_EDGE_CHARS = 128 + function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): number { const raw = graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS const requested = Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS @@ -111,6 +119,7 @@ export class SshRelaySession { private currentConnection: SshConnection | null = null private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null + private forwardedReattachReplayByPty = new Map() constructor( readonly targetId: string, @@ -950,6 +959,7 @@ export class SshRelaySession { const relayPtyId = toRelaySshPtyId(this.targetId, payload.id) clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) + this.forwardedReattachReplayByPty.delete(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code) const win = this.getMainWindow() @@ -959,6 +969,34 @@ export class SshRelaySession { }) } + private replayFingerprint(data: string): string { + const head = data.slice(0, REPLAY_FINGERPRINT_EDGE_CHARS) + const tail = data.slice(-REPLAY_FINGERPRINT_EDGE_CHARS) + return `${data.length}:${head}:${tail}` + } + + private shouldForwardReattachReplay(appPtyId: string, data: string): boolean { + const now = Date.now() + const fingerprint = this.replayFingerprint(data) + const previous = this.forwardedReattachReplayByPty.get(appPtyId) + this.forwardedReattachReplayByPty.set(appPtyId, { fingerprint, deliveredAt: now }) + return ( + !previous || + previous.fingerprint !== fingerprint || + now - previous.deliveredAt > RECONNECT_REPLAY_DUPLICATE_WINDOW_MS + ) + } + + private forwardReattachReplay(appPtyId: string, data: string): void { + if (!data || !this.shouldForwardReattachReplay(appPtyId, data)) { + return + } + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:replay', { id: appPtyId, data }) + } + } + private async reattachKnownPtys(shouldContinue: () => boolean): Promise { const leasedPtyIds = this.store .getSshRemotePtyLeases(this.targetId) @@ -983,13 +1021,14 @@ export class SshRelaySession { return } try { - await ptyProvider.attach(ptyId) + const attachResult = (await ptyProvider.attachForReconnect(ptyId)) ?? {} if (!shouldContinue()) { return } const appPtyId = toAppSshPtyId(this.targetId, ptyId) setPtyOwnership(appPtyId, this.targetId) this.store.markSshRemotePtyLease(this.targetId, ptyId, 'attached') + this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') } catch (err) { if (!isSshPtyNotFoundError(err)) { throw err @@ -1002,6 +1041,7 @@ export class SshRelaySession { const appPtyId = toAppSshPtyId(this.targetId, ptyId) clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) + this.forwardedReattachReplayByPty.delete(appPtyId) this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') // Why: if the new relay cannot reattach this id, the remote backing // process is gone. Tell the renderer so it clears stale pane bindings diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts new file mode 100644 index 00000000000..b007c8fd17c --- /dev/null +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -0,0 +1,283 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' + +const execCommandMock = vi.hoisted(() => vi.fn()) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: execCommandMock +})) + +// Why: await import() is required so vi.mock() above registers before the +// module under test is evaluated. Static import would bypass the mock. +const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution') + +const conn = {} as SshConnection + +describe('resolveRemoteNodePath', () => { + beforeEach(() => { + execCommandMock.mockReset() + }) + + // ── Path-probe strategy (runs first) ─────────────────────────────────── + + it('resolves system node via the path probe', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') // path probe + .mockResolvedValueOnce('v20.0.0\n') // version check + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + }) + + it('probes mise install directories', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.local/share/mise/installs/node/20/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') + }) + + it('probes asdf install directories', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.asdf/installs/nodejs/20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') + }) + + it('probes volta bin directory', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.volta/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('$HOME/.volta/bin/node') + }) + + it('respects a custom NVM_DIR instead of hardcoding $HOME/.nvm', async () => { + execCommandMock + .mockResolvedValueOnce('/custom/nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('nvm_dirs=${NVM_DIR:-"$HOME/.nvm"}') + expect(callScript).toContain('NVM_DIR[[:space:]]*=') + expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node') + }) + + it('quotes version-manager directory prefixes while leaving globs active', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.fnm/node-versions/v20.11.0/installation/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node') + expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') + expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') + }) + + it('does not depend on GNU sort when probing version-manager directories', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).not.toContain('sort -V') + }) + + it('keeps the path-probe script successful when optional directories are missing', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript.trimEnd()).toMatch(/\ntrue$/) + }) + + it('expands tilde NVM_DIR assignments from shell dotfiles', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + const home = mkdtempSync(path.join(os.tmpdir(), 'orca-nvm-probe-')) + try { + const nodePath = path.join(home, 'tilde-nvm/versions/node/v20.11.0/bin/node') + mkdirSync(path.dirname(nodePath), { recursive: true }) + writeFileSync(nodePath, '#!/bin/sh\nprintf "v20.11.0\\n"\n') + chmodSync(nodePath, 0o755) + writeFileSync(path.join(home, '.zshrc'), 'export NVM_DIR=~/tilde-nvm\n') + + const output = execFileSync('/bin/sh', ['-c', callScript], { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + }) + + expect(output.split('\n')).toContain(nodePath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + it('joins probes with newlines, not ||, so a missing dir does not mask later probes', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + // Why: an `||` chain would stop after the first successful probe and hide + // later version managers that may hold the first usable Node. + expect(callScript).not.toMatch(/node\b.*\|\|/) + }) + + it('rejects a path-probe candidate whose version is below the minimum', async () => { + // Probe returns two candidates; the first (v10) is too old, the second + // (v20) must be selected instead. + execCommandMock + .mockResolvedValueOnce( + '/home/u/.nvm/versions/node/v10.24.1/bin/node\n/home/u/.nvm/versions/node/v20.11.0/bin/node\n' + ) + .mockResolvedValueOnce('v10.24.1\n') // first candidate fails the gate + .mockResolvedValueOnce('v20.11.0\n') // second candidate passes + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v20.11.0/bin/node' + ) + }) + + it('accepts Node 18 (the exact minimum) as valid', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v18.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + }) + + it('deduplicates repeated candidate paths before version-checking', async () => { + // Why: some managers leave stale shims that resolve to the same binary; + // we should not version-check the same path twice. + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + expect(execCommandMock).toHaveBeenCalledTimes(2) + }) + + it('falls back to the login shell when path probes find nothing', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/bin/zsh') // $SHELL + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') // command -v node + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v20.11.0/bin/node' + ) + + expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/zsh' -lc 'command -v node'`, { + wrapCommand: false, + timeoutMs: 8_000 + }) + }) + + it('falls back to the login shell when every path-probe candidate is too old', async () => { + execCommandMock + .mockResolvedValueOnce('/old/node\n') // path probe + .mockResolvedValueOnce('v10.24.1\n') // too old + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.nvm/versions/node/v20.11.0/bin/node' + ) + }) + + // ── Login-shell strategy (fallback) ─────────────────────────────────── + + it('respects a non-default $SHELL instead of hardcoding bash', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/usr/bin/fish') // $SHELL + .mockResolvedValueOnce('/opt/homebrew/bin/node\n') + .mockResolvedValueOnce('v22.0.0\n') + + await resolveRemoteNodePath(conn) + + expect(execCommandMock).toHaveBeenNthCalledWith( + 3, + conn, + `'/usr/bin/fish' -lc 'command -v node'`, + { wrapCommand: false, timeoutMs: 8_000 } + ) + }) + + it('uses /bin/sh when the remote shell expansion falls back to it', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/bin/sh\n') // ${SHELL:-/bin/sh} + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/sh' -c 'command -v node'`, { + wrapCommand: false, + timeoutMs: 8_000 + }) + }) + + // ── Failure ─────────────────────────────────────────────────────────── + + it('throws when both strategies find no usable node', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('\n') // command -v node: empty + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) + + it('throws when the path-probe exec fails and the login shell finds nothing', async () => { + execCommandMock + .mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors + .mockResolvedValueOnce('/bin/zsh') // $SHELL + .mockResolvedValueOnce('\n') // command -v node: empty + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) + + it('throws when every candidate across both strategies is below the minimum', async () => { + execCommandMock + .mockResolvedValueOnce('/old/node\n') // path probe + .mockResolvedValueOnce('v8.17.0\n') // too old + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('/old/node2\n') // login shell + .mockResolvedValueOnce('v6.17.0\n') // too old + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) +}) diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index e655894e924..a5ec9b9fd05 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -1,12 +1,20 @@ import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' +import { shellEscape } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' +import { execCommand } from './ssh-relay-deploy-helpers' + +// Why: the relay requires Node.js 18+. Version managers like nvm keep every +// installed version on disk, so a naive "highest version" glob can hand back +// Node 8/10/12 and crash the relay on launch. Gate every candidate on this. +const MIN_NODE_MAJOR = 18 + +// Why: the login-shell fallback catches custom PATH setups in ~/.profile that +// the path probes don't cover. Interactive configs (conda prompts, etc.) can +// hang a login shell, so keep this short. +const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 -// Why: non-login SSH shells (the default for `exec`) don't source -// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH. -// We try common locations and fall back to a login-shell `which`. export async function resolveRemoteNodePath( conn: SshConnection, host?: RemoteHostPlatform @@ -15,48 +23,163 @@ export async function resolveRemoteNodePath( return resolveRemoteWindowsNodePath(conn) } - const script = [ - 'command -v node 2>/dev/null', - 'command -v /usr/local/bin/node 2>/dev/null', - 'command -v /opt/homebrew/bin/node 2>/dev/null', - // Why: nvm installs into a versioned directory. `ls -1` sorts - // alphabetically, which misorders versions (e.g. v9 > v18). Pipe - // through `sort -V` (version sort) so we pick the highest version. - 'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1', - 'command -v $HOME/.local/bin/node 2>/dev/null', - 'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null' - ].join(' || ') - - try { - const result = await execCommand(conn, script) - const nodePath = result.trim().split('\n')[0] - if (nodePath) { - console.log(`[ssh-relay] Found node at: ${nodePath}`) - return nodePath - } - } catch { - // Fall through to login shell attempt + // Strategy 1: probe well-known install directories for every common Node + // version manager (nvm, fnm, mise, asdf, volta, n) plus system locations. + // This doesn't depend on shell startup-file semantics — bash -lc skips + // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where + // nvm/mise/asdf hooks live. Probing directories directly is deterministic. + const probedPath = await tryResolveViaKnownPaths(conn) + if (probedPath) { + return probedPath } - // Why: last resort — source the full login profile. This is separated into - // its own exec because `bash -lc` can hang on remotes with interactive - // shell configs (conda prompts, etc.). If this times out, the error message - // from execCommand will tell us it was the login shell attempt. - try { - console.log('[ssh-relay] Trying login shell to find node...') - const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null") - const nodePath = result.trim().split('\n')[0] - if (nodePath) { - console.log(`[ssh-relay] Found node via login shell: ${nodePath}`) - return nodePath - } - } catch { - // Fall through + // Strategy 2 (fallback): ask the user's login shell. Catches custom PATH + // setups in ~/.profile / ~/.bash_profile that the probes don't cover. + const loginShellPath = await tryResolveViaLoginShell(conn) + if (loginShellPath) { + return loginShellPath } throwNodeNotFound() } +// Probe the on-disk install directories of every common Node version manager +// plus system package-manager locations. Every probe runs unconditionally so +// a missing directory prints nothing rather than short-circuiting later +// probes. Returns the first candidate that meets the minimum version. +async function tryResolveViaKnownPaths(conn: SshConnection): Promise { + const script = ` +command -v node 2>/dev/null +nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} +for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" +do + [ -r "$nvm_file" ] || continue + nvm_dir_from_file=$(sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' "$nvm_file" | tail -n 1) + case "$nvm_dir_from_file" in + \\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;; + \\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;; + *) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;; + esac + case "$nvm_dir_from_file" in + '$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;; + "~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;; + esac + [ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs +$nvm_dir_from_file" +done +printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir +do + [ -n "$nvm_dir" ] || continue + for candidate in "$nvm_dir"/versions/node/*/bin/node + do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" + done +done +for candidate in \\ + /usr/local/bin/node \\ + /opt/homebrew/bin/node \\ + "$HOME/.local/bin/node" \\ + "$HOME/.fnm/aliases/default/bin/node" \\ + "$HOME/.fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/mise/shims/node" \\ + "$HOME/.local/share/mise/installs/node"/*/bin/node \\ + "$HOME/.asdf/shims/node" \\ + "$HOME/.asdf/installs/nodejs"/*/bin/node \\ + "$HOME/.volta/bin/node" \\ + /usr/local/n/versions/node/*/bin/node +do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" +done +true +` + + try { + const result = await execCommand(conn, script) + const seen = new Set() + for (const line of result.split('\n')) { + const candidate = line.trim() + if (!candidate || seen.has(candidate)) { + continue + } + seen.add(candidate) + if (await nodeMeetsVersionRequirement(conn, candidate)) { + console.log(`[ssh-relay] Found node via path probe: ${candidate}`) + return candidate + } + } + } catch { + // Fall through to login shell. + } + return null +} + +// Run `command -v node` under the user's login shell, then verify the result +// meets the minimum version. Returns null on any failure (shell missing, no +// node found, version too old, timeout) so callers fall through to the error. +async function tryResolveViaLoginShell(conn: SshConnection): Promise { + try { + // Why: $SHELL is the user's configured login shell (set by chsh / passwd). + // Using it — rather than hardcoding bash — means zsh/fish users whose + // custom PATH hooks live in profile files get coverage too. We fall back + // to sh if $SHELL is unset (rare, e.g. restricted accounts). + const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { + timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS + }) + const shell = shellResult.trim().split('\n')[0] + if (!shell) { + return null + } + + const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), { + wrapCommand: false, + timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS + }) + const candidate = nodePath.trim().split('\n')[0] + if (!candidate) { + return null + } + + if (await nodeMeetsVersionRequirement(conn, candidate)) { + console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`) + return candidate + } + } catch { + // Fall through. + } + return null +} + +function buildCommandInShell(shell: string, command: string): string { + const shellName = shell.split('/').at(-1) + // Why: dash and POSIX sh do not require `-l`; when $SHELL falls back to + // /bin/sh, prefer a portable command over login-shell semantics. + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + return `${shellEscape(shell)} ${mode} ${shellEscape(command)}` +} + +// Returns true if `nodePath` runs and reports Node >= MIN_NODE_MAJOR. +// Caches nothing — this runs at most a few times per resolution (one per +// candidate), and the exec round-trip dominates. +async function nodeMeetsVersionRequirement( + conn: SshConnection, + nodePath: string +): Promise { + try { + const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { + wrapCommand: false + }) + const match = versionOutput.trim().match(/^v?(\d+)/) + if (!match) { + return false + } + const major = Number.parseInt(match[1]!, 10) + return major >= MIN_NODE_MAJOR + } catch { + // Binary missing or fails to run — not usable. + return false + } +} + async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { const script = [ '$paths = @()', diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1091ef71417..f69ca694c4a 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,7 +17,8 @@ const { registerPtyHandlersMock, hydrateLocalPtyRegistryAtBootMock, setupAutoUpdaterMock, - browserManagerUnregisterAllMock + browserManagerUnregisterAllMock, + runWorktreeChangeInvalidatorsMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), @@ -33,7 +34,8 @@ const { registerPtyHandlersMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), - browserManagerUnregisterAllMock: vi.fn() + browserManagerUnregisterAllMock: vi.fn(), + runWorktreeChangeInvalidatorsMock: vi.fn() })) vi.mock('electron', () => ({ @@ -64,6 +66,10 @@ vi.mock('../ipc/worktrees', () => ({ registerWorktreeHandlers: registerWorktreeHandlersMock })) +vi.mock('../ipc/worktree-change-invalidators', () => ({ + runWorktreeChangeInvalidators: runWorktreeChangeInvalidatorsMock +})) + vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), registerPtyHandlers: registerPtyHandlersMock @@ -592,5 +598,9 @@ describe('attachMainWindowServices', () => { } ] ]) + expect(runWorktreeChangeInvalidatorsMock).toHaveBeenCalledWith('repo-1') + expect(runWorktreeChangeInvalidatorsMock.mock.invocationCallOrder[0]).toBeLessThan( + sendMock.mock.invocationCallOrder[0] + ) }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index ce720ca612c..eace514bcc9 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -36,6 +36,7 @@ import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../share import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' +import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' let appReloadHandlerTokenCounter = 0 let activeAppReloadHandlerToken: number | null = null @@ -216,8 +217,12 @@ function registerRuntimeWindowLifecycle( } } runtime.setNotifier({ - worktreesChanged: (repoId, renamed) => - send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }), + worktreesChanged: (repoId, renamed) => { + // Why: clear detected-worktree scan caches before renderer listeners + // handle this event, preventing stale TTL reads after mutations. + runWorktreeChangeInvalidators(repoId) + send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }) + }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), reposChanged: () => send('repos:changed'), diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 911e997d47a..67da1e4672d 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -12,6 +12,9 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, + fsMkdirMock, + fsReaddirMock, + fsRmMock, fsWriteFileMock, fsStatMock, clipboardReadTextMock, @@ -39,6 +42,9 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), + fsMkdirMock: vi.fn(), + fsReaddirMock: vi.fn(), + fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), fsStatMock: vi.fn(), clipboardReadTextMock: vi.fn(), @@ -56,6 +62,9 @@ vi.mock('node:child_process', () => ({ })) vi.mock('node:fs/promises', () => ({ + mkdir: fsMkdirMock, + readdir: fsReaddirMock, + rm: fsRmMock, stat: fsStatMock, default: { writeFile: fsWriteFileMock @@ -111,6 +120,7 @@ import { registerClipboardHandlers, setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' +import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() @@ -150,6 +160,10 @@ function trackPromiseSettled(promise: Promise): () => boolean { return () => settled } +function dirent(name: string, directory = true): { name: string; isDirectory: () => boolean } { + return { name, isDirectory: () => directory } +} + describe('registerClipboardHandlers', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) @@ -159,6 +173,12 @@ describe('registerClipboardHandlers', () => { childStdinEndMock.mockClear() resolveAuthorizedPathMock.mockReset() resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + fsMkdirMock.mockReset() + fsMkdirMock.mockResolvedValue(undefined) + fsReaddirMock.mockReset() + fsReaddirMock.mockResolvedValue([]) + fsRmMock.mockReset() + fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() fsStatMock.mockReset() fsStatMock.mockResolvedValue({}) @@ -256,6 +276,111 @@ describe('registerClipboardHandlers', () => { } }) + it('sweeps expired remote clipboard staging directories', async () => { + const nowMs = 1760000000000 + fsReaddirMock.mockResolvedValue([ + dirent('orca-clipboard-file-expired'), + dirent('orca-clipboard-file-fresh'), + dirent('orca-clipboard-file-plain-file', false), + dirent('unrelated-temp') + ]) + fsStatMock.mockImplementation(async (targetPath: string) => { + if (targetPath.endsWith('expired')) { + return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 } + } + if (targetPath.endsWith('fresh')) { + return { mtimeMs: nowMs - 1000 } + } + throw new Error(`unexpected stat: ${targetPath}`) + }) + + await cleanupExpiredRemoteClipboardFiles(nowMs) + + expect(fsRmMock).toHaveBeenCalledTimes(1) + expect(fsRmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { + recursive: true, + force: true + }) + }) + + it('materializes remote files before writing them to the OS clipboard', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), + downloadFile: vi.fn().mockResolvedValue(undefined) + } + getSshFilesystemProviderMock.mockReturnValue(provider) + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + const tempDir = join( + '/tmp', + 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + ) + const tempPath = join(tempDir, 'report.pdf') + + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { + filePath: '/remote/report.pdf', + connectionId: 'ssh-1' + }) + ).resolves.toEqual({ ok: true }) + + expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf') + expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 }) + expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) + expect(fsStatMock).toHaveBeenCalledWith(tempPath) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() + expect(fsRmMock).not.toHaveBeenCalled() + }) + + it('does not materialize remote directories for OS clipboard copy', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), + downloadFile: vi.fn() + } + getSshFilesystemProviderMock.mockReturnValue(provider) + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { + filePath: '/remote/src', + connectionId: 'ssh-1' + }) + ).resolves.toEqual({ ok: false, reason: 'is-directory' }) + + expect(provider.downloadFile).not.toHaveBeenCalled() + expect(fsMkdirMock).not.toHaveBeenCalled() + expect(clipboardWriteBufferMock).not.toHaveBeenCalled() + }) + + it('cleans up remote clipboard temp files when transfer fails', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), + downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed')) + } + getSshFilesystemProviderMock.mockReturnValue(provider) + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + const tempDir = join( + '/tmp', + 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + ) + const tempPath = join(tempDir, 'report.pdf') + + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { + filePath: '/remote/report.pdf', + connectionId: 'ssh-1' + }) + ).rejects.toThrow('transfer failed') + + expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) + expect(fsRmMock).toHaveBeenCalledWith(tempDir, { recursive: true, force: true }) + expect(clipboardWriteBufferMock).not.toHaveBeenCalled() + }) + it('rejects unauthorized local files before touching the OS clipboard', async () => { resolveAuthorizedPathMock.mockRejectedValue( new Error( diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 0d0570f9408..5f572666d61 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -23,10 +23,23 @@ import { assertClipboardImageByteLengthWithinLimit, assertClipboardImageDimensionsWithinLimit } from '../../shared/clipboard-image' -import { writeFileToClipboard } from './clipboard-file-copy' +import { + writeFileToClipboard, + type ClipboardFileDeps, + type ClipboardFileResult +} from './clipboard-file-copy' +import { + cleanupExpiredRemoteClipboardFiles, + writeRemoteFileToClipboard +} from './clipboard-remote-file-copy' let trustedClipboardRendererWebContentsId: number | null = null +type ClipboardWriteFileRequest = { + filePath: string + connectionId?: string +} + export function setTrustedClipboardRendererWebContentsId(webContentsId: number | null): void { trustedClipboardRendererWebContentsId = webContentsId } @@ -53,6 +66,8 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:writeFile') ipcMain.removeHandler('clipboard:saveImageAsTempFile') + void cleanupExpiredRemoteClipboardFiles() + ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { assertTrustedClipboardSender(event) return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options) @@ -80,13 +95,16 @@ export function registerClipboardHandlers(store: Store): void { } ) // Why: copy the actual file to the OS clipboard so pasting in Finder/Explorer - // drops the file itself, not its path as text. Local files only. - ipcMain.handle('clipboard:writeFile', (event, filePath: string) => { - assertTrustedClipboardSender(event) - return writeFileToClipboard(filePath, { - platform: process.platform, - desktop: process.env.XDG_CURRENT_DESKTOP, - resolveFilePath: async (path) => { + // drops the file itself, not its path as text. + ipcMain.handle( + 'clipboard:writeFile', + (event, args: unknown): ClipboardFileResult | Promise => { + assertTrustedClipboardSender(event) + const request = normalizeClipboardWriteFileRequest(args) + if (!request) { + return { ok: false, reason: 'invalid-path' } + } + const deps = makeClipboardFileDeps(async (path) => { try { const authorizedPath = await resolveAuthorizedPath(path, store) await stat(authorizedPath) @@ -97,11 +115,17 @@ export function registerClipboardHandlers(store: Store): void { } return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' } } - }, - writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), - runCommand - }) - }) + }) + if (request.connectionId) { + return writeRemoteFileToClipboard({ + remotePath: request.filePath, + connectionId: request.connectionId, + deps + }) + } + return writeFileToClipboard(request.filePath, deps) + } + ) ipcMain.handle('clipboard:writeText', async (event, text: string) => { assertTrustedClipboardSender(event) return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text)) @@ -151,6 +175,36 @@ export function registerClipboardHandlers(store: Store): void { }) } +function normalizeClipboardWriteFileRequest(args: unknown): ClipboardWriteFileRequest | null { + if (typeof args === 'string') { + return { filePath: args } + } + if (!args || typeof args !== 'object' || Array.isArray(args)) { + return null + } + const filePath = (args as { filePath?: unknown }).filePath + if (typeof filePath !== 'string') { + return null + } + const connectionId = (args as { connectionId?: unknown }).connectionId + if (typeof connectionId === 'string' && connectionId.trim() !== '') { + return { filePath, connectionId } + } + return { filePath } +} + +function makeClipboardFileDeps( + resolveFilePath: ClipboardFileDeps['resolveFilePath'] +): ClipboardFileDeps { + return { + platform: process.platform, + desktop: process.env.XDG_CURRENT_DESKTOP, + resolveFilePath, + writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), + runCommand + } +} + function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void { if (!isTrustedClipboardRenderer(event.sender)) { throw new Error('Unauthorized clipboard IPC sender') diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts new file mode 100644 index 00000000000..052fce31ef3 --- /dev/null +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -0,0 +1,129 @@ +import { randomUUID } from 'node:crypto' +import type { Dirent } from 'node:fs' +import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' + +import { app } from 'electron' + +import { getRuntimePathBasename } from '../../shared/cross-platform-path' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + writeFileToClipboard, + type ClipboardFileDeps, + type ClipboardFileResult +} from './clipboard-file-copy' + +type RemoteClipboardFileDeps = Omit + +const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 +const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' +const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i +const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) + +export async function writeRemoteFileToClipboard({ + remotePath, + connectionId, + deps +}: { + remotePath: string + connectionId: string + deps: RemoteClipboardFileDeps +}): Promise { + const provider = requireSshFilesystemProvider(connectionId) + const remoteStat = await provider.stat(remotePath) + if (remoteStat.type === 'directory') { + return { ok: false, reason: 'is-directory' } + } + if (!provider.downloadFile) { + throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') + } + + const tempDir = join( + app.getPath('temp'), + `${REMOTE_CLIPBOARD_FILE_PREFIX}${Date.now()}-${randomUUID()}` + ) + await mkdir(tempDir, { mode: 0o700 }) + const localPath = join( + tempDir, + sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath)) + ) + let keepTempFile = false + + try { + await provider.downloadFile(remotePath, localPath) + const result = await writeFileToClipboard(localPath, { + ...deps, + resolveFilePath: async (path) => { + if (path !== localPath) { + return { ok: false, reason: 'invalid-path' } + } + try { + await stat(path) + return { ok: true, path } + } catch { + return { ok: false, reason: 'not-found' } + } + } + }) + if (result.ok) { + // Why: OS file clipboards keep a path reference, so the staged copy must + // survive after this IPC call long enough for the user to paste it. + keepTempFile = true + scheduleRemoteClipboardFileCleanup(tempDir) + } + return result + } finally { + if (!keepTempFile) { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + } + } +} + +export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { + const tempRoot = app.getPath('temp') + let entries: Dirent[] + try { + entries = await readdir(tempRoot, { withFileTypes: true }) + } catch { + return + } + + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { + return + } + const tempDir = join(tempRoot, entry.name) + try { + const tempStats = await stat(tempDir) + if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { + return + } + await rm(tempDir, { recursive: true, force: true }) + } catch { + // Why: stale staged SSH files should not make startup cleanup noisy. + } + }) + ) +} + +function sanitizeLocalClipboardFilename(remoteBasename: string): string { + const sanitized = Array.from(remoteBasename, (char) => + char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char + ) + .join('') + .replace(/[. ]+$/g, '') + if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) { + return 'download' + } + return sanitized +} + +function scheduleRemoteClipboardFileCleanup(tempDir: string): void { + const timer = setTimeout(() => { + void rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + }, REMOTE_CLIPBOARD_FILE_TTL_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e8a616dd001..6f3c64e62b6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2491,7 +2491,14 @@ export type PreloadApi = { writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void - writeClipboardFile: (filePath: string) => Promise<{ ok: boolean; reason?: string }> + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ) => Promise<{ ok: boolean; reason?: string }> onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 522b4dbf54c..e8a3786b429 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3308,8 +3308,14 @@ const api = { mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' }) }, - writeClipboardFile: (filePath: string): Promise<{ ok: boolean; reason?: string }> => - ipcRenderer.invoke('clipboard:writeFile', filePath), + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => subscribeNativeFileDrop(callback), getZoomLevel: (): number => webFrame.getZoomLevel(), diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index 47ddb8a671d..d55e86d7584 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -5,6 +5,7 @@ import * as path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' import { getStatusOp } from './git-handler-status-ops' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' const LARGE_STATUS_ENTRY_COUNT = 150_000 @@ -16,14 +17,20 @@ function buildLargeStatusOutput(count: number): string { return lines.join('\n') } +function buildBranchStatusOutput(head: string, branch: string): string { + return [`# branch.oid ${head}`, `# branch.head ${branch}`].join('\n') +} + describe('getStatusOp', () => { let tmpDir: string beforeEach(() => { + clearNoEffectiveUpstreamStatusCache() tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) }) afterEach(async () => { + clearNoEffectiveUpstreamStatusCache() await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -70,4 +77,142 @@ describe('getStatusOp', () => { expect(result.didHitLimit).toBeUndefined() expect(result.entries).toHaveLength(5) }) + + it('caches no-effective-upstream probes across status polls for the same head', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + const first = await getStatusOp(git, { worktreePath: tmpDir }) + const firstCallCount = git.mock.calls.length + const second = await getStatusOp(git, { worktreePath: tmpDir }) + + expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(second.upstreamStatus).toEqual(first.upstreamStatus) + expect(git.mock.calls).toHaveLength(firstCallCount + 1) + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') + ) + ).toHaveLength(1) + }) + + it('coalesces concurrent no-effective-upstream probes', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + await Promise.resolve() + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + await Promise.resolve() + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await Promise.all([ + getStatusOp(git, { worktreePath: tmpDir }), + getStatusOp(git, { worktreePath: tmpDir }), + getStatusOp(git, { worktreePath: tmpDir }) + ]) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') + ) + ).toHaveLength(1) + }) + + it('invalidates cached no-effective-upstream probes when the branch changes', async () => { + let branch = 'feature' + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', branch), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: `${branch}\n`, stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + branch = 'other-feature' + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls + .filter( + ([args]) => + args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/')) + ) + .map(([args]) => args.at(-1)) + ).toEqual(['refs/remotes/origin/feature', 'refs/remotes/origin/other-feature']) + }) + + it('does not cache a configured push target signal', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature/fix'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature/fix\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature/fix') + } + if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) { + return { stdout: 'fork\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('remote.pushDefault')) { + throw new Error('missing push default') + } + if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) { + return { stdout: 'fork\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) { + return { stdout: 'refs/heads/feature/fix\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.feature/fix.base')) { + throw new Error('missing branch base') + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/'))) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(2) + }) }) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index d0c8842f502..8619e6fd1e9 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -11,10 +11,8 @@ import { parseUnmergedEntry } from './git-handler-utils' import { parseStatusOutput } from './git-status-output-parser' import type { GitExec } from './git-handler-ops' import type { GitUpstreamStatus } from '../shared/types' -import { - getEffectiveGitUpstreamStatus, - splitRemoteBranchName -} from '../shared/git-effective-upstream' +import { splitRemoteBranchName } from '../shared/git-effective-upstream' +import { readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' import { applyLineStats, collectUntrackedAdditions, @@ -133,11 +131,17 @@ export async function getStatusOp( if (!didHitLimit) { if (shouldProbeEffectiveUpstreamStatus(branch, upstreamStatus?.upstreamName)) { - try { - upstreamStatus = await getEffectiveGitUpstreamStatus((args) => git(args, worktreePath)) - } catch { - // Why: status polling should keep returning working-tree entries even - // if the richer upstream probe hits a transient SSH/git ref error. + const branchName = getShortBranchName(branch) + if (branchName) { + try { + upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, + (args) => git(args, worktreePath) + ) + } catch { + // Why: status polling should keep returning working-tree entries even + // if the richer upstream probe hits a transient SSH/git ref error. + } } } diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts new file mode 100644 index 00000000000..ec1764891c8 --- /dev/null +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -0,0 +1,101 @@ +import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' +import type { GitCommandRunner } from '../shared/git-effective-upstream' +import type { GitUpstreamStatus } from '../shared/types' + +const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 30_000 + +type NoEffectiveUpstreamCacheIdentity = { + worktreePath: string + branchName: string + upstreamName?: string +} + +type NoEffectiveUpstreamCacheEntry = { + status: GitUpstreamStatus + expiresAt: number +} + +const noEffectiveUpstreamByIdentity = new Map() +const noEffectiveUpstreamInFlight = new Map>() + +function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string { + return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') +} + +function readCachedNoEffectiveUpstreamStatus( + cacheKey: string, + nowMs = Date.now() +): GitUpstreamStatus | null { + const entry = noEffectiveUpstreamByIdentity.get(cacheKey) + if (!entry) { + return null + } + if (entry.expiresAt <= nowMs) { + noEffectiveUpstreamByIdentity.delete(cacheKey) + return null + } + return entry.status +} + +function cacheNoEffectiveUpstreamStatus( + cacheKey: string, + status: GitUpstreamStatus, + probedSameNameOriginRef: boolean, + nowMs = Date.now() +): void { + // Why: hasConfiguredPushTarget controls publish behavior; keep that signal + // fresh rather than serving a stale positive from status polling. + if (status.hasUpstream || status.hasConfiguredPushTarget) { + noEffectiveUpstreamByIdentity.delete(cacheKey) + return + } + // Why: only cache negatives after probing origin/; other resolution + // paths can fail without proving the same-name publish branch is absent. + if (!probedSameNameOriginRef) { + return + } + noEffectiveUpstreamByIdentity.set(cacheKey, { + status, + expiresAt: nowMs + NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS + }) +} + +export async function readOrProbeNoEffectiveUpstreamStatus( + identity: NoEffectiveUpstreamCacheIdentity, + runGit: GitCommandRunner +): Promise { + const cacheKey = noEffectiveUpstreamCacheKey(identity) + const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) + if (cachedStatus) { + return cachedStatus + } + + const inFlight = noEffectiveUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + + let probedSameNameOriginRef = false + const probe = getEffectiveGitUpstreamStatus((args) => { + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) { + probedSameNameOriginRef = true + } + return runGit(args) + }).then((status) => { + cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef) + return status + }) + noEffectiveUpstreamInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) { + noEffectiveUpstreamInFlight.delete(cacheKey) + } + } +} + +export function clearNoEffectiveUpstreamStatusCache(): void { + noEffectiveUpstreamByIdentity.clear() + noEffectiveUpstreamInFlight.clear() +} diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index b2dfffe8ed2..6d2f9339b9e 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,5 +1,49 @@ -import { describe, expect, it } from 'vitest' -import { buildCommandLookupSpec, hasAbsoluteCommandPath } from './preflight-handler' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileAsyncMock } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn() +})) + +vi.mock('child_process', () => { + const execFileWithPromisify = Object.assign(vi.fn(), { + [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock + }) + return { execFile: execFileWithPromisify } +}) + +import { + buildCommandLookupSpec, + buildCommandLookupSpecs, + hasAbsoluteCommandPath, + isCommandOnPathForRelay +} from './preflight-handler' + +function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { + return [ + mode, + [ + `if resolved=$(command -v ${command} 2>/dev/null); then`, + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'fi' + ].join('\n') + ] +} + +function fishLookupArgs(command: string): string[] { + return [ + '-ilc', + [ + `set -l resolved (command -v ${command} 2>/dev/null)`, + 'if test -n "$resolved"', + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'end' + ].join('\n') + ] +} + +beforeEach(() => { + execFileAsyncMock.mockReset() +}) describe('buildCommandLookupSpec', () => { it('uses where.exe on native Windows SSH hosts', () => { @@ -10,15 +54,160 @@ describe('buildCommandLookupSpec', () => { }) }) - it('passes the command as an argument to the POSIX login-shell probe', () => { - expect(buildCommandLookupSpec('codex', 'linux')).toEqual({ + it('falls back to sh for POSIX probes without a configured shell', () => { + expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: ['-lc', 'command -v "$1"', 'sh', 'codex'] + args: lookupArgs("'codex'") + }) + }) + + it('uses the configured remote shell for POSIX probes', () => { + expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ + file: '/bin/zsh', + args: lookupArgs("'codex'", '-ilc') + }) + }) + + it('quotes command names in shell probes', () => { + expect( + buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') + ).toEqual({ + file: '/bin/bash', + args: lookupArgs("'agent'\\''cli'", '-ilc') + }) + }) +}) + +describe('buildCommandLookupSpecs', () => { + it('falls back to inherited PATH after a trusted configured POSIX shell', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ + { file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('allows a custom shell path only when the account login shell matches', () => { + expect( + buildCommandLookupSpecs( + 'codex', + 'darwin', + { SHELL: '/opt/homebrew/bin/zsh' }, + '/opt/homebrew/bin/zsh' + ) + ).toEqual([ + { file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('allows conservative system shell paths when account lookup is unavailable', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({ + file: '/usr/bin/bash', + args: lookupArgs("'codex'", '-ilc') + }) + }) + + it('uses fish syntax for trusted fish shells', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/fish' }, null)[0]).toEqual({ + file: '/usr/bin/fish', + args: fishLookupArgs("'codex'") + }) + }) + + it('ignores untrusted temp shell paths even when the basename is supported', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([ + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('ignores untrusted home-bin shell paths even when the basename is supported', () => { + expect( + buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash') + ).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }]) + }) +}) + +describe('isCommandOnPathForRelay', () => { + it('falls back to inherited PATH when shell startup returns no absolute command path', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'welcome\ncodex is a function\n' }) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/zsh', PATH: '/usr/bin' }, + accountLoginShell: '/bin/zsh' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 1, + '/bin/zsh', + lookupArgs("'codex'", '-ilc'), + { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + } + ) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + }) + + it('falls back to inherited PATH when shell startup fails', async () => { + execFileAsyncMock + .mockRejectedValueOnce(new Error('startup failed')) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/bash', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not execute an untrusted configured shell before inherited PATH lookup', async () => { + execFileAsyncMock.mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/tmp/zsh', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/tmp/zsh' }), + timeout: 5000 }) }) }) describe('hasAbsoluteCommandPath', () => { + it('ignores banners and shell function output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\ncodex is a shell function\n', 'linux')).toBe( + false + ) + }) + + it('ignores unmarked POSIX absolute paths from shell startup output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\n', 'linux')).toBe(false) + }) + + it('recognizes a sentinel-marked command path amid shell startup and exit output', () => { + expect( + hasAbsoluteCommandPath('welcome\n__ORCA_AGENT_PATH__/opt/bin/codex\nlogout-banner\n', 'linux') + ).toBe(true) + }) + it('recognizes Windows absolute command paths', () => { expect( hasAbsoluteCommandPath('C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd\r\n', 'win32') diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index fb47248323d..a0382d77275 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { userInfo } from 'os' import { promisify } from 'util' import path, { win32 } from 'path' import type { RelayDispatcher } from './dispatcher' @@ -12,6 +13,16 @@ type CommandLookupSpec = { windowsHide?: true } +type RelayCommandLookupOptions = { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + accountLoginShell?: string | null +} + +const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) +const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) +const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' + export class PreflightHandler { private dispatcher: RelayDispatcher @@ -43,35 +54,77 @@ export class PreflightHandler { return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } } - // Why: SSH exec channels give the relay a minimal environment without - // .zprofile/.bash_profile sourced. Running `which` directly would miss - // agents installed via Homebrew, nvm, cargo, pipx, etc. Spawning a login - // shell (`-lc`) ensures PATH matches what the user's PTY sessions see. - // Windows has no /bin/sh on native OpenSSH hosts, so use where.exe there. + // Why: SSH exec channels give the relay a minimal environment without shell + // startup files sourced. Ask the user's configured shell so agent dirs added + // by zsh/bash/fish startup hooks match the remote terminal experience. + // Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there. private async isCommandOnPath(command: string): Promise { - try { - const spec = buildCommandLookupSpec(command, process.platform) - const { stdout } = await execFileAsync(spec.file, spec.args, { - encoding: 'utf-8', - env: buildRelayCommandEnv(), - timeout: 5000, - ...(spec.windowsHide ? { windowsHide: true } : {}) - }) - return hasAbsoluteCommandPath(stdout, process.platform) - } catch { - return false - } + return isCommandOnPathForRelay(command) } } export function buildCommandLookupSpec( command: string, - platform: NodeJS.Platform + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null ): CommandLookupSpec { + const [spec] = buildCommandLookupSpecs(command, platform, env, accountLoginShell) + return spec ?? buildPosixCommandLookupSpec(command, '/bin/sh') +} + +export function buildCommandLookupSpecs( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null +): CommandLookupSpec[] { if (platform === 'win32') { - return { file: 'where.exe', args: [command], windowsHide: true } + return [{ file: 'where.exe', args: [command], windowsHide: true }] } - return { file: '/bin/sh', args: ['-lc', 'command -v "$1"', 'sh', command] } + const trustedShell = pickTrustedPosixShell( + env, + resolveAccountLoginShell(platform, accountLoginShell) + ) + const specs: CommandLookupSpec[] = [] + + if (trustedShell) { + specs.push(buildPosixCommandLookupSpec(command, trustedShell)) + } + + const inheritedPathSpec = buildPosixCommandLookupSpec(command, '/bin/sh') + if (!trustedShell || trustedShell !== inheritedPathSpec.file) { + specs.push(inheritedPathSpec) + } + + return specs +} + +export async function isCommandOnPathForRelay( + command: string, + options: RelayCommandLookupOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell) + + for (const spec of specs) { + try { + const { stdout } = await execFileAsync(spec.file, spec.args, { + encoding: 'utf-8', + env: buildRelayCommandEnv(env, platform), + timeout: 5000, + ...(spec.windowsHide ? { windowsHide: true } : {}) + }) + if (hasAbsoluteCommandPath(stdout, platform)) { + return true + } + } catch { + // Try the inherited-PATH fallback before reporting the agent missing. + } + } + + return false } export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean { @@ -79,5 +132,86 @@ export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform return output .split(/\r?\n/) .map((line) => line.trim()) - .some((line) => pathOps.isAbsolute(line)) + .some((line) => { + const resolvedPath = + platform === 'win32' + ? line + : line.startsWith(AGENT_PATH_PREFIX) + ? line.slice(AGENT_PATH_PREFIX.length) + : '' + return pathOps.isAbsolute(resolvedPath) + }) +} + +function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec { + const shellName = path.posix.basename(shell).toLowerCase() + if (shellName === 'fish') { + return { file: shell, args: ['-ilc', buildFishCommandLookupScript(command)] } + } + return { file: shell, args: [getShellCommandMode(shell), buildShCommandLookupScript(command)] } +} + +function buildShCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'fi' + ].join('\n') +} + +function buildFishCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `set -l resolved (command -v ${quotedCommand} 2>/dev/null)`, + 'if test -n "$resolved"', + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'end' + ].join('\n') +} + +function resolveAccountLoginShell( + platform: NodeJS.Platform, + accountLoginShell?: string | null +): string | null { + if (accountLoginShell !== undefined) { + return accountLoginShell + } + if (platform === 'win32') { + return null + } + try { + return userInfo().shell ?? null + } catch { + return null + } +} + +function pickTrustedPosixShell( + env: NodeJS.ProcessEnv, + accountLoginShell: string | null +): string | null { + const shell = env.SHELL + if (!shell || !path.posix.isAbsolute(shell)) { + return null + } + const shellName = path.posix.basename(shell).toLowerCase() + if (!SUPPORTED_POSIX_SHELLS.has(shellName)) { + return null + } + if (accountLoginShell) { + return shell === accountLoginShell ? shell : null + } + return CONSERVATIVE_SYSTEM_SHELL_DIRS.has(path.posix.dirname(shell)) ? shell : null +} + +function getShellCommandMode(shell: string): '-lc' | '-ilc' { + const shellName = path.posix.basename(shell).toLowerCase() + // Why: bash/zsh/fish users commonly add package-manager bins from interactive + // startup files. POSIX sh/dash may not support interactive login flags. + return shellName === 'sh' || shellName === 'dash' ? '-lc' : '-ilc' +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` } diff --git a/src/relay/relay-command-env.test.ts b/src/relay/relay-command-env.test.ts index 3a53eae3574..89602d81a6c 100644 --- a/src/relay/relay-command-env.test.ts +++ b/src/relay/relay-command-env.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { homedir } from 'os' import { buildRelayCommandEnv } from './relay-command-env' +// homedir() is the fallback when the relay env carries no HOME; mock it so the +// fallback path is deterministic and the "no resolvable home" branch is reachable. +vi.mock('os', () => ({ homedir: vi.fn(() => '/home/fallback') })) + describe('buildRelayCommandEnv', () => { it('adds POSIX git locations when the relay starts with an empty PATH', () => { const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '' }, 'linux') @@ -19,4 +24,207 @@ describe('buildRelayCommandEnv', () => { expect.arrayContaining(['C:\\Tools', 'C:\\Program Files\\Git\\cmd']) ) }) + + it('adds per-user package-manager bins resolved from HOME on POSIX', () => { + const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '/usr/bin' }, 'linux') + + expect(env.PATH?.split(':')).toEqual( + expect.arrayContaining([ + '/home/me/.local/bin', + '/home/me/.npm-global/bin', + '/home/me/.cargo/bin', + '/home/me/.bun/bin', + '/home/me/go/bin', + '/home/me/.deno/bin', + '/home/me/.local/share/pnpm' + ]) + ) + }) + + it('honors a relocated npm global prefix via npm_config_prefix', () => { + const env = buildRelayCommandEnv( + { HOME: '/home/me', PATH: '', npm_config_prefix: '/opt/npm' }, + 'linux' + ) + + expect(env.PATH?.split(':')).toContain('/opt/npm/bin') + }) + + it('honors a relocated cargo home via CARGO_HOME', () => { + const env = buildRelayCommandEnv( + { HOME: '/home/me', PATH: '', CARGO_HOME: '/opt/cargo' }, + 'linux' + ) + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toContain('/opt/cargo/bin') + expect(segments).not.toContain('/home/me/.cargo/bin') + }) + + it('honors a relocated bun install via BUN_INSTALL', () => { + const env = buildRelayCommandEnv( + { HOME: '/home/me', PATH: '', BUN_INSTALL: '/opt/bun' }, + 'linux' + ) + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toContain('/opt/bun/bin') + expect(segments).not.toContain('/home/me/.bun/bin') + }) + + it('honors a relocated deno install via DENO_INSTALL', () => { + const env = buildRelayCommandEnv( + { HOME: '/home/me', PATH: '', DENO_INSTALL: '/opt/deno' }, + 'linux' + ) + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toContain('/opt/deno/bin') + expect(segments).not.toContain('/home/me/.deno/bin') + }) + + it('uses GOBIN directly for the go bin directory', () => { + const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '', GOBIN: '/opt/go/bin' }, 'linux') + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toContain('/opt/go/bin') + expect(segments).not.toContain('/home/me/go/bin') + }) + + it('honors GOPATH for the go bin directory when GOBIN is unset', () => { + const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '', GOPATH: '/opt/gopath' }, 'linux') + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toContain('/opt/gopath/bin') + expect(segments).not.toContain('/home/me/go/bin') + }) + + it('honors PNPM_HOME for the pnpm global bin directory', () => { + const env = buildRelayCommandEnv( + { HOME: '/home/me', PATH: '', PNPM_HOME: '/opt/pnpm' }, + 'linux' + ) + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toContain('/opt/pnpm') + expect(segments).not.toContain('/home/me/.local/share/pnpm') + }) + + it('honors XDG_DATA_HOME for pnpm when PNPM_HOME is unset', () => { + const env = buildRelayCommandEnv( + { HOME: '/home/me', PATH: '', XDG_DATA_HOME: '/opt/xdg' }, + 'linux' + ) + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toContain('/opt/xdg/pnpm') + expect(segments).not.toContain('/home/me/.local/share/pnpm') + }) + + it('adds the macOS pnpm home for Darwin relay envs', () => { + const env = buildRelayCommandEnv({ HOME: '/Users/me', PATH: '' }, 'darwin') + + expect(env.PATH?.split(':')).toContain('/Users/me/Library/pnpm') + }) + + it('does not leak POSIX user bins into a Windows relay env', () => { + const env = buildRelayCommandEnv({ Path: 'C:\\Tools', HOME: '/home/me' }, 'win32') + + expect(env.Path).not.toContain('/home/me/.local/bin') + expect(env.Path).not.toContain('.npm-global') + }) + + it('adds Windows user package-manager bins to a Windows relay env', () => { + const env = buildRelayCommandEnv( + { + Path: 'C:\\Tools', + APPDATA: 'C:\\Users\\me\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local', + USERPROFILE: 'C:\\Users\\me', + PNPM_HOME: 'C:\\Users\\me\\AppData\\Local\\pnpm-home' + }, + 'win32' + ) + const segments = env.Path?.split(';') ?? [] + + expect(segments).toEqual( + expect.arrayContaining([ + 'C:\\Users\\me\\AppData\\Roaming\\npm', + 'C:\\Users\\me\\AppData\\Local\\pnpm', + 'C:\\Users\\me\\.cargo\\bin', + 'C:\\Users\\me\\.bun\\bin', + 'C:\\Users\\me\\go\\bin', + 'C:\\Users\\me\\.deno\\bin', + 'C:\\Users\\me\\AppData\\Local\\pnpm-home' + ]) + ) + }) + + it('honors relocated Windows package-manager env vars', () => { + const env = buildRelayCommandEnv( + { + Path: 'C:\\Tools', + CARGO_HOME: 'D:\\cargo', + BUN_INSTALL: 'D:\\bun', + DENO_INSTALL: 'D:\\deno', + GOBIN: 'D:\\go\\bin', + PNPM_HOME: 'D:\\pnpm' + }, + 'win32' + ) + const segments = env.Path?.split(';') ?? [] + + expect(segments).toEqual( + expect.arrayContaining([ + 'D:\\cargo\\bin', + 'D:\\bun\\bin', + 'D:\\deno\\bin', + 'D:\\go\\bin', + 'D:\\pnpm' + ]) + ) + }) + + it('deduplicates a user bin already present in the inherited PATH', () => { + const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '/home/me/.local/bin' }, 'linux') + const segments = env.PATH?.split(':') ?? [] + + expect(segments.filter((s) => s === '/home/me/.local/bin')).toHaveLength(1) + }) + + it('falls back to homedir() for user bins when the relay env carries no HOME', () => { + const env = buildRelayCommandEnv({ PATH: '/usr/bin' }, 'linux') + + expect(env.PATH?.split(':')).toContain('/home/fallback/.local/bin') + expect(env.PATH).not.toContain('undefined') + }) + + it('adds only POSIX fallbacks when no home directory can be resolved', () => { + vi.mocked(homedir).mockReturnValueOnce('') + const env = buildRelayCommandEnv({ PATH: '' }, 'linux') + const segments = env.PATH?.split(':') ?? [] + + expect(segments).toEqual(expect.arrayContaining(['/usr/local/bin', '/usr/bin', '/bin'])) + expect(segments.some((s) => s.includes('.local/bin'))).toBe(false) + }) + + it('keeps inherited PATH entries ahead of the appended fallbacks', () => { + const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '/custom/bin' }, 'linux') + const segments = env.PATH?.split(':') ?? [] + + expect(segments.indexOf('/custom/bin')).toBeLessThan(segments.indexOf('/usr/bin')) + }) + + it('orders the static POSIX fallbacks ahead of the resolved user bins', () => { + const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '' }, 'linux') + const segments = env.PATH?.split(':') ?? [] + + expect(segments.indexOf('/usr/bin')).toBeLessThan(segments.indexOf('/home/me/.local/bin')) + }) + + it('treats an empty-string HOME as absent and falls back to homedir()', () => { + const env = buildRelayCommandEnv({ HOME: '', PATH: '/usr/bin' }, 'linux') + + expect(env.PATH?.split(':')).toContain('/home/fallback/.local/bin') + }) }) diff --git a/src/relay/relay-command-env.ts b/src/relay/relay-command-env.ts index 2939e660fe8..be8e030b2cb 100644 --- a/src/relay/relay-command-env.ts +++ b/src/relay/relay-command-env.ts @@ -1,3 +1,6 @@ +import { homedir } from 'os' +import { posix, win32 } from 'path' + const POSIX_RELAY_PATH_FALLBACKS = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin'] const WINDOWS_RELAY_PATH_FALLBACKS = [ 'C:\\Program Files\\Git\\cmd', @@ -6,6 +9,119 @@ const WINDOWS_RELAY_PATH_FALLBACKS = [ 'C:\\Windows' ] +// Why: the login-shell probe (`/bin/sh -lc`) sources ~/.profile but not the +// interactive ~/.bashrc, so per-user package-manager bins are invisible to +// detection even though the user can run those agents. Add them to PATH so the +// relay sees what interactive PTY sessions do; prefer each tool's documented +// relocation env var over the $HOME default so relocated installs stay covered. +function getPosixUserInstallBinFallbacks( + baseEnv: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): string[] { + const home = baseEnv.HOME || homedir() + const bins: string[] = [] + if (baseEnv.PNPM_HOME) { + bins.push(baseEnv.PNPM_HOME) + } + if (home) { + bins.push(posix.join(home, '.local', 'bin'), posix.join(home, '.npm-global', 'bin')) + if (platform === 'darwin') { + bins.push(posix.join(home, 'Library', 'pnpm')) + } + } + const cargoBin = baseEnv.CARGO_HOME + ? posix.join(baseEnv.CARGO_HOME, 'bin') + : home + ? posix.join(home, '.cargo', 'bin') + : null + if (cargoBin) { + bins.push(cargoBin) + } + const bunBin = baseEnv.BUN_INSTALL + ? posix.join(baseEnv.BUN_INSTALL, 'bin') + : home + ? posix.join(home, '.bun', 'bin') + : null + if (bunBin) { + bins.push(bunBin) + } + const denoBin = baseEnv.DENO_INSTALL + ? posix.join(baseEnv.DENO_INSTALL, 'bin') + : home + ? posix.join(home, '.deno', 'bin') + : null + if (denoBin) { + bins.push(denoBin) + } + // GOBIN is the bin dir itself; otherwise go installs into $GOPATH/bin (default ~/go/bin). + const goBin = baseEnv.GOBIN + ? baseEnv.GOBIN + : baseEnv.GOPATH + ? posix.join(baseEnv.GOPATH, 'bin') + : home + ? posix.join(home, 'go', 'bin') + : null + if (goBin) { + bins.push(goBin) + } + // pnpm/PNPM_HOME point at the global-bin dir directly; the default lives under + // XDG_DATA_HOME (default ~/.local/share). + const pnpmHome = baseEnv.PNPM_HOME + ? null + : baseEnv.XDG_DATA_HOME + ? posix.join(baseEnv.XDG_DATA_HOME, 'pnpm') + : home + ? posix.join(home, '.local', 'share', 'pnpm') + : null + if (pnpmHome) { + bins.push(pnpmHome) + } + const npmPrefix = baseEnv.npm_config_prefix + if (npmPrefix) { + bins.push(posix.join(npmPrefix, 'bin')) + } + return bins +} + +function getWindowsUserInstallBinFallbacks(baseEnv: NodeJS.ProcessEnv): string[] { + const bins = baseEnv.PNPM_HOME ? [baseEnv.PNPM_HOME] : [] + if (baseEnv.APPDATA) { + bins.push(win32.join(baseEnv.APPDATA, 'npm')) + } + if (baseEnv.LOCALAPPDATA) { + bins.push(win32.join(baseEnv.LOCALAPPDATA, 'pnpm')) + } + if (baseEnv.CARGO_HOME) { + bins.push(win32.join(baseEnv.CARGO_HOME, 'bin')) + } + if (baseEnv.BUN_INSTALL) { + bins.push(win32.join(baseEnv.BUN_INSTALL, 'bin')) + } + if (baseEnv.DENO_INSTALL) { + bins.push(win32.join(baseEnv.DENO_INSTALL, 'bin')) + } + if (baseEnv.GOBIN) { + bins.push(baseEnv.GOBIN) + } else if (baseEnv.GOPATH) { + bins.push(win32.join(baseEnv.GOPATH, 'bin')) + } + if (baseEnv.USERPROFILE) { + if (!baseEnv.CARGO_HOME) { + bins.push(win32.join(baseEnv.USERPROFILE, '.cargo', 'bin')) + } + if (!baseEnv.BUN_INSTALL) { + bins.push(win32.join(baseEnv.USERPROFILE, '.bun', 'bin')) + } + if (!baseEnv.GOBIN && !baseEnv.GOPATH) { + bins.push(win32.join(baseEnv.USERPROFILE, 'go', 'bin')) + } + if (!baseEnv.DENO_INSTALL) { + bins.push(win32.join(baseEnv.USERPROFILE, '.deno', 'bin')) + } + } + return bins +} + function getPathKey(env: NodeJS.ProcessEnv): 'PATH' | 'Path' { return env.Path !== undefined && env.PATH === undefined ? 'Path' : 'PATH' } @@ -14,8 +130,11 @@ function getPathDelimiter(platform: NodeJS.Platform): string { return platform === 'win32' ? ';' : ':' } -function getFallbackSegments(platform: NodeJS.Platform): string[] { - return platform === 'win32' ? WINDOWS_RELAY_PATH_FALLBACKS : POSIX_RELAY_PATH_FALLBACKS +function getFallbackSegments(platform: NodeJS.Platform, baseEnv: NodeJS.ProcessEnv): string[] { + if (platform === 'win32') { + return [...WINDOWS_RELAY_PATH_FALLBACKS, ...getWindowsUserInstallBinFallbacks(baseEnv)] + } + return [...POSIX_RELAY_PATH_FALLBACKS, ...getPosixUserInstallBinFallbacks(baseEnv, platform)] } export function buildRelayCommandEnv( @@ -26,7 +145,7 @@ export function buildRelayCommandEnv( const delimiter = getPathDelimiter(platform) const segments = new Set((baseEnv[key] ?? '').split(delimiter).filter(Boolean)) - for (const segment of getFallbackSegments(platform)) { + for (const segment of getFallbackSegments(platform, baseEnv)) { segments.add(segment) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index aaf638d38d7..4071cd92a67 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -62,7 +62,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + TOGGLE_FLOATING_TERMINAL_EVENT, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, isFloatingWorkspacePanelShortcut, @@ -389,8 +392,11 @@ function App(): React.JSX.Element { useShallow((s) => ({ toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, + fetchReposForAllHosts: s.fetchReposForAllHosts, fetchProjectGroups: s.fetchProjectGroups, + fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, fetchFolderWorkspaces: s.fetchFolderWorkspaces, + fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, fetchAllWorktrees: s.fetchAllWorktrees, fetchWorktreeLineage: s.fetchWorktreeLineage, fetchSettings: s.fetchSettings, @@ -841,9 +847,12 @@ function App(): React.JSX.Element { // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. await actions.fetchSettings() - await actions.fetchRepos() - await actions.fetchProjectGroups() - await actions.fetchFolderWorkspaces() + // Why: load local + every configured runtime environment (not just the + // active one) so a cold start that restored a remote workspace doesn't + // hide local repos. The sidebar "All hosts" scope then shows them all. + await actions.fetchReposForAllHosts() + await actions.fetchProjectGroupsForAllHosts() + await actions.fetchFolderWorkspacesForAllHosts() await actions.fetchAllWorktrees() await actions.fetchWorktreeLineage() const persistedUI = await window.api.ui.get() @@ -1390,6 +1399,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1404,6 +1414,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1421,6 +1432,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1516,6 +1528,22 @@ function App(): React.JSX.Element { return } + // Why: when the floating workspace is closed, its own keydown handler is + // unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord + // here by opening the panel with a one-shot intent so it mounts straight + // into the maximized state. While the panel is open, this is a no-op: the + // panel's handler owns the maximize/restore toggle. + if ( + !floatingTerminalOpen && + matchShortcut('floatingWorkspace.maximize') && + floatingTerminalEnabled + ) { + input.preventDefault() + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + return + } + // Why: keep this guard. TipTap's Cmd+B bold binding depends on the // window-level handler *not* toggling the sidebar when focus lives in an // editable surface. The main-process before-input-event already carves out diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index fa5802dbd57..0320b41b796 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -11,7 +11,7 @@ describe('renderer startup runtime routing', () => { expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeGreaterThanOrEqual(0) expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( - startupBlock.indexOf('await actions.fetchRepos()') + startupBlock.indexOf('await actions.fetchReposForAllHosts()') ) expect(startupBlock.indexOf('await actions.fetchSettings()')).toBeLessThan( startupBlock.indexOf('await actions.fetchAllWorktrees()') diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d73f553139b..867ed6e958f 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -922,6 +922,26 @@ /* ── Sidebar ─────────────────────────────────────────── */ +[data-file-explorer-row][data-selected='true'] { + /* Why: flat bg-accent ≈ panel background in light mode, so selection mixes + foreground into accent and adds an inset ring to stay legible in both themes. */ + background: color-mix(in srgb, var(--foreground) 8%, var(--accent)); + box-shadow: inset 0 0 0 1px var(--border); + color: var(--accent-foreground); +} + +[data-file-explorer-row][data-selected='true']:hover { + background: color-mix(in srgb, var(--foreground) 10%, var(--accent)); +} + +.dark [data-file-explorer-row][data-selected='true'] { + background: color-mix(in srgb, var(--accent) 70%, transparent); +} + +.dark [data-file-explorer-row][data-selected='true']:hover { + background: color-mix(in srgb, var(--accent) 80%, transparent); +} + .sidebar { width: 240px; flex-shrink: 0; diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 24ab4c3425b..df32f64ccae 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -557,6 +557,15 @@ height: 116px; } +.mobile-page-root .mp-qr-large { + padding: 10px; +} + +.mobile-page-root .mp-qr-large img { + width: 164px; + height: 164px; +} + .mobile-page-root .mp-qr-stack { display: flex; flex-direction: column; @@ -704,8 +713,9 @@ } .mobile-page-root .mp-network-select { - min-width: 220px; - max-width: 280px; + flex: 1 1 180px; + min-width: 0; + max-width: 240px; } .mobile-page-root .mp-network-refresh { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 53ffdffbc1d..e9495d4c30f 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -22,6 +22,7 @@ import { Files, GitMerge, GitPullRequest, + GitPullRequestDraft, List, LoaderCircle, Lock, @@ -85,6 +86,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' @@ -209,6 +211,15 @@ import { resolveTaskPageGitHubStatusStateDraft, updateTaskPageGitHubStatusLocalState } from '@/components/task-page-github-status-state' +import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge' +import { + getTaskPageGitHubPRIconTone, + isTaskPageGitHubDraftPR +} from '@/components/task-page-github-work-item-status' +import { + createTaskPageJiraLoadFailureState, + type TaskPageJiraLoadError +} from '@/components/task-page-jira-load-state' import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { buildJiraCreateTextAdf } from '@/components/jira-create-adf' @@ -815,6 +826,51 @@ function groupLinearIssues( return [...sections.values()] } +function TaskPageJiraErrorBanner({ + error, + open, + onOpenChange +}: { + error: TaskPageJiraLoadError + open: boolean + onOpenChange: (open: boolean) => void +}): React.JSX.Element { + return ( + +
+ +
+
{error.title}
+ {error.details ? ( + <> + + + + +
+ {error.details} +
+
+ + ) : null} +
+
+
+ ) +} + function getLinearIssueGridTemplate(visibleProperties: ReadonlySet): string { const columns = ['96px', 'minmax(240px,1.55fr)'] if (visibleProperties.has('labels')) { @@ -1069,11 +1125,7 @@ function GHStatusCell({ ) if (item.type !== 'issue' || !repo) { - return ( - - {translate('auto.components.TaskPage.606a85c774', 'Open')} - - ) + return } return ( @@ -4121,7 +4173,8 @@ export default function TaskPage(): React.JSX.Element { // Jira tab state const [jiraIssues, setJiraIssues] = useState([]) const [jiraLoading, setJiraLoading] = useState(false) - const [jiraError, setJiraError] = useState(null) + const [jiraError, setJiraError] = useState(null) + const [jiraErrorDetailsOpen, setJiraErrorDetailsOpen] = useState(false) const [jiraSearchInput, setJiraSearchInput] = useState('') const [appliedJiraSearch, setAppliedJiraSearch] = useState('') const [activeJiraPreset, setActiveJiraPreset] = useState('assigned') @@ -7231,6 +7284,7 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false setJiraLoading(true) setJiraError(null) + setJiraErrorDetailsOpen(false) const trimmed = appliedJiraSearch.trim() const request = @@ -7252,7 +7306,9 @@ export default function TaskPage(): React.JSX.Element { if (cancelled) { return } - setJiraError(err instanceof Error ? err.message : 'Failed to load Jira issues.') + const failureState = createTaskPageJiraLoadFailureState(err) + setJiraIssues(failureState.issues) + setJiraError(failureState.error) setJiraLoading(false) }) @@ -7706,91 +7762,84 @@ export default function TaskPage(): React.JSX.Element { })} ) : null} - {/* Why: the repo combobox filters Items mode by repo. In - Project mode the row set comes from the project's - view filter (server-side), so this control would be - inert — hide it to avoid suggesting it does - something. */} - {githubMode !== 'project' && ( - <> -
- { - const normalized = normalizeTaskRepoSelection(eligibleRepos, next) - setRepoSelection(normalized) - void updateSettings({ defaultRepoSelection: [...normalized] }).catch( - () => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) - ) - } - ) - }} - onSelectAll={() => { - const allIds = new Set(taskPickerRepos.map((r) => r.id)) - setRepoSelection(allIds) - void updateSettings({ defaultRepoSelection: null }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) + {/* Why: Project rows are now repo-scoped too, so the + selection must stay visible in both GitHub modes. */} +
+ { + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ defaultRepoSelection: [...normalized] }).catch( + () => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' ) - }) - }} - triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" - /> -
- - - - - - {selectedGitHubRepoExternalLink + ) + } + ) + }} + onSelectAll={() => { + const allIds = new Set(taskPickerRepos.map((r) => r.id)) + setRepoSelection(allIds) + void updateSettings({ defaultRepoSelection: null }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+ + + + + + {selectedGitHubRepoExternalLink + ? translate( + 'auto.components.TaskPage.8d1e17a3ef', + 'Open {{value0}} in GitHub', + { value0: selectedGitHubRepoExternalLink.label } + ) + : translate( + 'auto.components.TaskPage.bc46d8204e', + 'Select one project to open in GitHub' + )} + + ) : null} @@ -8579,7 +8628,7 @@ export default function TaskPage(): React.JSX.Element { ) ) : taskSource === 'github' && githubMode === 'project' ? (
- +
) : taskSource === 'github' ? (
@@ -8763,6 +8812,29 @@ export default function TaskPage(): React.JSX.Element { const attachedWorkspaceLabel = attachedWorkspace ? getGithubWorkItemWorkspaceAttachmentLabel(attachedWorkspace) : null + const githubTaskIdPill = ( + + {item.type === 'pr' ? ( + isTaskPageGitHubDraftPR(item) ? ( + + ) return ( // Why: the row is a clickable container rather than a // )} @@ -1601,7 +1629,7 @@ export default function CombinedDiffViewer({ toggleSection={toggleSection} openSection={openSection} openSectionTitle={ - isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' + isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' } setSectionHeights={setSectionHeights} setSections={setSections} diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index b45d224e334..ed813e0ecf3 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -199,7 +199,8 @@ export function EditorContent({ const isCombinedDiff = activeFile.mode === 'diff' && - (activeFile.diffSource === 'combined-uncommitted' || + (activeFile.diffSource === 'combined-all' || + activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index a790aa231b9..89761a4171d 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -72,6 +72,7 @@ import { openHttpLink } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' +import { prewarmMarkdownPreviewLocalImages } from './markdown-preview-local-images' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { statRuntimePath } from '@/runtime/runtime-file-client' import { useMountedRef } from '@/hooks/useMountedRef' @@ -545,6 +546,13 @@ export default function MarkdownPreview({ const renderedContent = usePreserveSectionDuringExternalEdit(content, bodyRef) + useEffect(() => { + const prewarm = prewarmMarkdownPreviewLocalImages(renderedContent, filePath, { + runtimeContext: imageRuntimeContext + }) + return prewarm.cancel + }, [renderedContent, filePath, imageRuntimeContext]) + const frontMatter = useMemo(() => extractFrontMatter(renderedContent), [renderedContent]) const tableOfContentsItems = useMemo( () => buildMarkdownTableOfContents(renderedContent), diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff-entries.test.ts index 2aa4935564c..859cd92d882 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.test.ts @@ -35,7 +35,7 @@ describe('getCombinedUncommittedEntries', () => { ]) }) - it('excludes untracked entries when no area filter is set', () => { + it('includes every area when no area filter is set', () => { const liveEntries: GitStatusEntry[] = [ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, @@ -44,7 +44,8 @@ describe('getCombinedUncommittedEntries', () => { expect(getCombinedUncommittedEntries(liveEntries, undefined)).toEqual([ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, - { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' } + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, + { path: 'src/untracked.ts', status: 'untracked', area: 'untracked' } ]) }) }) diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff-entries.ts index c2ca3526d8c..e27e5037644 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.ts @@ -15,10 +15,7 @@ export function getCombinedUncommittedEntries( if (entry.conflictStatus === 'unresolved') { return false } - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) } diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts index 5f92d27f6d9..cbffbbc51e3 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts +++ b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts @@ -2,7 +2,7 @@ import { basename } from '@/lib/path' import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -export type CombinedDiffFileTreeMode = 'uncommitted' | 'branch' | 'commit' +export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit' export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit' @@ -20,10 +20,10 @@ export function getCombinedDiffFileTreeSectionKey( mode: CombinedDiffFileTreeMode, entry: CombinedDiffFileTreeEntry ): string { - if (mode === 'uncommitted' && 'area' in entry) { + if ((mode === 'all' || mode === 'uncommitted') && 'area' in entry) { return `${entry.area}:${entry.path}` } - return `${mode === 'branch' ? 'combined-branch' : 'combined-commit'}:${entry.path}` + return `${mode === 'commit' ? 'combined-commit' : 'combined-branch'}:${entry.path}` } export function createCombinedDiffSectionIndexMap( diff --git a/src/renderer/src/components/editor/editor-header.test.ts b/src/renderer/src/components/editor/editor-header.test.ts index 7cb9e26deec..0939bc8dae0 100644 --- a/src/renderer/src/components/editor/editor-header.test.ts +++ b/src/renderer/src/components/editor/editor-header.test.ts @@ -98,7 +98,7 @@ describe('getEditorHeaderCopyState', () => { filePath: '/repo/worktree', relativePath: 'All Changes', mode: 'diff', - diffSource: 'combined-uncommitted' + diffSource: 'combined-all' }) ) ).toEqual({ diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index 9cfc39fe495..a3fbafbd6da 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -35,7 +35,8 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState const isCombinedDiff = file.mode === 'diff' && - (file.diffSource === 'combined-uncommitted' || + (file.diffSource === 'combined-all' || + file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-branch' || file.diffSource === 'combined-commit') @@ -66,6 +67,7 @@ export function getEditorHeaderOpenFileState( const isSingleDiff = file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 50e55931db3..c39f38abe36 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -42,9 +42,12 @@ export function getEditorDisplayLabel( } const source = file.diffSource - if (source === 'combined-uncommitted') { + if (source === 'combined-all') { return 'All Changes' } + if (source === 'combined-uncommitted') { + return file.combinedAreaFilter ? getBaseLabel(file, variant) : 'Uncommitted Changes' + } if (source === 'combined-branch') { return `Branch Changes (${file.branchCompare?.baseRef ?? 'base'})` } diff --git a/src/renderer/src/components/editor/editor-panel-diff-reload.ts b/src/renderer/src/components/editor/editor-panel-diff-reload.ts index ed33a5cbf31..4b58a471239 100644 --- a/src/renderer/src/components/editor/editor-panel-diff-reload.ts +++ b/src/renderer/src/components/editor/editor-panel-diff-reload.ts @@ -5,6 +5,7 @@ export function isReloadableSingleFileDiffTab(file: OpenFile): boolean { return ( file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 85463f36d64..be9738db6a4 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -40,12 +40,14 @@ export function getEditorPanelRenderModel({ const isSingleDiff = activeFile.mode === 'diff' && activeFile.diffSource !== undefined && + activeFile.diffSource !== 'combined-all' && activeFile.diffSource !== 'combined-uncommitted' && activeFile.diffSource !== 'combined-branch' && activeFile.diffSource !== 'combined-commit' const isCombinedDiff = activeFile.mode === 'diff' && - (activeFile.diffSource === 'combined-uncommitted' || + (activeFile.diffSource === 'combined-all' || + activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') const resolvedLanguage = diff --git a/src/renderer/src/components/editor/markdown-preview-controls.ts b/src/renderer/src/components/editor/markdown-preview-controls.ts index df0762ff483..fc6ef9f5c1b 100644 --- a/src/renderer/src/components/editor/markdown-preview-controls.ts +++ b/src/renderer/src/components/editor/markdown-preview-controls.ts @@ -44,6 +44,7 @@ export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly Ma } if ( target.mode === 'diff' && + target.diffSource !== 'combined-all' && target.diffSource !== 'combined-uncommitted' && target.diffSource !== 'combined-branch' && target.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts new file mode 100644 index 00000000000..f7d60703fb3 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { + MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY, + MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT, + extractMarkdownPreviewLocalImageCandidates, + prewarmMarkdownPreviewLocalImages, + type MarkdownPreviewLocalImageCandidate +} from './markdown-preview-local-images' + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function markdownImages(count: number): string { + return Array.from({ length: count }, (_, index) => `![${index}](images/${index}.png)`).join('\n') +} + +describe('extractMarkdownPreviewLocalImageCandidates', () => { + it('extracts direct, reference-style, and GFM table images in document order', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + [ + '![Direct](./direct.png)', + '![Reference][logo]', + '', + '| Asset |', + '| --- |', + '| ![Table](table.png) |', + '', + '[logo]: ../assets/logo.png' + ].join('\n'), + '/repo/docs/readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual([ + '/repo/docs/direct.png', + '/repo/assets/logo.png', + '/repo/docs/table.png' + ]) + }) + + it('resolves Windows drive-letter and percent-escaped paths through image path helpers', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + ['![Drive]()', '![Escaped](./space%20name.png)'].join('\n'), + 'C:\\repo\\docs\\readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual([ + 'C:/repo/assets/diagram.png', + 'C:/repo/docs/space name.png' + ]) + }) + + it('resolves relative image paths from UNC markdown files through image path helpers', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + '![Unc](./unc.png)', + '\\\\server\\share\\repo\\docs\\readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual([ + '//server/share/repo/docs/unc.png' + ]) + }) + + it('skips external, data, blob, raw HTML, and unsupported-scheme images', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + [ + '![Http](https://example.com/image.png)', + '![Data](data:image/png;base64,abc)', + '![Blob](blob:abc)', + '![Mail](mailto:image@example.com)', + '', + '![Local](./local.png)' + ].join('\n'), + '/repo/docs/readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual(['/repo/docs/local.png']) + }) + + it('dedupes by cache key after dropping query strings and fragments', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + ['![One](./logo.png?v=1#one)', '![Two](./logo.png?v=2#two)'].join('\n'), + '/repo/docs/readme.md' + ) + + expect(candidates).toHaveLength(1) + expect(candidates[0]?.absolutePath).toBe('/repo/docs/logo.png') + }) + + it('caps candidates below the blob cache size', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + markdownImages(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT + 10), + '/repo/docs/readme.md' + ) + + expect(candidates).toHaveLength(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) + expect(candidates).toHaveLength(64) + }) + + it('uses runtime owner data when deduping cache keys', () => { + const firstOwner = extractMarkdownPreviewLocalImageCandidates( + '![Logo](./logo.png)', + '/repo/docs/readme.md', + { + runtimeContext: { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + } + ) + const secondOwner = extractMarkdownPreviewLocalImageCandidates( + '![Logo](./logo.png)', + '/repo/docs/readme.md', + { + runtimeContext: { + settings: { activeRuntimeEnvironmentId: 'env-2' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + } + ) + + expect(firstOwner[0]?.cacheKey).not.toBe(secondOwner[0]?.cacheKey) + }) +}) + +describe('prewarmMarkdownPreviewLocalImages', () => { + it('prewarms capped, deduped candidates through the shared loader boundary', async () => { + const loaded: MarkdownPreviewLocalImageCandidate[] = [] + const prewarm = prewarmMarkdownPreviewLocalImages( + ['![One](./one.png)', '![Duplicate](./one.png?v=1)', '![Two](./two.png)'].join('\n'), + '/repo/docs/readme.md', + { + loadImage: (candidate) => { + loaded.push(candidate) + return Promise.resolve(null) + } + } + ) + + await prewarm.done + + expect(loaded.map((candidate) => candidate.absolutePath)).toEqual([ + '/repo/docs/one.png', + '/repo/docs/two.png' + ]) + }) + + it('does not enqueue more than the candidate cap', async () => { + const loaded: MarkdownPreviewLocalImageCandidate[] = [] + const prewarm = prewarmMarkdownPreviewLocalImages( + markdownImages(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT + 5), + '/repo/docs/readme.md', + { + concurrency: MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT, + loadImage: (candidate) => { + loaded.push(candidate) + return Promise.resolve(null) + } + } + ) + + await prewarm.done + + expect(loaded).toHaveLength(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) + }) + + it('never starts more reads than the concurrency limit at once', async () => { + const releaseNext: (() => void)[] = [] + const started: string[] = [] + let active = 0 + let maxActive = 0 + + const prewarm = prewarmMarkdownPreviewLocalImages(markdownImages(6), '/repo/docs/readme.md', { + concurrency: 2, + loadImage: (candidate) => { + started.push(candidate.absolutePath) + active += 1 + maxActive = Math.max(maxActive, active) + return new Promise((resolve) => { + releaseNext.push(() => { + active -= 1 + resolve(null) + }) + }) + } + }) + + expect(started).toHaveLength(2) + releaseNext.shift()?.() + await flushPromises() + expect(started).toHaveLength(3) + + while (releaseNext.length > 0) { + releaseNext.shift()?.() + await flushPromises() + } + await prewarm.done + + expect(maxActive).toBe(2) + expect(started).toHaveLength(6) + }) + + it('uses the documented default concurrency', async () => { + const pending: (() => void)[] = [] + const started: string[] = [] + + const prewarm = prewarmMarkdownPreviewLocalImages(markdownImages(10), '/repo/docs/readme.md', { + loadImage: (candidate) => { + started.push(candidate.absolutePath) + return new Promise((resolve) => { + pending.push(() => resolve(null)) + }) + } + }) + + expect(started).toHaveLength(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY) + prewarm.cancel() + for (const release of pending) { + release() + } + await prewarm.done + }) + + it('stops scheduling not-yet-started work after cancellation', async () => { + const pending: (() => void)[] = [] + const started: string[] = [] + const prewarm = prewarmMarkdownPreviewLocalImages(markdownImages(5), '/repo/docs/readme.md', { + concurrency: 2, + loadImage: (candidate) => { + started.push(candidate.absolutePath) + return new Promise((resolve) => { + pending.push(() => resolve(null)) + }) + } + }) + + expect(started).toHaveLength(2) + prewarm.cancel() + for (const release of pending) { + release() + } + await prewarm.done + + expect(started).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.ts b/src/renderer/src/components/editor/markdown-preview-local-images.ts new file mode 100644 index 00000000000..57d37f09d9e --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-local-images.ts @@ -0,0 +1,196 @@ +import remarkFrontmatter from 'remark-frontmatter' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' +import { resolveImageAbsolutePath } from './markdown-preview-links' +import { getLocalImageCacheKey, loadLocalImageAbsolutePath } from './useLocalImageSrc' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' + +export const MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT = 64 +export const MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY = 4 + +type MarkdownImageRuntimeContext = Omit & { + connectionId?: string | null +} + +export type MarkdownPreviewLocalImageCandidate = { + absolutePath: string + cacheKey: string + rawSrc: string +} + +type MarkdownImageAstNode = { + children?: MarkdownImageAstNode[] + identifier?: string + label?: string + type?: string + url?: string +} + +type ExtractLocalImageCandidatesOptions = { + connectionId?: string | null + limit?: number + runtimeContext?: MarkdownImageRuntimeContext +} + +type PrewarmMarkdownPreviewLocalImagesOptions = ExtractLocalImageCandidatesOptions & { + concurrency?: number + loadImage?: (candidate: MarkdownPreviewLocalImageCandidate) => Promise +} + +export type MarkdownPreviewLocalImagePrewarm = { + cancel: () => void + done: Promise +} + +function normalizeReferenceIdentifier(identifier: string): string { + return identifier.trim().replace(/\s+/g, ' ').toLowerCase() +} + +function collectImageDefinitions( + node: MarkdownImageAstNode, + definitions: Map +): void { + if ( + node.type === 'definition' && + typeof node.identifier === 'string' && + typeof node.url === 'string' + ) { + definitions.set(normalizeReferenceIdentifier(node.identifier), node.url) + } + for (const child of node.children ?? []) { + collectImageDefinitions(child, definitions) + } +} + +export function extractMarkdownPreviewLocalImageCandidates( + markdown: string, + filePath: string, + options: ExtractLocalImageCandidatesOptions = {} +): MarkdownPreviewLocalImageCandidate[] { + const limit = Math.max(0, options.limit ?? MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) + if (limit === 0) { + return [] + } + + // Why: parser extraction matches rendered Markdown image/reference/table + // nodes without scanning raw text or accidentally prewarming HTML tags. + const tree = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkFrontmatter, ['yaml', 'toml']) + .parse(markdown) as MarkdownImageAstNode + const definitions = new Map() + const candidates: MarkdownPreviewLocalImageCandidate[] = [] + const seenCacheKeys = new Set() + + collectImageDefinitions(tree, definitions) + + function appendRawSrc(rawSrc: string): void { + if (candidates.length >= limit) { + return + } + const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) + if (!absolutePath) { + return + } + const cacheKey = getLocalImageCacheKey( + absolutePath, + options.connectionId, + options.runtimeContext + ) + if (seenCacheKeys.has(cacheKey)) { + return + } + seenCacheKeys.add(cacheKey) + candidates.push({ absolutePath, cacheKey, rawSrc }) + } + + function visit(node: MarkdownImageAstNode): void { + if (candidates.length >= limit) { + return + } + if (node.type === 'image' && typeof node.url === 'string') { + appendRawSrc(node.url) + } else if (node.type === 'imageReference' && typeof node.identifier === 'string') { + const rawSrc = definitions.get(normalizeReferenceIdentifier(node.identifier)) + if (rawSrc) { + appendRawSrc(rawSrc) + } + } + for (const child of node.children ?? []) { + visit(child) + } + } + + visit(tree) + return candidates +} + +export function prewarmMarkdownPreviewLocalImages( + markdown: string, + filePath: string, + options: PrewarmMarkdownPreviewLocalImagesOptions = {} +): MarkdownPreviewLocalImagePrewarm { + const candidates = extractMarkdownPreviewLocalImageCandidates(markdown, filePath, options) + const concurrency = Math.max( + 1, + options.concurrency ?? MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY + ) + const loadImage = + options.loadImage ?? + ((candidate: MarkdownPreviewLocalImageCandidate) => + loadLocalImageAbsolutePath( + candidate.absolutePath, + options.connectionId, + options.runtimeContext + )) + let cancelled = false + let nextIndex = 0 + let activeCount = 0 + let resolveDone!: () => void + const done = new Promise((resolve) => { + resolveDone = resolve + }) + + const settleIfFinished = (): void => { + if ((cancelled || nextIndex >= candidates.length) && activeCount === 0) { + resolveDone() + } + } + + const scheduleNext = (): void => { + while (!cancelled && activeCount < concurrency && nextIndex < candidates.length) { + const candidate = candidates[nextIndex] + nextIndex += 1 + if (!candidate) { + continue + } + activeCount += 1 + let loadPromise: Promise + try { + loadPromise = loadImage(candidate) + } catch { + loadPromise = Promise.resolve() + } + loadPromise + .catch(() => undefined) + .finally(() => { + activeCount -= 1 + scheduleNext() + settleIfFinished() + }) + } + settleIfFinished() + } + + scheduleNext() + + return { + cancel: () => { + cancelled = true + settleIfFinished() + }, + done + } +} diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index cd0ade29020..551902aee27 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -152,6 +152,12 @@ describe('rich markdown round trip', () => { expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a') }) + it('preserves encoded local image paths with screenshot filenames', () => { + expect(roundTripMarkdown('![](Screenshot%202026-06-22%20at%203.37.19%20PM%20copy.png)\n')).toBe( + '![](Screenshot%202026-06-22%20at%203.37.19%20PM%20copy.png)' + ) + }) + it('does not surface Linear issue reference definitions as description text', () => { const input = [ '- [x] [H-279]', diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts index 060a69d5d15..0379a925563 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts @@ -93,6 +93,33 @@ describe('insertRichMarkdownImageFromPath', () => { ) }) + it('inserts markdown-safe image src values for screenshot filenames with spaces', async () => { + vi.mocked(importExternalPathsToRuntime).mockResolvedValue({ + results: [ + { + status: 'imported', + destPath: '/repo/Screenshot 2026-06-22 at 3.37.19 PM copy.png' + } + ] + } as never) + const { editor, insertContentAt } = editorWithRunResult(true) + + await insertRichMarkdownImageFromPath({ + editor: editor as never, + filePath: '/repo/note.md', + sourcePath: '/tmp/image.png', + worktreeId: 'wt-1', + insertPos: 4 + }) + + expect(insertContentAt).toHaveBeenCalledWith(4, { + type: 'image', + attrs: { + src: 'Screenshot%202026-06-22%20at%203.37.19%20PM%20copy.png' + } + }) + }) + it('skips editor mutation when the caller rejects the stale target after import', async () => { const { editor, chain } = editorWithRunResult(true) diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.ts index 89a48e07b27..216434bdeab 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.ts @@ -66,10 +66,11 @@ export async function insertRichMarkdownImageFromPath({ return } + const imageSrc = encodeMarkdownImageBasename(imported.destPath) const inserted = editor .chain() .focus() - .insertContentAt(insertPos, { type: 'image', attrs: { src: basename(imported.destPath) } }) + .insertContentAt(insertPos, { type: 'image', attrs: { src: imageSrc } }) .run() if (!inserted) { toast.error( @@ -81,6 +82,12 @@ export async function insertRichMarkdownImageFromPath({ } } +function encodeMarkdownImageBasename(destPath: string): string { + // Why: unescaped spaces and delimiters in markdown image destinations make + // screenshot filenames render as literal text or broken partial paths. + return encodeURIComponent(basename(destPath)) +} + function getWorktreePath(worktreeId: string | null): string | null { if (!worktreeId) { return null diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index a922cd5e93f..98907f2709a 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -1,8 +1,73 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getLocalImageCacheKey, loadLocalImageSrc } from './useLocalImageSrc' +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getLocalImageCacheKey, + invalidateLocalImageSrcCacheForTests, + loadLocalImageSrc, + resetLocalImageSrcStateForTests, + useLocalImageSrc +} from './useLocalImageSrc' + +type PreviewResult = { + content: string + isBinary: boolean + mimeType?: string +} + +function deferred(): { + promise: Promise + reject: (error: unknown) => void + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } +} + +function binaryPreview(content = 'AA=='): PreviewResult { + return { content, isBinary: true, mimeType: 'image/png' } +} + +function setReadFile(readFile: ReturnType): void { + globalThis.window.api = { + fs: { readFile } + } as unknown as Window['api'] +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function HookProbe({ + filePath, + onRender, + src +}: { + filePath: string + onRender: (displaySrc: string | undefined) => void + src: string +}): null { + onRender(useLocalImageSrc(src, filePath)) + return null +} + +beforeEach(() => { + resetLocalImageSrcStateForTests() + vi.spyOn(URL, 'createObjectURL').mockReset() + vi.spyOn(URL, 'revokeObjectURL').mockReset() +}) afterEach(() => { - vi.unstubAllGlobals() + resetLocalImageSrcStateForTests() + vi.restoreAllMocks() }) describe('getLocalImageCacheKey', () => { @@ -26,6 +91,42 @@ describe('getLocalImageCacheKey', () => { expect(localKey).not.toBe(remoteKey) expect(remoteKey).not.toBe(otherRemoteKey) }) +}) + +describe('loadLocalImageSrc', () => { + it('shares one pending read and one blob URL for duplicate local image loads', async () => { + const read = deferred() + const readFile = vi.fn().mockReturnValue(read.promise) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-image') + setReadFile(readFile) + + const first = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + const second = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + + expect(readFile).toHaveBeenCalledTimes(1) + read.resolve(binaryPreview()) + + await expect(Promise.all([first, second])).resolves.toEqual([ + 'blob:local-image', + 'blob:local-image' + ]) + expect(URL.createObjectURL).toHaveBeenCalledTimes(1) + }) + + it('clears failed in-flight reads so a later retry can succeed', async () => { + const readFile = vi + .fn() + .mockRejectedValueOnce(new Error('denied')) + .mockResolvedValueOnce(binaryPreview()) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:retry') + setReadFile(readFile) + + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBeNull() + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( + 'blob:retry' + ) + expect(readFile).toHaveBeenCalledTimes(2) + }) it('does not fall back to raw local src when IPC returns non-binary content', async () => { const readFile = vi.fn().mockResolvedValue({ @@ -33,11 +134,7 @@ describe('getLocalImageCacheKey', () => { content: '', mimeType: 'image/svg+xml' }) - vi.stubGlobal('window', { - api: { - fs: { readFile } - } - }) + setReadFile(readFile) await expect(loadLocalImageSrc('diagram.svg', '/repo/docs/readme.md')).resolves.toBeNull() expect(readFile).toHaveBeenCalledWith({ @@ -47,40 +144,112 @@ describe('getLocalImageCacheKey', () => { }) it('does not fall back to raw local src when IPC rejects the read', async () => { - vi.stubGlobal('window', { - api: { - fs: { readFile: vi.fn().mockRejectedValue(new Error('denied')) } - } - }) + setReadFile(vi.fn().mockRejectedValue(new Error('denied'))) await expect( loadLocalImageSrc('file:///repo/docs/diagram.png', '/repo/docs/readme.md') ).resolves.toBeNull() }) - it('revokes a blob URL that is overwritten by a concurrent load for the same image', async () => { - const readFile = vi.fn().mockResolvedValue({ - isBinary: true, - content: 'AA==', - mimeType: 'image/png' - }) + it('suppresses a stale pending completion after cache invalidation', async () => { + const firstRead = deferred() + const readFile = vi + .fn() + .mockReturnValueOnce(firstRead.promise) + .mockResolvedValueOnce(binaryPreview()) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fresh') + setReadFile(readFile) + + const staleLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + invalidateLocalImageSrcCacheForTests() + firstRead.resolve(binaryPreview()) + + await expect(staleLoad).resolves.toBeNull() + expect(URL.createObjectURL).not.toHaveBeenCalled() + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( + 'blob:fresh' + ) + expect(readFile).toHaveBeenCalledTimes(2) + }) + + it('does not let an older invalidated read overwrite a newer successful read', async () => { + const firstRead = deferred() + const secondRead = deferred() + const readFile = vi + .fn() + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(secondRead.promise) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:newer') + setReadFile(readFile) + + const staleLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + invalidateLocalImageSrcCacheForTests() + const newerLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + + secondRead.resolve(binaryPreview('AQ==')) + await expect(newerLoad).resolves.toBe('blob:newer') + firstRead.resolve(binaryPreview('Ag==')) + await expect(staleLoad).resolves.toBeNull() + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( + 'blob:newer' + ) + expect(readFile).toHaveBeenCalledTimes(2) + expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:newer') + }) + + it('keeps runtime owners in separate image cache entries', async () => { + const readFile = vi.fn().mockResolvedValue(binaryPreview()) vi.spyOn(URL, 'createObjectURL') - .mockReturnValueOnce('blob:local-image-first') - .mockReturnValueOnce('blob:local-image-second') - const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - vi.stubGlobal('window', { - api: { - fs: { readFile } - } + .mockReturnValueOnce('blob:runtime-one') + .mockReturnValueOnce('blob:runtime-two') + setReadFile(readFile) + + await expect( + loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + ).resolves.toBe('blob:runtime-one') + await expect( + loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-2', + worktreePath: '/repo', + connectionId: 'ssh-2' + }) + ).resolves.toBe('blob:runtime-two') + expect(readFile).toHaveBeenCalledTimes(2) + }) + + it('does not update mounted hook state after unmount', async () => { + const read = deferred() + const readFile = vi.fn().mockReturnValue(read.promise) + const renders: (string | undefined)[] = [] + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:unmounted') + setReadFile(readFile) + + const container = document.createElement('div') + const root: Root = createRoot(container) + await act(async () => { + root.render( + createElement(HookProbe, { + filePath: '/repo/docs/readme.md', + onRender: (displaySrc) => renders.push(displaySrc), + src: 'diagram.png' + }) + ) + }) + await act(async () => { + root.unmount() }) - const first = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') - const second = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + read.resolve(binaryPreview()) + await act(async () => { + await flushPromises() + }) - await expect(Promise.all([first, second])).resolves.toEqual([ - 'blob:local-image-first', - 'blob:local-image-second' - ]) - expect(revokeObjectURL).toHaveBeenCalledWith('blob:local-image-first') + expect(renders).toEqual([undefined]) }) }) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 9d55bb7ddae..d99c55913ff 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -10,6 +10,7 @@ import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' const BLOB_URL_CACHE_MAX_SIZE = 100 const blobUrlCache = new Map() +const inFlightBlobUrlLoads = new Map>() export function getLocalImageCacheKey( absolutePath: string, @@ -35,8 +36,8 @@ function cacheBlobUrl(key: string, url: string): void { if (previousUrl !== undefined) { blobUrlCache.delete(key) if (previousUrl !== url) { - // Why: concurrent loads for the same image key can both create blob URLs; - // overwriting the cache must release the superseded Blob. + // Why: cache replacements must release the superseded Blob even when + // they come from rare stale state or future loader changes. URL.revokeObjectURL(previousUrl) } } @@ -93,6 +94,7 @@ function scheduleBlobUrlRevocation(urls: string[]): void { function invalidateImageCache(): void { const staleUrls = Array.from(blobUrlCache.values()) blobUrlCache.clear() + inFlightBlobUrlLoads.clear() cacheGeneration += 1 for (const listener of cacheListeners) { listener() @@ -119,6 +121,7 @@ function disposeImageCacheModuleState(): void { URL.revokeObjectURL(url) } blobUrlCache.clear() + inFlightBlobUrlLoads.clear() cacheListeners.clear() } @@ -211,20 +214,13 @@ export function useLocalImageSrc( } let cancelled = false - readImagePreview(absolutePath, connectionId, runtimeContext) - .then((result) => { + const effectGeneration = generation + loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext) + .then((url) => { if (cancelled) { return } - if (result.isBinary && result.content) { - const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(cacheKey, url) - setDisplaySrc(url) - } else { - // Why: local image paths must stay behind IPC/runtime authorization; - // handing raw file: or relative paths back to Chromium can escape it. - setDisplaySrc(undefined) - } + setDisplaySrc(cacheGeneration === effectGeneration && url ? url : undefined) }) .catch(() => { if (!cancelled) { @@ -271,21 +267,69 @@ export async function loadLocalImageSrc( return cached } - try { - const result = await readImagePreview(absolutePath, connectionId, runtimeContext) - if (result.isBinary && result.content) { - const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(cacheKey, url) - return url - } - // Why: local image paths must stay behind IPC/runtime authorization; - // callers should render a missing image instead of falling back to raw src. - return null - } catch { - // Fall through + return loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext) +} + +export function loadLocalImageAbsolutePath( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +): Promise { + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cached = blobUrlCache.get(cacheKey) + if (cached) { + return Promise.resolve(cached) } - return null + const inFlight = inFlightBlobUrlLoads.get(cacheKey) + if (inFlight) { + return inFlight + } + + const readGeneration = cacheGeneration + const loadPromise = readImagePreview(absolutePath, connectionId, runtimeContext) + .then((result) => { + if (!result.isBinary || !result.content || cacheGeneration !== readGeneration) { + // Why: local image paths must stay behind IPC/runtime authorization; + // handing raw file: or relative paths back to Chromium can escape it. + return null + } + const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') + if (cacheGeneration !== readGeneration) { + URL.revokeObjectURL(url) + return null + } + cacheBlobUrl(cacheKey, url) + return url + }) + .catch(() => null) + .finally(() => { + if (inFlightBlobUrlLoads.get(cacheKey) === loadPromise) { + inFlightBlobUrlLoads.delete(cacheKey) + } + }) + inFlightBlobUrlLoads.set(cacheKey, loadPromise) + return loadPromise +} + +export function resetLocalImageSrcStateForTests(): void { + if (pendingBlobUrlRevocationTimer !== null) { + clearTimeout(pendingBlobUrlRevocationTimer) + pendingBlobUrlRevocationTimer = null + } + revokePendingBlobUrls() + for (const url of blobUrlCache.values()) { + URL.revokeObjectURL(url) + } + blobUrlCache.clear() + inFlightBlobUrlLoads.clear() + cacheGeneration = 0 + pendingBlobUrlRevocations.clear() + cacheListeners.clear() +} + +export function invalidateLocalImageSrcCacheForTests(): void { + invalidateImageCache() } function readImagePreview( @@ -293,17 +337,21 @@ function readImagePreview( connectionId?: string | null, runtimeContext?: Omit & { connectionId?: string | null } ) { - if (!runtimeContext) { - return window.api.fs.readFile({ - filePath: absolutePath, - connectionId: connectionId ?? undefined - }) + try { + if (!runtimeContext) { + return window.api.fs.readFile({ + filePath: absolutePath, + connectionId: connectionId ?? undefined + }) + } + return readRuntimeFilePreview( + { + ...runtimeContext, + connectionId: runtimeContext.connectionId ?? connectionId ?? undefined + }, + absolutePath + ) + } catch (error) { + return Promise.reject(error) } - return readRuntimeFilePreview( - { - ...runtimeContext, - connectionId: runtimeContext.connectionId ?? connectionId ?? undefined - }, - absolutePath - ) } diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx index ed07ec80440..b224e19ab1c 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx @@ -47,7 +47,10 @@ function EmulatorPaneContent({ tab, worktreeId, isActive = true }: EmulatorPaneP }) return ( -
+
+

{translate( diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 29ffe3ceb4d..c571d145808 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -3,7 +3,7 @@ * asserted without mounting the full Electron renderer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { KeybindingOverrides } from '../../../../shared/keybindings' +import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' import type { OpenFile } from '@/store/slices/editor' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' @@ -14,6 +14,10 @@ import { getMaximizedFloatingTerminalBounds, type FloatingTerminalPanelBounds } from './floating-terminal-panel-bounds' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' type EffectCallback = () => void | (() => void) @@ -32,6 +36,7 @@ type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record + renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -52,10 +57,12 @@ type FloatingPanelStoreState = { closeTab: (tabId: string) => void closeBrowserTab: (tabId: string) => void closeFile: (fileId: string) => void + closeUnifiedTab: (tabId: string) => Tab | null markFileDirty: (fileId: string, dirty: boolean) => void activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void + setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void @@ -64,7 +71,11 @@ type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record - settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string } + settings: { + activeRuntimeEnvironmentId?: string | null + floatingTerminalCwd?: string + terminalShortcutPolicy?: TerminalShortcutPolicy + } } const hookRuntime = vi.hoisted(() => ({ @@ -85,6 +96,7 @@ const mocks = vi.hoisted(() => ({ closeWebRuntimeSessionTab: vi.fn(), closeFile: vi.fn(), closeTab: vi.fn(), + closeUnifiedTab: vi.fn(), createBrowserTab: vi.fn(), createTab: vi.fn(), createWebRuntimeSessionBrowserTab: vi.fn(), @@ -100,6 +112,7 @@ const mocks = vi.hoisted(() => ({ pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), setActiveTab: vi.fn(), + setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -177,6 +190,12 @@ vi.mock('@/components/browser-pane/BrowserPane', () => ({ } })) +vi.mock('@/components/emulator-pane/EmulatorPane', () => ({ + default: function EmulatorPane() { + return null + } +})) + vi.mock('@/components/editor/EditorPanel', () => ({ default: function EditorPanel() { return null @@ -406,6 +425,38 @@ function setFloatingEditorTabs(files: OpenFile[]): void { state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } } +function setFloatingSimulatorTab(): Tab { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const tab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + state.unifiedTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab] } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: tab.id, + tabOrder: [tab.id], + recentTabIds: [tab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab.id] } + return tab +} + function resetStore(tabs: TerminalTab[] = []): void { storeBox.state = { tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs }, @@ -417,9 +468,11 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, + renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, + closeUnifiedTab: mocks.closeUnifiedTab, createTab: mocks.createTab, createBrowserTab: mocks.createBrowserTab, closeTab: mocks.closeTab, @@ -429,6 +482,7 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, + setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -552,10 +606,12 @@ function getPanelClassName(element: unknown): string { } function getMockedLocalStorage(): { + clear: ReturnType getItem: ReturnType setItem: ReturnType } { return window.localStorage as unknown as { + clear: ReturnType getItem: ReturnType setItem: ReturnType } @@ -596,6 +652,79 @@ function makeMacShortcutKeyEvent({ } } +function bindFocusedFloatingPanelKeydown(element: unknown): { + keydownListener: (event: unknown) => void + panelElement: { + contains: ReturnType + focus: ReturnType + closest: ReturnType + } +} { + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { + contains: vi.fn().mockReturnValue(true), + focus: vi.fn(), + closest: vi.fn() + } + panelElement.closest.mockImplementation((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: panelElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + return { keydownListener, panelElement } +} + +function makeFocusedPanelKeyEvent({ + altKey = false, + code, + ctrlKey = false, + key, + metaKey = false, + preventDefault = vi.fn(), + shiftKey = false, + stopImmediatePropagation = vi.fn(), + stopPropagation = vi.fn(), + target +}: { + altKey?: boolean + code?: string + ctrlKey?: boolean + key: string + metaKey?: boolean + preventDefault?: () => void + shiftKey?: boolean + stopImmediatePropagation?: () => void + stopPropagation?: () => void + target: unknown +}): unknown { + return { + altKey, + code: code ?? (key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key), + ctrlKey, + defaultPrevented: false, + key, + metaKey, + preventDefault, + repeat: false, + shiftKey, + stopImmediatePropagation, + stopPropagation, + target + } +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(() => { vi.clearAllMocks() @@ -605,6 +734,9 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.values = [] saveDialogBox.fileId = null resetStore() + // Why: the open-maximized intent is a module singleton; drain any leftover + // from a prior test so it cannot bleed into an unrelated render. + consumeFloatingTerminalOpenMaximizedIntent() mocks.createTab.mockReturnValue(makeTab({ id: 'created-tab' })) mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(false) mocks.createWebRuntimeSessionTerminal.mockResolvedValue(false) @@ -614,6 +746,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { + clear: vi.fn(), getItem: vi.fn(() => null), removeItem: vi.fn(), setItem: vi.fn() @@ -1358,6 +1491,375 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-2') }) + it('routes focused floating tab rename shortcuts to the active floating tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() + }) + + it('routes focused floating tab index shortcuts to the matching visible tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' }), makeTab({ id: 'tab-3' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit3', + ctrlKey: true, + key: '3', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('tab-3') + expect(mocks.setActiveTab).toHaveBeenCalledWith('tab-3') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + }) + + it('routes focused floating tab index shortcuts across mixed visible tab types', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const terminalTab = makeTab({ id: 'terminal-tab' }) + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + const browserTab: BrowserTab = { + id: 'browser-tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: '', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 2 + } + const browserUnifiedTab: Tab = { + id: 'browser-unified-tab', + entityId: browserTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 2 + } + const terminalUnifiedTab: Tab = { + id: terminalTab.id, + entityId: terminalTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + label: terminalTab.title, + customLabel: terminalTab.customTitle, + color: terminalTab.color, + sortOrder: 0, + createdAt: terminalTab.createdAt + } + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab] } + state.browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] } + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [terminalUnifiedTab, simulatorTab, browserUnifiedTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: terminalUnifiedTab.id, + tabOrder: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id], + recentTabIds: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + terminalUnifiedTab.id, + simulatorTab.id, + browserUnifiedTab.id + ] + } + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('simulator-tab') + }) + + it('ignores focused floating tab index shortcuts past the visible tab count', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit5', + ctrlKey: true, + key: '5', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(stopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + expect(mocks.setActiveTab).not.toHaveBeenCalled() + }) + + it('ignores focused floating tab rename shortcuts when no tab is active', async () => { + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + }) + + it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + ;(storeBox.state as FloatingPanelStoreState).settings = { + ...(storeBox.state as FloatingPanelStoreState).settings, + terminalShortcutPolicy: 'terminal-first' + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + const ctrlPreventDefault = vi.fn() + const ctrlStopPropagation = vi.fn() + const ctrlStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault: ctrlPreventDefault, + stopImmediatePropagation: ctrlStopImmediatePropagation, + stopPropagation: ctrlStopPropagation, + target + }) + ) + + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const altPreventDefault = vi.fn() + const altStopPropagation = vi.fn() + const altStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + code: 'Digit2', + key: '2', + preventDefault: altPreventDefault, + stopImmediatePropagation: altStopImmediatePropagation, + stopPropagation: altStopPropagation, + target + }) + ) + + expect(ctrlPreventDefault).not.toHaveBeenCalled() + expect(ctrlStopPropagation).not.toHaveBeenCalled() + expect(ctrlStopImmediatePropagation).not.toHaveBeenCalled() + expect(altPreventDefault).not.toHaveBeenCalled() + expect(altStopPropagation).not.toHaveBeenCalled() + expect(altStopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + }) + + it('routes focused floating workspace maximize shortcuts like the titlebar control', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + + it('opens maximized when the open-maximized intent is set, ignoring saved bounds', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + requestFloatingTerminalOpenMaximized() + + await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + // Why: the intent is one-shot and must be consumed by the open transition. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('does not maximize on open when no intent is set', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + + const element = await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(element)).toEqual(savedBounds) + }) + + it('routes focused floating workspace minimize shortcuts to close the panel', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.minimize': ['Ctrl+Alt+N'] + } as unknown as KeybindingOverrides + const onOpenChange = vi.fn() + const element = await renderPanel(true, onOpenChange) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'n', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('routes focused floating workspace maximize shortcuts from a custom binding on Linux', async () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + it('keeps the empty floating workspace focused after Cmd+W closes the last tab', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) @@ -1678,6 +2180,77 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) + it('renders and closes simulator tabs in the floating workspace', async () => { + const tab = setFloatingSimulatorTab() + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + const emulatorPane = findByTypeName(element, 'EmulatorPane') + ;(tabBar.props.onCloseFile as (tabId: string) => void)(tab.id) + + expect(tabBar.props.activeTabType).toBe('simulator') + expect(tabBar.props.activeSimulatorTabId).toBe(tab.id) + expect(emulatorPane.props.tab).toBe(tab) + expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(tab.id) + expect(mocks.closeFile).not.toHaveBeenCalledWith(tab.id) + }) + + it('keeps simulator tabs open when closing all files', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const file = makeFile({ id: 'file-a' }) + const editorTab: Tab = { + id: 'tab-file-a', + entityId: file.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'editor', + label: file.relativePath, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + state.openFiles = [file] + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [editorTab, simulatorTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: editorTab.id, + tabOrder: [editorTab.id, simulatorTab.id], + recentTabIds: [editorTab.id, simulatorTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [editorTab.id, simulatorTab.id] + } + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onCloseAllFiles as () => void)() + + expect(mocks.closeFile).toHaveBeenCalledWith(file.id) + expect(mocks.closeUnifiedTab).not.toHaveBeenCalledWith(simulatorTab.id) + }) + it('routes floating terminal create and close through active web runtime sessions', async () => { const onOpenChange = vi.fn() setFloatingTabs([makeTab({ id: 'tab-1' })]) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index d4919c0b55f..f7c4c31e9b8 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -7,6 +7,7 @@ import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { FileText, Globe, Minus, TerminalSquare } from 'lucide-react' import { toast } from 'sonner' import BrowserPane from '@/components/browser-pane/BrowserPane' +import EmulatorPane from '@/components/emulator-pane/EmulatorPane' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import TabBar from '@/components/tab-bar/TabBar' @@ -57,6 +58,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { keybindingMatchesAction, + matchKeybindingDigitIndex, type KeybindingActionId, type KeybindingContext, type KeybindingMatchOptions, @@ -93,6 +95,7 @@ import { type FloatingTerminalPanelBoundsSource } from './floating-terminal-panel-bounds' import { translate } from '@/i18n/i18n' +import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const EMPTY_GROUPS: TabGroup[] = [] @@ -184,6 +187,7 @@ export function FloatingTerminalPanel({ const closeTab = useAppStore((s) => s.closeTab) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) const closeFile = useAppStore((s) => s.closeFile) + const closeUnifiedTab = useAppStore((s) => s.closeUnifiedTab) const markFileDirty = useAppStore((s) => s.markFileDirty) const activateTab = useAppStore((s) => s.activateTab) const setActiveTab = useAppStore((s) => s.setActiveTab) @@ -277,11 +281,17 @@ export function FloatingTerminalPanel({ const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null const activeEditorUnifiedId = - activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser' + activeTab && + activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'browser' && + activeTab.contentType !== 'simulator' ? activeTab.id : null const activeEditorFileId = - activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser' + activeTab && + activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'browser' && + activeTab.contentType !== 'simulator' ? activeTab.entityId : null const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs]) @@ -331,7 +341,12 @@ export function FloatingTerminalPanel({ const editorItems = useMemo( () => groupTabs - .filter((tab) => tab.contentType !== 'terminal' && tab.contentType !== 'browser') + .filter( + (tab) => + tab.contentType !== 'terminal' && + tab.contentType !== 'browser' && + tab.contentType !== 'simulator' + ) .map((tab) => { const file = floatingFiles.find((candidate) => candidate.id === tab.entityId) return file ? { ...file, tabId: tab.id } : null @@ -339,11 +354,19 @@ export function FloatingTerminalPanel({ .filter((file): file is OpenFile & { tabId: string } => file !== null), [floatingFiles, groupTabs] ) - // Why: restored sessions can retain unified tabs whose backing terminal/file/browser - // records are gone; the empty landing should follow what the user can see. + const simulatorItems = useMemo( + () => groupTabs.filter((tab) => tab.contentType === 'simulator'), + [groupTabs] + ) + // Why: restored sessions can retain unified tabs whose backing records are + // gone; the empty landing should follow what the user can see. const hasVisibleFloatingTabs = - terminalItems.length > 0 || browserItems.length > 0 || editorItems.length > 0 - const visibleFloatingItemCount = terminalItems.length + browserItems.length + editorItems.length + terminalItems.length > 0 || + browserItems.length > 0 || + editorItems.length > 0 || + simulatorItems.length > 0 + const visibleFloatingItemCount = + terminalItems.length + browserItems.length + editorItems.length + simulatorItems.length const activeClosableTab = hasVisibleFloatingTabs ? activeTab : null const tabBarOrder = useMemo( () => @@ -355,6 +378,26 @@ export function FloatingTerminalPanel({ }), [activeGroup, groupTabs] ) + const visibleFloatingTabOrder = useMemo( + () => + tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator') { + return simulatorItems.some((item) => item.id === tab.id) + } + return editorItems.some((item) => item.tabId === tab.id) + }), + [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] + ) const activeBrowserTab = activeBrowserId ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) : null @@ -366,7 +409,9 @@ export function FloatingTerminalPanel({ ? 'browser' : activeTab?.contentType === 'terminal' ? 'terminal' - : 'editor' + : activeTab?.contentType === 'simulator' + ? 'simulator' + : 'editor' useContextualTour('floating-workspace', open, 'floating_workspace_visible', { recordFeatureInteraction: tourInteractionSnapshot?.recordFeatureInteractionForTour ?? false, @@ -784,6 +829,8 @@ export function FloatingTerminalPanel({ } else if (item.contentType === 'browser') { destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + } else if (item.contentType === 'simulator') { + closeUnifiedTab(item.id) } else { const file = state.openFiles.find((candidate) => candidate.id === item.entityId) if (file?.isDirty) { @@ -797,7 +844,7 @@ export function FloatingTerminalPanel({ queueEditorCloseRequests(dirtyEditorFileIds) } }, - [activeGroup, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] + [activeGroup, closeBrowserTab, closeFile, closeTab, closeUnifiedTab, queueEditorCloseRequests] ) const closeFloatingItem = useCallback( @@ -868,7 +915,13 @@ export function FloatingTerminalPanel({ closeFloatingItems( currentGroupTabs .filter( - (tab) => tab.contentType !== 'terminal' && tab.contentType !== 'browser' && !tab.isPinned + (tab) => + tab.contentType !== 'terminal' && + tab.contentType !== 'browser' && + // Why: simulator tabs are not files; "Close All Files" must leave + // the Mobile Emulator open like terminal/browser tabs do. + tab.contentType !== 'simulator' && + !tab.isPinned ) .map((tab) => tab.id) ) @@ -932,21 +985,91 @@ export function FloatingTerminalPanel({ setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target)) }, []) + const toggleMaximized = useCallback(() => { + if (maximized) { + const restoredState = restoreBoundsRef.current ?? { + committedBounds: getDefaultFloatingTerminalCommittedBounds(), + renderedBounds: getDefaultFloatingTerminalBounds(), + source: 'default' as const + } + restoreBoundsRef.current = null + boundsSourceRef.current = restoredState.source + committedBoundsRef.current = restoredState.committedBounds + const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) + ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) + : restoredState.renderedBounds + stagedBoundsRef.current = null + setBounds(restoredBounds) + setMaximized(false) + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + const maximizePanel = useCallback(() => { + // Why: idempotent maximize used by the open-into-maximized intent. Unlike + // toggleMaximized it never restores, and only stashes restore bounds on the + // first transition so a redundant call cannot clobber the saved size. + if (maximized) { + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + useEffect(() => { + // Why: when App opens the panel via Cmd+Opt+Shift+A while it was closed, + // it records a one-shot intent; honor it once the panel is open so it + // starts maximized regardless of its last saved size. + if (open && consumeFloatingTerminalOpenMaximizedIntent()) { + maximizePanel() + } + }, [open, maximizePanel]) + const handleFloatingPanelShortcutAction = useCallback( (input: FloatingPanelShortcutInput, consume: () => void): boolean => { const state = useAppStore.getState() const platform = getShortcutPlatform() + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(input.target) const context: KeybindingContext = input.doubleTapModifier ? 'app' - : isFloatingWorkspaceTerminalInputTarget(input.target) + : isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + // Floating panel chrome owns these controls even when xterm has DOM focus; + // keep the rest of the shortcut table in terminal context for terminal-first. + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const matches = (actionId: KeybindingActionId): boolean => keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions) + const matchesFloatingChrome = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction( + actionId, + input, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if (matches('tab.newTerminal')) { consume() @@ -982,10 +1105,43 @@ export function FloatingTerminalPanel({ } return true } + if (matchesFloatingChrome('tab.rename') && activeTab) { + consume() + state.setRenamingTabId(activeTab.id) + return true + } + const selectedTabIndex = matchKeybindingDigitIndex( + 'tab.selectByIndex', + input, + platform, + state.keybindings, + matchOptions + ) + if (selectedTabIndex !== null) { + const visibleId = visibleFloatingTabOrder[selectedTabIndex] + if (!visibleId) { + return false + } + consume() + activateFloatingItem(visibleId) + return true + } + if (matchesFloatingChrome('floatingWorkspace.maximize')) { + consume() + toggleMaximized() + return true + } + if (matchesFloatingChrome('floatingWorkspace.minimize')) { + consume() + onOpenChange(false) + return true + } return false }, [ activeClosableTab, + activeTab, + activateFloatingItem, closeFloatingItem, createFloatingBrowserTab, createFloatingMarkdownTab, @@ -993,7 +1149,9 @@ export function FloatingTerminalPanel({ focusPanelForShortcutsAfterClose, onOpenChange, openFloatingMarkdownTab, - visibleFloatingItemCount + toggleMaximized, + visibleFloatingItemCount, + visibleFloatingTabOrder ] ) @@ -1013,14 +1171,47 @@ export function FloatingTerminalPanel({ const state = useAppStore.getState() const platform = getShortcutPlatform() - const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target) - ? 'terminal' - : 'app' + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(event.target) + const context: KeybindingContext = isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const nativeEvent = event.nativeEvent + const isFloatingChromeShortcut = + keybindingMatchesAction( + 'tab.rename', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + matchKeybindingDigitIndex( + 'tab.selectByIndex', + nativeEvent, + platform, + state.keybindings, + matchOptions + ) !== null || + keybindingMatchesAction( + 'floatingWorkspace.maximize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + keybindingMatchesAction( + 'floatingWorkspace.minimize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if ( !isFloatingWorkspacePanelShortcut( @@ -1029,7 +1220,8 @@ export function FloatingTerminalPanel({ panelRef.current, state.keybindings, matchOptions - ) + ) && + !isFloatingChromeShortcut ) { return } @@ -1214,35 +1406,6 @@ export function FloatingTerminalPanel({ window.removeEventListener('blur', handleWindowBlur) } }, [open]) - - const toggleMaximized = useCallback(() => { - if (maximized) { - const restoredState = restoreBoundsRef.current ?? { - committedBounds: getDefaultFloatingTerminalCommittedBounds(), - renderedBounds: getDefaultFloatingTerminalBounds(), - source: 'default' as const - } - restoreBoundsRef.current = null - boundsSourceRef.current = restoredState.source - committedBoundsRef.current = restoredState.committedBounds - const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) - ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) - : restoredState.renderedBounds - stagedBoundsRef.current = null - setBounds(restoredBounds) - setMaximized(false) - return - } - restoreBoundsRef.current = { - committedBounds: committedBoundsRef.current, - renderedBounds: bounds, - source: boundsSourceRef.current - } - stagedBoundsRef.current = null - setBounds(getMaximizedFloatingTerminalBounds()) - setMaximized(true) - }, [bounds, maximized]) - const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return @@ -1375,6 +1538,7 @@ export function FloatingTerminalPanel({ browserTabs={browserItems} activeFileId={activeEditorUnifiedId} activeBrowserTabId={activeBrowserId} + activeSimulatorTabId={activeTab?.contentType === 'simulator' ? activeTab.id : null} activeTabType={activeTabType} onActivateFile={activateFloatingItem} onCloseFile={closeFloatingItem} @@ -1464,6 +1628,18 @@ export function FloatingTerminalPanel({

) })} + {simulatorItems.map((tab) => { + const isActive = tab.id === activeTab?.id + return ( +
+ +
+ ) + })} {activeEditorFile ? (
s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') + const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const defaultAgent = @@ -160,13 +171,19 @@ export function FloatingTerminalWindowControls({ {maximized - ? translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', - 'Restore' + ? withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', + 'Restore' + ), + maximizeShortcutLabel ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', - 'Maximize' + : withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', + 'Maximize' + ), + maximizeShortcutLabel )} @@ -187,9 +204,12 @@ export function FloatingTerminalWindowControls({ - {translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', - 'Minimize' + {withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', + 'Minimize' + ), + minimizeShortcutLabel )} diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index f76361d3654..705d306072c 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -47,19 +47,25 @@ import type { GitHubWorkItem } from '../../../../shared/types' import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker' import ProjectViewList from './ProjectViewList' import ProjectItemSlugDialog from './ProjectItemSlugDialog' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' import { resolveMissingRepoProjectDialogState, resolveRepoBackedProjectDialogState } from './project-dialog-state' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, getVisibleProjectTable, type CachedVisibleProjectTable } from './project-visible-table-cache' import { translate } from '@/i18n/i18n' -type Props = Record +type Props = { + selectedRepoIds: ReadonlySet +} const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new' @@ -80,7 +86,7 @@ function getProjectViewSourceScope(settings: Parameters s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) const fetchProjectViewTable = useAppStore((s) => s.fetchProjectViewTable) @@ -329,9 +335,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J const table: GitHubProjectTable | null = currentCacheKey ? (projectViewCache[currentCacheKey]?.data ?? null) : null + const selectedRepoFingerprint = useMemo( + () => getSelectedRepoFingerprint(selectedRepoIds), + [selectedRepoIds] + ) const filteredTable = useMemo( - () => (table && slugIndexReady ? filterProjectTableRowsByOpenRepos(table, lookupSlug) : null), - [table, slugIndexReady, lookupSlug] + () => + table && slugIndexReady + ? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds) + : null, + [table, slugIndexReady, lookupSlug, selectedRepoIds] ) const lastFilteredTableRef = useRef(null) // Why: this cache only prevents a blank table while the repo slug index @@ -339,6 +352,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J // a second render after every filtered-table change. lastFilteredTableRef.current = getNextVisibleProjectTableCache({ currentCacheKey, + selectedRepoFingerprint, sourceTable: table, slugIndexReady, filteredTable, @@ -346,6 +360,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) const visibleTable = getVisibleProjectTable({ currentCacheKey, + selectedRepoFingerprint, slugIndexReady, filteredTable, cachedTable: lastFilteredTableRef.current @@ -401,7 +416,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } | null>(null) const liveRepoIds = useMemo(() => new Set(repos.map((repo) => repo.id)), [repos]) - const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(dialogRepoItem, liveRepoIds) + const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState( + dialogRepoItem, + liveRepoIds, + selectedRepoIds + ) if (resolvedDialogRepoItem !== dialogRepoItem) { // Why: repo-backed Project dialogs cannot edit after their repo leaves // Orca; clear them before the modal tree receives stale repo ids. @@ -412,7 +431,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J slugIndexReady, slugDialog, repoNotInOrca, - lookupSlug + lookupSlug, + selectedRepoIds }) if (resolvedMissingRepoDialogs.slugDialog !== slugDialog) { // Why: once a previously missing repo is registered, Project rows should @@ -483,6 +503,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J [] ) + const openProjectRowUrlWithToast = useCallback((row: GitHubProjectRow, message: string) => { + if (row.content.url) { + void window.api.shell.openUrl(row.content.url) + } + toast.message(message) + }, []) + const handleOpenDialog = useCallback( (row: GitHubProjectRow) => { if (!currentCacheKey || !table) { @@ -496,19 +523,69 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (matched) { - const workItem = buildWorkItem(row, matched.id) + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'selected_match') { + const workItem = buildWorkItem(row, resolution.repo.id) if (workItem) { - setDialogRepoItem({ workItem, repoPath: matched.path, repoId: matched.id, origin }) + setDialogRepoItem({ + workItem, + repoPath: resolution.repo.path, + repoId: resolution.repo.id, + origin + }) return } } - // Unknown repo — use the simplified slug-mode dialog. - setSlugDialog({ origin }) + if (resolution.status === 'no_global_match') { + // Unknown repo — use the simplified slug-mode dialog. + setSlugDialog({ origin }) + return + } + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + } }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleStartWork = useCallback( @@ -520,9 +597,23 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J if (!origin) { return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (!matched) { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'no_global_match') { setRepoNotInOrca({ owner: origin.owner, repo: origin.repo, @@ -530,13 +621,36 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) return } - const workItem = buildWorkItem(row, matched.id) + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + return + } + if (resolution.status !== 'selected_match') { + return + } + const workItem = buildWorkItem(row, resolution.repo.id) if (!workItem) { return } void launchWorkItemDirect({ item: workItem, - repoId: matched.id, + repoId: resolution.repo.id, launchSource: 'task_page', telemetrySource: 'sidebar', openModalFallback: () => { @@ -550,7 +664,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } }) }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleEditAssignees = useCallback( diff --git a/src/renderer/src/components/github-project/project-dialog-state.test.ts b/src/renderer/src/components/github-project/project-dialog-state.test.ts index 78aa6b1274a..99afa238841 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.test.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.test.ts @@ -8,18 +8,34 @@ describe('resolveRepoBackedProjectDialogState', () => { it('keeps a repo-backed dialog when the repo still exists', () => { const dialog = { repoId: 'repo-1', label: 'Issue 1' } - expect(resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']))).toBe(dialog) + expect( + resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']), new Set(['repo-1'])) + ).toBe(dialog) }) it('clears a repo-backed dialog when its repo is removed', () => { expect( - resolveRepoBackedProjectDialogState({ repoId: 'repo-1' }, new Set(['repo-2'])) + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-2']), + new Set(['repo-1']) + ) + ).toBeNull() + }) + + it('clears a repo-backed dialog when its repo is no longer selected', () => { + expect( + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-1']), + new Set(['repo-2']) + ) ).toBeNull() }) }) describe('resolveMissingRepoProjectDialogState', () => { - it('waits for the slug index before closing missing-repo dialogs', () => { + it('clears fallback dialogs while the slug index is rebuilding', () => { const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } @@ -28,9 +44,10 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: false, slugDialog, repoNotInOrca, - lookupSlug: () => ['repo-1'] + lookupSlug: () => [{ id: 'repo-1' }], + selectedRepoIds: new Set(['repo-1']) }) - ).toEqual({ slugDialog, repoNotInOrca }) + ).toEqual({ slugDialog: null, repoNotInOrca: null }) }) it('clears slug fallback dialogs once the repo slug resolves', () => { @@ -40,7 +57,8 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBeNull() @@ -54,10 +72,39 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBe(slugDialog) expect(result.repoNotInOrca).toBeNull() }) + + it('clears fallback dialogs when the repo is globally known but not selected', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [{ id: 'repo-2' }], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog: null, repoNotInOrca: null }) + }) + + it('keeps missing-repo fallback dialogs when there are no global matches', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog, repoNotInOrca }) + }) }) diff --git a/src/renderer/src/components/github-project/project-dialog-state.ts b/src/renderer/src/components/github-project/project-dialog-state.ts index cb092bcae10..88f71c9fcb1 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.ts @@ -14,17 +14,30 @@ type RepoNotInOrcaDialogState = { repo: string } -type LookupSlug = (slug: string) => readonly unknown[] +type RepoMatch = { + id: string +} -function hasRepoMatch(lookupSlug: LookupSlug, owner: string, repo: string): boolean { - return lookupSlug(`${owner}/${repo}`).length > 0 +type LookupSlug = (slug: string) => readonly RepoMatch[] + +function shouldCloseFallbackDialog(args: { + lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet + owner: string + repo: string +}): boolean { + const matches = args.lookupSlug(`${args.owner}/${args.repo}`) + const selectedMatchCount = matches.filter((match) => args.selectedRepoIds.has(match.id)).length + const unselectedMatchCount = matches.length - selectedMatchCount + return selectedMatchCount > 0 || unselectedMatchCount > 0 } export function resolveRepoBackedProjectDialogState( dialog: T | null, - liveRepoIds: ReadonlySet + liveRepoIds: ReadonlySet, + selectedRepoIds: ReadonlySet ): T | null { - if (dialog && !liveRepoIds.has(dialog.repoId)) { + if (dialog && (!liveRepoIds.has(dialog.repoId) || !selectedRepoIds.has(dialog.repoId))) { return null } return dialog @@ -38,21 +51,34 @@ export function resolveMissingRepoProjectDialogState< slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet }): { slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null } { - const { lookupSlug, repoNotInOrca, slugDialog, slugIndexReady } = args + const { lookupSlug, repoNotInOrca, selectedRepoIds, slugDialog, slugIndexReady } = args if (!slugIndexReady) { - return { slugDialog, repoNotInOrca } + return { slugDialog: null, repoNotInOrca: null } } return { slugDialog: - slugDialog && hasRepoMatch(lookupSlug, slugDialog.origin.owner, slugDialog.origin.repo) + slugDialog && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: slugDialog.origin.owner, + repo: slugDialog.origin.repo + }) ? null : slugDialog, repoNotInOrca: - repoNotInOrca && hasRepoMatch(lookupSlug, repoNotInOrca.owner, repoNotInOrca.repo) + repoNotInOrca && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: repoNotInOrca.owner, + repo: repoNotInOrca.repo + }) ? null : repoNotInOrca } diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index fb061c262b9..f70b44be4a6 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types' import type { Repo } from '../../../../shared/types' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsByOpenRepos, + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' function repo(id: string): Repo { return { @@ -92,3 +96,113 @@ describe('filterProjectTableRowsByOpenRepos', () => { expect(filtered.totalCount).toBe(0) }) }) + +describe('filterProjectTableRowsBySelectedRepos', () => { + it('keeps a row when at least one slug match is selected', () => { + const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + (slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), + true, + new Set(['repo-1']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['visible']) + expect(filtered.totalCount).toBe(1) + }) + + it('filters a row when only unselected repos match', () => { + const rows = [row('hidden', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-2')], + true, + new Set(['repo-1']) + ) + + expect(filtered.rows).toEqual([]) + expect(filtered.totalCount).toBe(0) + }) + + it('keeps a row with multiple selected matches for action ambiguity handling', () => { + const rows = [row('ambiguous', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-1'), repo('repo-2'), repo('repo-3')], + true, + new Set(['repo-1', 'repo-2']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['ambiguous']) + }) +}) + +describe('resolveSelectedProjectRowRepo', () => { + it('reports loading without reading stale slug matches', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('loading', 'acme/orca'), + lookupSlug: () => { + throw new Error('should not read stale matches') + }, + slugIndexReady: false, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('loading') + }) + + it('reports invalid slug for rows without a repository', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing-slug', null), + lookupSlug: () => [repo('repo-1')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('invalid_slug') + }) + + it('reports no global match when Orca has no repo for the slug', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing', 'acme/orca'), + lookupSlug: () => [], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('no_global_match') + }) + + it('reports global-only matches when the repo is not selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('unselected', 'acme/orca'), + lookupSlug: () => [repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('unselected_match') + }) + + it('returns the selected match when exactly one matching repo is selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('selected', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-2']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } }) + }) + + it('reports ambiguity when multiple matching repos are selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('ambiguous', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1', 'repo-2']) + }) + + expect(resolution.status).toBe('ambiguous_selected_match') + }) +}) diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index 5e5f90d113c..a0c23c20d66 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -3,6 +3,52 @@ import type { Repo } from '../../../../shared/types' export type ProjectRowSlugLookup = (slug: string | null | undefined) => readonly Repo[] +export type SelectedProjectRowResolution = + | { status: 'loading' } + | { status: 'invalid_slug' } + | { status: 'no_global_match' } + | { status: 'unselected_match'; globalMatches: readonly Repo[] } + | { status: 'selected_match'; repo: Repo; globalMatches: readonly Repo[] } + | { + status: 'ambiguous_selected_match' + selectedMatches: readonly Repo[] + globalMatches: readonly Repo[] + } + +export function resolveSelectedProjectRowRepo(input: { + row: GitHubProjectRow + lookupSlug: ProjectRowSlugLookup + slugIndexReady: boolean + selectedRepoIds: ReadonlySet +}): SelectedProjectRowResolution { + if (!input.slugIndexReady) { + return { status: 'loading' } + } + + const repository = input.row.content.repository + if (!repository) { + return { status: 'invalid_slug' } + } + const [owner, repo] = repository.split('/') + if (!owner || !repo) { + return { status: 'invalid_slug' } + } + + const globalMatches = input.lookupSlug(repository) + if (globalMatches.length === 0) { + return { status: 'no_global_match' } + } + + const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id)) + if (selectedMatches.length === 0) { + return { status: 'unselected_match', globalMatches } + } + if (selectedMatches.length === 1) { + return { status: 'selected_match', repo: selectedMatches[0], globalMatches } + } + return { status: 'ambiguous_selected_match', selectedMatches, globalMatches } +} + export function projectRowHasOpenRepo( row: GitHubProjectRow, lookupSlug: ProjectRowSlugLookup @@ -20,3 +66,26 @@ export function filterProjectTableRowsByOpenRepos( } return { ...table, rows, totalCount: rows.length } } + +export function filterProjectTableRowsBySelectedRepos( + table: GitHubProjectTable, + lookupSlug: ProjectRowSlugLookup, + slugIndexReady: boolean, + selectedRepoIds: ReadonlySet +): GitHubProjectTable { + const rows = table.rows.filter((row) => { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + return ( + resolution.status === 'selected_match' || resolution.status === 'ambiguous_selected_match' + ) + }) + if (rows.length === table.rows.length && table.totalCount === rows.length) { + return table + } + return { ...table, rows, totalCount: rows.length } +} diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts index e2bc44cca24..e5eeabc40fc 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectTable } from '../../../../shared/github-project-types' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, - getVisibleProjectTable + getVisibleProjectTable, + getVisibleProjectTableCacheKey } from './project-visible-table-cache' function table(id: string): GitHubProjectTable { @@ -17,20 +19,22 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable, slugIndexReady: true, filteredTable, previous: null }) - ).toEqual({ cacheKey: 'project:view', table: filteredTable }) + ).toEqual({ cacheKey: 'project:view:selected:["repo-1"]', table: filteredTable }) }) it('keeps the previous cache while the slug index is rebuilding', () => { - const previous = { cacheKey: 'project:view', table: table('previous') } + const previous = { cacheKey: 'project:view:selected:["repo-1"]', table: table('previous') } expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: table('source'), slugIndexReady: false, filteredTable: null, @@ -45,6 +49,7 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: null, + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: null, slugIndexReady: false, filteredTable: null, @@ -54,11 +59,12 @@ describe('project visible table cache', () => { }) it('shows a matching cached table while the slug index is rebuilding', () => { - const cachedTable = { cacheKey: 'project:view', table: table('cached') } + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable @@ -67,15 +73,36 @@ describe('project visible table cache', () => { }) it('does not show stale cached data for a different cache key', () => { - const cachedTable = { cacheKey: 'other:view', table: table('cached') } + const cachedTable = { cacheKey: 'other:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable }) ).toBeNull() }) + + it('does not show stale cached data for a different repo selection', () => { + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } + + expect( + getVisibleProjectTable({ + currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-2'])), + slugIndexReady: false, + filteredTable: null, + cachedTable + }) + ).toBeNull() + }) + + it('layers selection only onto the renderer visible-table cache key', () => { + const storeCacheKey = 'organization:stablyai:1:view-id:local' + + expect(getVisibleProjectTableCacheKey(storeCacheKey, '["repo-1"]')).not.toBe(storeCacheKey) + }) }) diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.ts b/src/renderer/src/components/github-project/project-visible-table-cache.ts index 2411012259c..6390e897281 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.ts @@ -5,24 +5,41 @@ export type CachedVisibleProjectTable = { table: GitHubProjectTable } +export function getSelectedRepoFingerprint(selectedRepoIds: ReadonlySet): string { + return JSON.stringify([...selectedRepoIds].sort()) +} + +export function getVisibleProjectTableCacheKey( + currentCacheKey: string | null, + selectedRepoFingerprint: string +): string | null { + return currentCacheKey ? `${currentCacheKey}:selected:${selectedRepoFingerprint}` : null +} + export function getNextVisibleProjectTableCache(input: { currentCacheKey: string | null + selectedRepoFingerprint: string sourceTable: GitHubProjectTable | null slugIndexReady: boolean filteredTable: GitHubProjectTable | null previous: CachedVisibleProjectTable | null }): CachedVisibleProjectTable | null { - if (!input.currentCacheKey || !input.sourceTable) { + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + if (!visibleCacheKey || !input.sourceTable) { return null } if (input.slugIndexReady && input.filteredTable) { - return { cacheKey: input.currentCacheKey, table: input.filteredTable } + return { cacheKey: visibleCacheKey, table: input.filteredTable } } return input.previous } export function getVisibleProjectTable(input: { currentCacheKey: string | null + selectedRepoFingerprint: string slugIndexReady: boolean filteredTable: GitHubProjectTable | null cachedTable: CachedVisibleProjectTable | null @@ -30,5 +47,9 @@ export function getVisibleProjectTable(input: { if (input.slugIndexReady || !input.currentCacheKey) { return input.filteredTable } - return input.cachedTable?.cacheKey === input.currentCacheKey ? input.cachedTable.table : null + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + return input.cachedTable?.cacheKey === visibleCacheKey ? input.cachedTable.table : null } diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 43409635549..1d7ac73c013 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -121,7 +121,7 @@ export function HeroFlow({
s.settings?.agentCmdOverrides ?? {}) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const [query, setQuery] = useState('') - const [scope, setScope] = useState('workspace') + const [scope, setScope] = useState('project') const [sort, setSort] = useState('updated') - const [group, setGroup] = useState('folder') + const [group, setGroup] = useState('project') const [hideEmptySessions, setHideEmptySessions] = useState(true) const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) const [sessions, setSessions] = useState([]) @@ -45,27 +60,61 @@ export default function AiVaultPanel(): React.JSX.Element { const refreshIdRef = useRef(0) const refreshInFlightRef = useRef(false) const mountedRef = useRef(true) + const userChangedScopeRef = useRef(false) - const isRemoteWorktree = Boolean(activeRepo?.connectionId) + const isRemoteWorktree = Boolean(activeWorktreeRepo?.connectionId) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. - const liveWorktrees = useMemo(() => Object.values(worktreesByRepo).flat(), [worktreesByRepo]) const activeWorktreePaths = useMemo( - () => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, liveWorktrees), - [activeWorktree, liveWorktrees] + () => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees), + [activeWorktree, allWorktrees] ) + const projectContext = useMemo( + () => + buildAiVaultProjectContext({ + repos, + worktrees: allWorktrees, + projectHostSetupProjection, + activeRepo, + activeWorktree, + sessions + }), + [activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions] + ) + const activeProjectKey = projectContext.activeProjectKey + const projectLabelByKey = projectContext.projectLabelByKey + const sessionProjectById = projectContext.sessionProjectById const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length const viewAdjustmentCount = (hasAllAgentsSelected ? 0 : 1) + (sort === 'updated' ? 0 : 1) + - (group === 'folder' ? 0 : 1) + + (group === 'project' ? 0 : 1) + (hideEmptySessions ? 0 : 1) + // Project scope depends on active project context, but should come back after + // transient context loss unless the user intentionally chose another scope. useEffect(() => { - if (!activeWorktreePath && scope === 'workspace') { - setScope('all') + const normalizedScope = normalizeAiVaultScopeForContext({ + scope, + activeProjectKey, + activeWorktreePath + }) + if (normalizedScope !== scope) { + setScope(normalizedScope) } - }, [activeWorktreePath, scope]) + }, [activeProjectKey, activeWorktreePath, scope]) + + useEffect(() => { + if ( + shouldRestoreAiVaultProjectScope({ + scope, + activeProjectKey, + userChangedScope: userChangedScopeRef.current + }) + ) { + setScope('project') + } + }, [activeProjectKey, scope]) const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { if (refreshInFlightRef.current) { @@ -120,14 +169,32 @@ export default function AiVaultPanel(): React.JSX.Element { scope, sort, activeWorktreePaths, + activeProjectKey, + sessionProjectById, + projectLabelByKey, hideEmptySessions }), - [activeWorktreePaths, agents, hideEmptySessions, query, scope, sessions, sort] + [ + activeProjectKey, + activeWorktreePaths, + agents, + hideEmptySessions, + projectLabelByKey, + query, + scope, + sessionProjectById, + sessions, + sort + ] ) const groups = useMemo( - () => groupAiVaultSessions(filteredSessions, group), - [filteredSessions, group] + () => + groupAiVaultSessions(filteredSessions, group, { + sessionProjectById, + projectLabelByKey + }), + [filteredSessions, group, projectLabelByKey, sessionProjectById] ) const buildResumeCommand = useCallback( @@ -212,10 +279,15 @@ export default function AiVaultPanel(): React.JSX.Element { const resetViewOptions = useCallback(() => { setAgents([...AI_VAULT_AGENTS]) setSort('updated') - setGroup('folder') + setGroup('project') setHideEmptySessions(true) }, []) + const handleScopeChange = useCallback((nextScope: AiVaultScope) => { + userChangedScopeRef.current = nextScope !== 'project' + setScope(nextScope) + }, []) + const toggleGroup = useCallback((key: string) => { setCollapsedGroups((current) => { const next = new Set(current) @@ -237,6 +309,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionCount={sessions.length} hasScanResult={Boolean(scanResult)} activeWorktreePath={activeWorktreePath} + activeProjectKey={activeProjectKey} scope={scope} agents={agents} sort={sort} @@ -244,7 +317,7 @@ export default function AiVaultPanel(): React.JSX.Element { hideEmptySessions={hideEmptySessions} adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} - onScopeChange={setScope} + onScopeChange={handleScopeChange} onAgentEnabledChange={setAgentEnabled} onSortChange={setSort} onGroupChange={setGroup} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index a241117a458..f9feda064f4 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -6,7 +6,8 @@ import { Clock3, FolderOpen, ListFilter, - LoaderCircle + LoaderCircle, + PanelsTopLeft } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -36,7 +37,7 @@ import { translate } from '@/i18n/i18n' const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0' const VAULT_SCOPE_TOGGLE_ITEM_CLASS = - 'h-6 min-h-6 min-w-0 border border-transparent bg-transparent px-1.5 text-[10px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1' + 'h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1.5' export function VaultGroupHeader({ group, @@ -99,15 +100,21 @@ export function SessionLoadingState(): React.JSX.Element { export function VaultScopeSwitch({ scope, workspaceAvailable, + projectAvailable, onScopeChange }: { scope: AiVaultScope workspaceAvailable: boolean + projectAvailable: boolean onScopeChange: (scope: AiVaultScope) => void }): React.JSX.Element { - const worktreeLabel = translate( - 'auto.components.right.sidebar.AiVaultPanelControls.worktreeScope', - 'Worktree' + const workspaceLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.workspaceScope', + 'Workspace' + ) + const projectLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.projectScope', + 'Project' ) const allLabel = translate('auto.components.right.sidebar.AiVaultPanelControls.allScope', 'All') @@ -116,12 +123,12 @@ export function VaultScopeSwitch({ type="single" value={scope} onValueChange={(value) => { - if (value === 'workspace' || value === 'all') { + if (value === 'workspace' || value === 'project' || value === 'all') { onScopeChange(value) } }} variant="outline" - className="h-6 shrink-0 rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs" + className="h-7 w-full rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs" aria-label={translate( 'auto.components.right.sidebar.AiVaultPanelControls.scopeAriaLabel', 'Session History scope: {{value0}}', @@ -129,25 +136,37 @@ export function VaultScopeSwitch({ value0: scope === 'workspace' ? translate( - 'auto.components.right.sidebar.AiVaultPanelControls.currentWorktreeLower', - 'current worktree' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower', - 'all sessions' + 'auto.components.right.sidebar.AiVaultPanelControls.currentWorkspaceLower', + 'current workspace' ) + : scope === 'project' + ? translate( + 'auto.components.right.sidebar.AiVaultPanelControls.currentProjectLower', + 'current project' + ) + : translate( + 'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower', + 'all sessions' + ) } )} > - - {allLabel} - - {worktreeLabel} + {workspaceLabel} + + + {projectLabel} + + + {allLabel} ) @@ -253,6 +272,10 @@ export function VaultViewMenu({ value={group} onValueChange={(value) => onGroupChange(value as AiVaultGroup)} > + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')} + {translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 9c0af246830..309ecdbeae1 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -16,6 +16,7 @@ type AiVaultPanelHeaderProps = { sessionCount: number hasScanResult: boolean activeWorktreePath: string | null + activeProjectKey: string | null scope: AiVaultScope agents: readonly AiVaultAgent[] sort: AiVaultSort @@ -39,6 +40,7 @@ export function AiVaultPanelHeader({ sessionCount, hasScanResult, activeWorktreePath, + activeProjectKey, scope, agents, sort, @@ -56,7 +58,7 @@ export function AiVaultPanelHeader({ }: AiVaultPanelHeaderProps): React.JSX.Element { return (
-
+
{/* Why: below 300px the header competes with fixed controls, so compact copy prevents overlap. */} @@ -97,11 +99,6 @@ export function AiVaultPanelHeader({
-
+
+ +
+
{ expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false) }) - it('shows OS file copy only for single local desktop selections', () => { - expect(shouldShowCopyFileAction(null, 1)).toBe(true) - expect(shouldShowCopyFileAction(undefined, 2)).toBe(false) - expect(shouldShowCopyFileAction('ssh-1', 1)).toBe(false) + it('shows OS file copy for single local rows and SSH file rows on desktop', () => { + const previous = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + try { + expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(true) + expect(shouldShowCopyFileAction(directoryNode, null, 1)).toBe(true) + expect(shouldShowCopyFileAction(fileNode, undefined, 2)).toBe(false) + expect(shouldShowCopyFileAction(fileNode, 'ssh-1', 1)).toBe(true) + expect(shouldShowCopyFileAction(directoryNode, 'ssh-1', 1)).toBe(false) - ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true - expect(shouldShowCopyFileAction(null, 1)).toBe(false) + expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(false) + } finally { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = previous + } + }) + + it('copies local and SSH file rows through the clipboard file API', async () => { + const writeClipboardFile = vi.fn().mockResolvedValue({ ok: true }) + ;( + globalThis as unknown as { + window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } } + } + ).window = { api: { ui: { writeClipboardFile } } } + + await copyFileToOsClipboard(fileNode) + await copyFileToOsClipboard(fileNode, 'ssh-1') + + expect(writeClipboardFile).toHaveBeenNthCalledWith(1, '/repo/src/index.ts') + expect(writeClipboardFile).toHaveBeenNthCalledWith(2, { + filePath: '/repo/src/index.ts', + connectionId: 'ssh-1' + }) + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('shows a failure toast when OS file copy fails', async () => { + const writeClipboardFile = vi.fn().mockResolvedValue({ ok: false, reason: 'invalid-path' }) + ;( + globalThis as unknown as { + window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } } + } + ).window = { api: { ui: { writeClipboardFile } } } + + await copyFileToOsClipboard(fileNode) + + expect(toastErrorMock).toHaveBeenCalledWith('Could not copy the file to the clipboard') + }) + + it('shows the remote copy rejection message when SSH materialization fails', async () => { + const writeClipboardFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped')) + ;( + globalThis as unknown as { + window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } } + } + ).window = { api: { ui: { writeClipboardFile } } } + + await copyFileToOsClipboard(fileNode, 'ssh-1') + + expect(toastErrorMock).toHaveBeenCalledWith('Remote connection dropped') }) it('calls the preload download API and shows success only when not canceled', async () => { diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 757120d782a..7975df4605a 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -314,11 +314,15 @@ export function shouldShowRemoteDownloadAction( ) } -export function shouldShowCopyFileAction(connectionId?: string | null, selectionSize = 1): boolean { - // Why: the OS file clipboard only holds local files — remote (SSH) files - // don't exist on this machine, and the web client has no native clipboard. +export function shouldShowCopyFileAction( + node: TreeNode, + connectionId?: string | null, + selectionSize = 1 +): boolean { + // Why: remote directories would require recursive materialization semantics; + // keep this to a single concrete file reference until multi-file copy exists. return ( - !connectionId && + (!connectionId || !node.isDirectory) && selectionSize === 1 && (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true ) @@ -360,6 +364,26 @@ export async function downloadRemoteFile(node: TreeNode, connectionId: string): } } +export async function copyFileToOsClipboard( + node: TreeNode, + connectionId?: string | null +): Promise { + const failureMessage = translate( + 'auto.components.right.sidebar.FileExplorerRow.b234ab25b4', + 'Could not copy the file to the clipboard' + ) + try { + const result = await window.api.ui.writeClipboardFile( + connectionId ? { filePath: node.path, connectionId } : node.path + ) + if (!result.ok) { + toast.error(failureMessage) + } + } catch (error) { + toast.error(extractIpcErrorMessage(error, failureMessage)) + } +} + export function FileExplorerRow({ node, isExpanded, @@ -403,7 +427,7 @@ export function FileExplorerRow({ const FileIcon = getFileTypeIcon(node.relativePath || node.name) const rowDropDir = node.isDirectory ? node.path : targetDir const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId) - const showCopyFileAction = shouldShowCopyFileAction(connectionId, selectionSize) + const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize) const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileExplorerRowDrag({ rowDropDir, @@ -432,23 +456,8 @@ export function FileExplorerRow({ void downloadRemoteFile(node, connectionId) }, [connectionId, node]) const handleCopyFile = useCallback(() => { - const failureMessage = translate( - 'auto.components.right.sidebar.FileExplorerRow.b234ab25b4', - 'Could not copy the file to the clipboard' - ) - void window.api.ui - .writeClipboardFile(node.path) - .then((result) => { - if (!result.ok) { - toast.error(failureMessage) - } - }) - // A failure in the main process rejects the IPC promise; surface the same - // toast instead of leaving an unhandled rejection with no feedback. - .catch(() => { - toast.error(failureMessage) - }) - }, [node.path]) + void copyFileToOsClipboard(node, connectionId) + }, [connectionId, node]) return (
) } @@ -102,11 +104,13 @@ export function SourceControlBranchContextRow({ if (summary.status !== 'ready') { return (
- + + + {summary.errorMessage ?? translate( @@ -131,15 +135,17 @@ export function SourceControlBranchContextRow({ return (
-
+
{translate('auto.components.right.sidebar.SourceControl.e8a1c4b203', 'vs')} - + + +
{stats.length > 0 ? ( diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index fe3be19d1a1..b5a5d850c1a 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -9,7 +9,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { return ( diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index c2cab9dcee0..f7f43cff2ed 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -82,6 +82,35 @@ describe('FontAutocomplete', () => { ).toBe('JetBrains Mono') }) + it('shows the full list on focus when the committed font has multiple matching suggestions', async () => { + function Harness(): ReactNode { + const [value, setValue] = useState('Cascadia Mono') + return ( + + ) + } + + await act(async () => { + root.render() + }) + + await act(async () => { + getInput().focus() + }) + + expect(getOptionLabels()).toEqual([ + 'Arial', + 'Cascadia Code', + 'Cascadia Mono', + 'Cascadia Mono PL', + 'Consolas' + ]) + }) + it('keeps typed searches filtered even after the value updates', async () => { function Harness(): ReactNode { const [value, setValue] = useState('Geist') diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 6af9a0f9511..d60a72c6900 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -630,12 +630,10 @@ export function FontAutocomplete({ () => filterFontSuggestions(suggestions, query), [suggestions, query] ) - // Why: a committed font fills the input, but typed searches can also mirror - // into `value`; only expand exact matches outside an active search session. + // Why: the committed font fills the input, but opening the chooser should + // still reveal every installed font instead of only fonts sharing that name. const visibleSuggestions = - !isFilteringQuery && normalizedQuery === normalizedValue && filteredSuggestions.length <= 1 - ? suggestions - : filteredSuggestions + !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions // Why: sync the highlighted index during render rather than via useEffect so // the correct item is highlighted on the very first paint after open/filter diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index 705b0b7e784..b97b053ad9c 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -1,10 +1,24 @@ import type { GlobalSettings } from '../../../../shared/types' +import { RotateCcw } from 'lucide-react' +import { Slider } from '../ui/slider' +import { Button } from '../ui/button' +import { Label } from '../ui/label' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { getTerminalRightClickToPasteSearchEntry } from './terminal-windows-search' import { OSC52_CLIPBOARD_SETTING_ID } from '../terminal-pane/osc52-clipboard-setting-anchor' import { translate } from '@/i18n/i18n' +import { + DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, + DEFAULT_TERMINAL_SCROLL_SENSITIVITY, + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity +} from '@/lib/pane-manager/pane-terminal-options' +import { + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + normalizeTerminalTuiMouseWheelMultiplier +} from '@/lib/pane-manager/pane-terminal-mouse-wheel' type TerminalInteractionSectionProps = { settings: GlobalSettings @@ -13,6 +27,65 @@ type TerminalInteractionSectionProps = { isWindows: boolean } +type ScrollSpeedSliderProps = { + label: string + description: string + value: number + min: number + max: number + step: number + suffix: string + onChange: (value: number) => void +} + +function formatScrollSpeedValue(value: number): string { + return Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +function ScrollSpeedSlider({ + label, + description, + value, + min, + max, + step, + suffix, + onChange +}: ScrollSpeedSliderProps): React.JSX.Element { + return ( +
+
+
+ +

{description}

+
+ + {formatScrollSpeedValue(value)} + {suffix} + +
+ { + if (next !== undefined) { + onChange(next) + } + }} + /> +
+ {formatScrollSpeedValue(min)} + {formatScrollSpeedValue(max)} +
+
+ ) +} + export function TerminalInteractionSection({ settings, updateSettings, @@ -33,6 +106,123 @@ export function TerminalInteractionSection({ />
+ +
+
+
+ +

+ {translate( + 'auto.components.settings.TerminalPane.scrollSpeed.helper', + 'Adjust how wheel input feels in scrollback and in mouse-aware terminal apps.' + )} +

+
+ +
+
+ + updateSettings({ + terminalScrollSensitivity: normalizeTerminalScrollSensitivity(value) + }) + } + /> + + updateSettings({ + terminalFastScrollSensitivity: normalizeTerminalFastScrollSensitivity(value) + }) + } + /> + + updateSettings({ + terminalTuiScrollSensitivity: normalizeTerminalTuiMouseWheelMultiplier(value) + }) + } + /> +
+
+
+ {/* Why: the Windows-only right-click toggle lives in this section, so the section must also match that search term or settings search would hide the control even though it is present. */} diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx index 3422b3fbeed..349bea46ea8 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx @@ -9,8 +9,10 @@ type MockTerminalInstance = { open: Mock write: Mock reset: Mock + refresh: Mock dispose: Mock loadAddon: Mock + rows: number } type MockLigaturesAddonInstance = { @@ -66,8 +68,10 @@ vi.mock('@xterm/xterm', () => ({ open: Mock write: Mock reset: Mock + refresh: Mock dispose: Mock loadAddon: Mock + rows: number constructor(options: Record) { this.options = { ...options } @@ -78,7 +82,9 @@ vi.mock('@xterm/xterm', () => ({ }) this.write = vi.fn() this.reset = vi.fn() + this.refresh = vi.fn() this.dispose = vi.fn() + this.rows = Number(options.rows) this.loadAddon = vi.fn(() => { if (mockXterm.nextLoadAddonError) { throw mockXterm.nextLoadAddonError @@ -228,6 +234,7 @@ describe('TerminalSettingsPreview terminal lifecycle', () => { const addon = mockLigaturesAddon.instances[0] expect(terminal.loadAddon).toHaveBeenCalledOnce() expect(terminal.loadAddon).toHaveBeenCalledWith(addon) + expect(terminal.refresh).toHaveBeenCalledWith(0, 14) runCleanups() expect(addon.dispose).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index 4294e497a93..8d2a74a51ca 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -260,6 +260,9 @@ export function TerminalSettingsPreview({ try { terminal.loadAddon(addon) ligaturesAddonRef.current = addon + // Why: the preview writes its sample before this effect runs; repaint + // so already-rendered operators switch to ligature glyphs immediately. + terminal.refresh(0, terminal.rows - 1) } catch (err) { addon.dispose() console.warn('[settings preview] ligatures addon failed to attach', err) diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx new file mode 100644 index 00000000000..d55a2336af7 --- /dev/null +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { GlobalSettings } from '../../../../shared/types' +import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting' + +vi.mock('../sidebar/use-sidebar-host-scope-options', () => ({ + useSidebarHostScopeOptions: () => ({ hostOptions: [] }) +})) + +let container: HTMLDivElement +let root: Root +let pickFolderMock: ReturnType + +beforeEach(() => { + pickFolderMock = vi.fn() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + repos: { + pickFolder: pickFolderMock + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + Reflect.deleteProperty(window, 'api') +}) + +function renderWorkspaceDirectorySetting(args: { + settings?: GlobalSettings + updateSettings: (updates: Partial) => void +}): void { + act(() => { + root.render( + + ) + }) +} + +function getInput(): HTMLInputElement { + const input = container.querySelector('input') + if (!input) { + throw new Error('workspace directory input was not rendered') + } + return input +} + +function typePath(path: string): void { + act(() => { + const input = getInput() + // Why: React tracks controlled inputs through the native setter; direct + // assignment can be ignored by the synthetic input event handler. + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setValue?.call(input, path) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +function blurInput(): void { + act(() => { + getInput().dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) +} + +function pressInputKey(key: string, options?: { isComposing?: boolean; keyCode?: number }): void { + act(() => { + const event = new KeyboardEvent('keydown', { key, bubbles: true }) + if (options?.isComposing !== undefined) { + Object.defineProperty(event, 'isComposing', { value: options.isComposing }) + } + if (options?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: options.keyCode }) + } + getInput().dispatchEvent(event) + }) +} + +function getBrowseButton(): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find( + (entry) => entry.textContent?.trim() === 'Browse' + ) + if (!button) { + throw new Error('browse button was not rendered') + } + return button +} + +async function clickBrowseAfterInputBlur(): Promise { + await act(async () => { + const button = getBrowseButton() + button.dispatchEvent(new Event('pointerdown', { bubbles: true })) + getInput().dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('WorkspaceDirectorySetting', () => { + it('keeps typed workspace paths local until blur', () => { + const updateSettings = vi.fn() + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('o') + typePath('or') + typePath('orca-workspaces') + + expect(updateSettings).not.toHaveBeenCalled() + + blurInput() + + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ workspaceDir: 'orca-workspaces' }) + }) + + it('commits Enter once even though Enter also blurs the input', () => { + const updateSettings = vi.fn() + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('orca-workspaces') + pressInputKey('Enter') + blurInput() + + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ workspaceDir: 'orca-workspaces' }) + }) + + it('does not commit Enter while IME composition is active', () => { + const updateSettings = vi.fn() + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('orca-workspaces') + pressInputKey('Enter', { isComposing: true }) + + expect(getInput().value).toBe('orca-workspaces') + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('resets the draft on Escape without saving', () => { + const updateSettings = vi.fn() + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('orca-workspaces') + pressInputKey('Escape') + blurInput() + + expect(getInput().value).toBe(getDefaultSettings('/tmp').workspaceDir) + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('does not reset Escape while IME composition is active', () => { + const updateSettings = vi.fn() + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('orca-workspaces') + pressInputKey('Escape', { isComposing: true }) + + expect(getInput().value).toBe('orca-workspaces') + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('does not save a dirty partial path before Browse resolves', async () => { + const updateSettings = vi.fn() + pickFolderMock.mockResolvedValue('/Users/alice/workspaces') + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('orca-w') + await clickBrowseAfterInputBlur() + + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ workspaceDir: '/Users/alice/workspaces' }) + }) + + it('resets an unsaved dirty draft when Browse is canceled', async () => { + const updateSettings = vi.fn() + pickFolderMock.mockResolvedValue(null) + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('orca-w') + await clickBrowseAfterInputBlur() + + expect(getInput().value).toBe(getDefaultSettings('/tmp').workspaceDir) + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('allows retrying the same draft when the persisted value has not changed', () => { + const updateSettings = vi.fn() + renderWorkspaceDirectorySetting({ updateSettings }) + + typePath('orca-workspaces') + blurInput() + blurInput() + + expect(updateSettings).toHaveBeenCalledTimes(2) + expect(updateSettings).toHaveBeenNthCalledWith(1, { workspaceDir: 'orca-workspaces' }) + expect(updateSettings).toHaveBeenNthCalledWith(2, { workspaceDir: 'orca-workspaces' }) + }) +}) diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 5d2085eefa8..70fa7e3eb7f 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useId, useRef, useState } from 'react' import { FolderOpen, RotateCcw } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' import { @@ -33,6 +33,7 @@ export function WorkspaceDirectorySetting({ }: WorkspaceDirectorySettingProps): React.JSX.Element { const { hostOptions } = useSidebarHostScopeOptions() const [scope, setScope] = useState(CLIENT_DEFAULT_SCOPE) + const inputId = useId() const clientDefaultLabel = translate( 'auto.components.settings.WorkspaceDirectorySetting.1a2b3c4d5e', @@ -59,6 +60,21 @@ export function WorkspaceDirectorySetting({ settings.workspaceDir ) : settings.workspaceDir + // Why: settings:set prepares the workspace root with mkdir; committing each + // keystroke would create every typed path prefix as a real directory. + const [draftValue, setDraftValue] = useState(value) + const draftValueRef = useRef(value) + const skipNextBlurCommitRef = useRef(false) + + useEffect(() => { + setDraftValue(value) + draftValueRef.current = value + }, [value]) + + const setDraft = (next: string): void => { + draftValueRef.current = next + setDraftValue(next) + } const writeValue = (next: string): void => { if (!editingHost) { @@ -75,6 +91,26 @@ export function WorkspaceDirectorySetting({ }) } + const commitDraftValue = (): void => { + const next = draftValueRef.current + if (next === value) { + return + } + writeValue(next) + } + + const handleBlur = (): void => { + if (skipNextBlurCommitRef.current) { + skipNextBlurCommitRef.current = false + return + } + commitDraftValue() + } + + const resetDraftValue = (): void => { + setDraft(value) + } + const resetOverride = (): void => { if (!editingHost) { return @@ -89,9 +125,16 @@ export function WorkspaceDirectorySetting({ } const handleBrowse = async (): Promise => { - const path = await window.api.repos.pickFolder() - if (path) { - writeValue(path) + try { + const path = await window.api.repos.pickFolder() + if (path) { + setDraft(path) + writeValue(path) + return + } + resetDraftValue() + } finally { + skipNextBlurCommitRef.current = false } } @@ -113,7 +156,7 @@ export function WorkspaceDirectorySetting({ className="space-y-2" >
-
writeValue(e.target.value)} + id={inputId} + value={draftValue} + onChange={(e) => { + setDraft(e.target.value) + }} + onBlur={handleBlur} + onKeyDown={(e) => { + if (isComposingKeyboardEvent(e)) { + return + } + if (e.key === 'Enter') { + skipNextBlurCommitRef.current = true + commitDraftValue() + e.currentTarget.blur() + return + } + if (e.key === 'Escape') { + skipNextBlurCommitRef.current = true + resetDraftValue() + e.currentTarget.blur() + } + }} className="flex-1 text-xs" /> + + ) + + return ( + + } + label={translate( + 'auto.components.sidebar.WorktreeCardMeta.e97d8f2876', + 'Issue #{{value0}}', + { + value0: issue.number + } + )} + actions={ + <> + {issue.url && onCopyIssueLink && ( + + {issueMenuOpen ? ( + moreActionsTrigger + ) : ( + + {moreActionsTrigger} + + {moreActionsLabel} + + + )} + + + + {translate('auto.components.sidebar.WorktreeCardMeta.copyLink', 'Copy link')} + + + + )} + {onEditIssue && ( + + + + )} + {issue.url && onOpenGitHubIssueInOrca && ( + + + + )} + {issue.url && ( + + + + )} + + } + /> + +
+ {issue.title} +
+ {(issue.state || issueLabels.length > 0) && ( +
+ {issue.state && } + {issueLabels.map((label) => ( + + {label} + + ))} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx index 07c73ec5620..7464b6f5477 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -2,9 +2,14 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WorktreeCardDetailsHover } from './WorktreeCardMeta' +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn() +})) + const interactionMocks = vi.hoisted(() => ({ hoverOpen: false, onHoverOpenChange: undefined as ((open: boolean) => void) | undefined, @@ -13,6 +18,10 @@ const interactionMocks = vi.hoisted(() => ({ onUnlinkSelect: undefined as (() => void) | undefined })) +vi.mock('sonner', () => ({ + toast: toastMocks +})) + vi.mock('@/components/ui/hover-card', () => ({ HoverCard: ({ children, @@ -78,6 +87,7 @@ const reviewFixture = { describe('WorktreeCardDetailsHover interactions', () => { let container: HTMLDivElement let root: Root + const writeClipboardText = vi.fn() afterEach(() => { act(() => { @@ -89,6 +99,21 @@ describe('WorktreeCardDetailsHover interactions', () => { interactionMocks.onHoverOpenChange = undefined interactionMocks.onReviewMenuOpenChange = undefined interactionMocks.onUnlinkSelect = undefined + writeClipboardText.mockReset() + toastMocks.success.mockReset() + toastMocks.error.mockReset() + }) + + beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { + writeClipboardText + } + } + }) + writeClipboardText.mockResolvedValue(undefined) }) function renderHover(onUnlinkReview = vi.fn()): ReturnType { @@ -142,16 +167,14 @@ describe('WorktreeCardDetailsHover interactions', () => { ) }) - it('suppresses the tooltip while the review menu is open', () => { + it('omits the review trigger tooltip while the review menu is open', () => { renderHover() act(() => { interactionMocks.onReviewMenuOpenChange?.(true) }) - expect(container.querySelector('[data-tooltip-open]')?.getAttribute('data-tooltip-open')).toBe( - 'false' - ) + expect(container.textContent).not.toContain('More PR actions') }) it('invokes unlink and closes the hover from the menu item', () => { @@ -178,4 +201,55 @@ describe('WorktreeCardDetailsHover interactions', () => { container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open') ).toBe('false') }) + + it('copies the review URL and closes the hover from the menu item', async () => { + const onUnlinkReview = renderHover() + + act(() => { + interactionMocks.onHoverOpenChange?.(true) + interactionMocks.onReviewMenuOpenChange?.(true) + }) + + const copyButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy link') + ) + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456') + expect(onUnlinkReview).not.toHaveBeenCalled() + expect(toastMocks.success).toHaveBeenCalledWith('PR link copied') + expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe( + 'false' + ) + expect( + container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open') + ).toBe('false') + }) + + it('reports clipboard failures without unlinking the review', async () => { + writeClipboardText.mockRejectedValueOnce(new Error('clipboard unavailable')) + const onUnlinkReview = renderHover() + + act(() => { + interactionMocks.onHoverOpenChange?.(true) + interactionMocks.onReviewMenuOpenChange?.(true) + }) + + const copyButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy link') + ) + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456') + expect(onUnlinkReview).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledWith('Failed to copy link') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx index 54be63c528a..895efe1151a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx @@ -109,13 +109,15 @@ describe('WorktreeCardDetailsHover', () => { expect(moreActionsIndex).toBeGreaterThan(-1) expect(markup).toContain('More PR actions') + expect(markup).toContain('Copy link') expect(markup).toContain('Unlink PR') expect(moreActionsIndex).toBeLessThan(openInOrcaIndex) expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex) expect(markup).not.toContain('aria-label="Unlink PR"') + expect(markup.indexOf('Copy link')).toBeLessThan(markup.indexOf('Unlink PR')) }) - it('puts issue edit before open actions and keeps GitHub last', () => { + it('puts issue copy menu before edit and open actions and keeps GitHub last', () => { const markup = renderToStaticMarkup( { ) + const moreActionsIndex = markup.indexOf('aria-label="More issue actions"') + const copyLinkIndex = markup.indexOf('Copy link') const editIssueIndex = markup.indexOf('aria-label="Edit issue"') const openInOrcaIndex = markup.indexOf('aria-label="Open in Orca"') const viewOnGitHubIndex = markup.indexOf('aria-label="View on GitHub"') + expect(moreActionsIndex).toBeGreaterThan(-1) + expect(copyLinkIndex).toBeGreaterThan(-1) expect(editIssueIndex).toBeGreaterThan(-1) + expect(moreActionsIndex).toBeLessThan(editIssueIndex) + expect(copyLinkIndex).toBeLessThan(editIssueIndex) expect(editIssueIndex).toBeLessThan(openInOrcaIndex) expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 9025c86e6f6..a33e167669b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Badge } from '@/components/ui/badge' import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' +import { toast } from 'sonner' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -12,7 +13,7 @@ import { WorktreeCardDetailSectionContent } from './WorktreeCardDetailSection' import { DetailHeader, MetaIconBadge, MetadataActionIcon } from './WorktreeCardMetadataControls' -import { IssueStateBadge, LinearStateBadge } from './WorktreeCardMetadataStatusBadges' +import { LinearStateBadge } from './WorktreeCardMetadataStatusBadges' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' import type { @@ -25,6 +26,7 @@ import type { import { translate } from '@/i18n/i18n' import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection' import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' +import { WorktreeCardIssueDetailSection } from './WorktreeCardIssueDetailSection' export type { WorktreeCardIssueDisplay, @@ -155,8 +157,10 @@ export function WorktreeCardDetailsHover({ const internalHoverControl = useWorktreeCardDetailsHoverControl() const { hoverOpen, + issueMenuOpen, reviewMenuOpen, handleHoverOpenChange, + handleIssueMenuOpenChange, handleReviewMenuOpenChange, closeHover } = hoverControl ?? internalHoverControl @@ -167,6 +171,43 @@ export function WorktreeCardDetailsHover({ }, [closeHover] ) + const copyLinkedWorkItemLink = React.useCallback(async (url: string, label: string) => { + try { + // Why: Electron clipboard IPC remains reliable from nested hover/dropdown + // overlays where browser clipboard activation can be lost. + await window.api.ui.writeClipboardText(url) + toast.success( + translate('auto.components.sidebar.WorktreeCardMeta.copyLinkSuccess', '{{value0}} copied', { + value0: label + }) + ) + } catch { + toast.error( + translate('auto.components.sidebar.WorktreeCardMeta.copyLinkFailure', 'Failed to copy link') + ) + } + }, []) + const handleCopyIssueLink = React.useCallback((): void => { + if (!issue?.url) { + return + } + closeHover() + void copyLinkedWorkItemLink( + issue.url, + translate('auto.components.sidebar.WorktreeCardMeta.issueLinkLabel', 'Issue link') + ) + }, [closeHover, copyLinkedWorkItemLink, issue?.url]) + const handleCopyReviewLink = React.useCallback((): void => { + if (!review?.url) { + return + } + void copyLinkedWorkItemLink( + review.url, + translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', { + value0: getReviewLabel(review) + }) + ) + }, [copyLinkedWorkItemLink, review]) const showIdentityHeader = Boolean(branchName || workspaceTitle) @@ -178,7 +219,6 @@ export function WorktreeCardDetailsHover({ return children } - const issueLabels = issue?.labels ?? [] const branchIdentity = branchName ? (
)} - {issue && ( - - } - label={translate( - 'auto.components.sidebar.WorktreeCardMeta.e97d8f2876', - 'Issue #{{value0}}', - { value0: issue.number } - )} - actions={ - <> - {onEditIssue && ( - - - - )} - {issue.url && onOpenGitHubIssueInOrca && ( - - - - )} - {issue.url && ( - - - - )} - - } - /> - -
- {issue.title} -
- {(issue.state || issueLabels.length > 0) && ( -
- {issue.state && } - {issueLabels.map((label) => ( - - {label} - - ))} -
- )} -
-
- )} + {linearIssue && ( @@ -357,6 +343,7 @@ export function WorktreeCardDetailsHover({ reviewMenuOpen={reviewMenuOpen} onReviewMenuOpenChange={handleReviewMenuOpenChange} onOpenReviewInOrca={onOpenReviewInOrca} + onCopyReviewLink={review?.url ? handleCopyReviewLink : undefined} onUnlinkReview={onUnlinkReview} closeHover={closeHover} /> diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx index 8b8f774fd34..257ccd70c70 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx @@ -7,7 +7,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react' +import { Copy, Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react' import { translate } from '@/i18n/i18n' import { WorktreeCardDetailSection, @@ -23,6 +23,7 @@ type WorktreeCardReviewDetailSectionProps = { reviewMenuOpen: boolean onReviewMenuOpenChange: (open: boolean) => void onOpenReviewInOrca?: (event: React.MouseEvent) => void + onCopyReviewLink?: () => void onUnlinkReview?: () => void closeHover: () => void } @@ -32,6 +33,7 @@ export function WorktreeCardReviewDetailSection({ reviewMenuOpen, onReviewMenuOpenChange, onOpenReviewInOrca, + onCopyReviewLink, onUnlinkReview, closeHover }: WorktreeCardReviewDetailSectionProps): React.JSX.Element | null { @@ -41,6 +43,25 @@ export function WorktreeCardReviewDetailSection({ const reviewLabel = getReviewLabel(review) const reviewProvider = getProviderName(review) + const moreActionsLabel = translate( + 'auto.components.sidebar.WorktreeCardMeta.dbe2d18972', + 'More {{value0}} actions', + { value0: reviewLabel } + ) + const moreActionsTrigger = ( + + + + ) const dismissAndOpenReview = (event: React.MouseEvent): void => { closeHover() onOpenReviewInOrca?.(event) @@ -57,53 +78,52 @@ export function WorktreeCardReviewDetailSection({ )} actions={ <> - {onUnlinkReview && ( + {(onCopyReviewLink || onUnlinkReview) && ( - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeCardMeta.dbe2d18972', - 'More {{value0}} actions', - { value0: reviewLabel } - )} - - + {reviewMenuOpen ? ( + moreActionsTrigger + ) : ( + + {moreActionsTrigger} + + {moreActionsLabel} + + + )} - { - closeHover() - onUnlinkReview?.() - }} - > - - {translate( - 'auto.components.sidebar.WorktreeCardMeta.ae76907ca6', - 'Unlink {{value0}}', - { value0: reviewLabel } - )} - + {onCopyReviewLink && ( + { + closeHover() + onCopyReviewLink() + }} + > + + {translate( + 'auto.components.sidebar.WorktreeCardReviewDetailSection.copyLink', + 'Copy link' + )} + + )} + {onUnlinkReview && ( + { + closeHover() + onUnlinkReview() + }} + > + + {translate( + 'auto.components.sidebar.WorktreeCardMeta.ae76907ca6', + 'Unlink {{value0}}', + { value0: reviewLabel } + )} + + )} )} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3764ee8adff..a46ed4c2d47 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -87,6 +87,7 @@ import { getActiveStickyIndexesForScroll, getStickyHeaderIndexes, getVirtualRowTransform, + pruneStaleVirtualRowElementCache, shouldUseHeaderTopSpacing, type RenderRow } from './worktree-list-virtual-rows' @@ -1941,6 +1942,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => renderRows.map(getRenderRowKey).join('\n'), [renderRows] ) + const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() const activeStickyIndexes = getActiveStickyIndexesForScroll({ @@ -1976,6 +1978,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) useLayoutEffect(() => { + pruneStaleVirtualRowElementCache({ + activeRowKeys: activeRenderRowKeys, + virtualizer + }) // Why: after delete/collapse, TanStack may briefly retain the removed row's // cached element. Measuring that disconnected node reports 0px and corrupts // the next row's slot, so measure only elements whose DOM key still matches @@ -1983,7 +1989,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp measureMountedRows() const frameId = window.requestAnimationFrame(measureMountedRows) return () => window.cancelAnimationFrame(frameId) - }, [prCacheLen, issueCacheLen, measureMountedRows, renderRowKeySignature]) + }, [ + activeRenderRowKeys, + prCacheLen, + issueCacheLen, + measureMountedRows, + renderRowKeySignature, + virtualizer + ]) useVirtualizedScrollAnchor({ anchorRef: scrollAnchorRef, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 84816f774e3..6b5a7ab15c0 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -4,6 +4,7 @@ import { type LinkedWorkItemSummary } from '@/lib/new-workspace' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' +import { createBrowserUuid } from '@/lib/browser-uuid' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, @@ -210,6 +211,11 @@ export async function submitFolderWorkspaceCreate({ workspacePath: workspace.folderPath, connectionId: workspace.connectionId ?? projectGroup.connectionId }) + if (startupPlan && !startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this queued + // startup, so both halves share one renderer-session token. + startupPlan.launchToken = createBrowserUuid() + } const startup = quickAgent && startupPlan @@ -217,6 +223,7 @@ export async function submitFolderWorkspaceCreate({ command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, + ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/components/sidebar/project-header-color.test.ts b/src/renderer/src/components/sidebar/project-header-color.test.ts index e02d622e3b3..931319f242d 100644 --- a/src/renderer/src/components/sidebar/project-header-color.test.ts +++ b/src/renderer/src/components/sidebar/project-header-color.test.ts @@ -39,6 +39,16 @@ describe('resolveProjectGroupHeaderColor', () => { ).toBe(REPO_COLORS[5]) }) + it('returns the repo color for provider-backed project headers', () => { + expect( + resolveProjectGroupHeaderColor({ + groupBy: 'repo', + headerKey: 'project:github:stablyai/orca', + badgeColor: REPO_COLORS[6] + }) + ).toBe(REPO_COLORS[6]) + }) + it('falls back to gray for unknown project group headers', () => { expect( resolveProjectGroupHeaderColor({ diff --git a/src/renderer/src/components/sidebar/project-header-color.ts b/src/renderer/src/components/sidebar/project-header-color.ts index 8a83920bd11..c92f22bf555 100644 --- a/src/renderer/src/components/sidebar/project-header-color.ts +++ b/src/renderer/src/components/sidebar/project-header-color.ts @@ -2,6 +2,7 @@ import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/consta import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' const PROJECT_GROUP_HEADER_KEY_PREFIX = 'repo:' +const PROVIDER_PROJECT_HEADER_KEY_PREFIX = 'project:' export function resolveRepoHeaderColor(badgeColor: string | null | undefined): string { const normalizedBadgeColor = normalizeRepoBadgeColor(badgeColor) @@ -19,9 +20,13 @@ export function resolveProjectGroupHeaderColor(args: { headerKey: string badgeColor: string | null | undefined }): string | undefined { - // Why: pinned headers can appear while grouped by repo, but only repo:* headers - // represent a repo folder whose user-authored badge color should be shown. - if (args.groupBy !== 'repo' || !args.headerKey.startsWith(PROJECT_GROUP_HEADER_KEY_PREFIX)) { + // Why: pinned/project-folder headers can appear while grouped by repo; only + // actual project headers should inherit user-authored project colors. + if ( + args.groupBy !== 'repo' || + (!args.headerKey.startsWith(PROJECT_GROUP_HEADER_KEY_PREFIX) && + !args.headerKey.startsWith(PROVIDER_PROJECT_HEADER_KEY_PREFIX)) + ) { return undefined } return resolveRepoHeaderColor(args.badgeColor) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index 916e0adae39..8d54e0a7220 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -13,33 +13,13 @@ const mocks = vi.hoisted(() => { } }), tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record, - browserTabsByWorktree: {} as Record, - openFiles: [] as { worktreeId: string }[] + ptyIdsByTabId: {} as Record } const activateAndRevealWorktree = vi.fn() - const markInputQuietSchedulerInput = vi.fn() - const pendingCallbacks: (() => void)[] = [] - const pendingCancels: ReturnType[] = [] - const scheduleAfterInputQuiet = vi.fn((callback: () => void) => { - let cancelled = false - const cancel = vi.fn(() => { - cancelled = true - }) - pendingCallbacks.push(() => { - if (!cancelled) { - callback() - } - }) - pendingCancels.push(cancel) - return cancel - }) + const activateAndRevealFolderWorkspace = vi.fn() return { + activateAndRevealFolderWorkspace, activateAndRevealWorktree, - markInputQuietSchedulerInput, - pendingCallbacks, - pendingCancels, - scheduleAfterInputQuiet, state, toastError: vi.fn() } @@ -52,26 +32,19 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace, activateAndRevealWorktree: mocks.activateAndRevealWorktree })) -vi.mock('@/lib/input-quiet-scheduler', () => ({ - markInputQuietSchedulerInput: mocks.markInputQuietSchedulerInput, - scheduleAfterInputQuiet: mocks.scheduleAfterInputQuiet -})) - vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { runSleepWorktrees } from './sleep-worktree-flow' -describe('sleep flow vs queued slept-workspace activation', () => { +describe('sleep flow vs slept-workspace activation', () => { beforeEach(() => { mocks.activateAndRevealWorktree.mockClear() - mocks.markInputQuietSchedulerInput.mockClear() - mocks.scheduleAfterInputQuiet.mockClear() - mocks.pendingCallbacks.length = 0 - mocks.pendingCancels.length = 0 + mocks.activateAndRevealFolderWorkspace.mockClear() mocks.toastError.mockClear() mocks.state.activeWorktreeId = 'wt-parent' mocks.state.setActiveWorktree.mockClear() @@ -93,26 +66,22 @@ describe('sleep flow vs queued slept-workspace activation', () => { 'tab-child-2': ['pty-child-2'], 'tab-child-3': ['pty-child-3'] } - mocks.state.browserTabsByWorktree = {} - mocks.state.openFiles = [] }) - it('does not let an old slept-parent activation fire after sleeping children', async () => { + it('does not leave behind a delayed parent activation after sleeping children', async () => { await runSleepWorktrees(['wt-parent']) expect(mocks.state.activeWorktreeId).toBeNull() expect(mocks.state.ptyIdsByTabId['tab-parent']).toEqual([]) - // A normal click on the slept parent row during selection setup queues a - // wake internally, even though the user is just trying to select children. activateWorktreeFromSidebar('wt-parent') - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(mocks.pendingCallbacks).toHaveLength(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-parent', { + revealInSidebar: false + }) await runSleepWorktrees(['wt-child-1', 'wt-child-2', 'wt-child-3']) - mocks.pendingCallbacks[0]?.() - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(mocks.pendingCancels[0]).toHaveBeenCalledTimes(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index c349dd6eba0..c60544949f1 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -14,9 +14,7 @@ const mocks = vi.hoisted(() => { const toastError = vi.fn() const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() - const cancelPendingSidebarWorktreeActivation = vi.fn() return { - cancelPendingSidebarWorktreeActivation, clearWorktreeSleepIntent, markWorktreeSleepIntent, state, @@ -35,9 +33,6 @@ vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, markWorktreeSleepIntent: mocks.markWorktreeSleepIntent })) -vi.mock('@/lib/sidebar-worktree-activation', () => ({ - cancelPendingSidebarWorktreeActivation: mocks.cancelPendingSidebarWorktreeActivation -})) import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' @@ -51,7 +46,6 @@ describe('runSleepWorktree', () => { mocks.state.consumeSuppressedPtyExit.mockClear() mocks.markWorktreeSleepIntent.mockClear() mocks.clearWorktreeSleepIntent.mockClear() - mocks.cancelPendingSidebarWorktreeActivation.mockClear() mocks.toastError.mockClear() mocks.state.activeWorktreeId = null mocks.state.tabsByWorktree = {} @@ -76,15 +70,6 @@ describe('runSleepWorktree', () => { expect(browsersCallOrder).toBeLessThan(terminalsCallOrder) }) - it('cancels a queued sidebar wake before sleeping worktrees', async () => { - await runSleepWorktrees(['wt-1', 'wt-2']) - - expect(mocks.cancelPendingSidebarWorktreeActivation).toHaveBeenCalledTimes(1) - const cancelCall = mocks.cancelPendingSidebarWorktreeActivation.mock.invocationCallOrder[0] - const browserCall = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] - expect(cancelCall).toBeLessThan(browserCall) - }) - it('clears activeWorktreeId before teardown when the slept worktree is active', async () => { mocks.state.activeWorktreeId = 'wt-1' diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index c0a34d6ad90..76e653c0449 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,7 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' -import { cancelPendingSidebarWorktreeActivation } from '@/lib/sidebar-worktree-activation' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' import { PINNED_GROUP_KEY } from './worktree-list-groups' @@ -106,9 +105,6 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise if (worktreeIds.length === 0) { return } - // Why: clicking a slept sidebar row queues a delayed wake. A later Sleep - // command is the newer intent, so do not let that stale wake respawn PTYs. - cancelPendingSidebarWorktreeActivation() const { activeWorktreeId, setActiveWorktree, diff --git a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts index b90b20d55eb..e17c12b7a26 100644 --- a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts +++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts @@ -1,32 +1,34 @@ import { useCallback, useRef, useState } from 'react' +type WorktreeCardDetailMenu = 'issue' | 'review' + export function useWorktreeCardDetailsHoverControl() { const [open, setOpen] = useState(false) - const [reviewMenuOpen, setReviewMenuOpen] = useState(false) + const [openMenu, setOpenMenu] = useState(null) const pendingHoverCloseRef = useRef(false) const closeHover = useCallback(() => { pendingHoverCloseRef.current = false - setReviewMenuOpen(false) + setOpenMenu(null) setOpen(false) }, []) const handleHoverOpenChange = useCallback( (next: boolean) => { - // Why: the portaled PR menu sits outside HoverCardContent — keep the card - // mounted until the menu closes so the unlink item stays clickable. - if (reviewMenuOpen) { + // Why: portaled detail menus sit outside HoverCardContent — keep the card + // mounted until the menu closes so the menu items stay clickable. + if (openMenu) { pendingHoverCloseRef.current = !next return } pendingHoverCloseRef.current = false setOpen(next) }, - [reviewMenuOpen] + [openMenu] ) - const handleReviewMenuOpenChange = useCallback((next: boolean) => { - setReviewMenuOpen(next) + const setDetailMenuOpen = useCallback((menu: WorktreeCardDetailMenu, next: boolean) => { + setOpenMenu(next ? menu : null) if (!next && pendingHoverCloseRef.current) { pendingHoverCloseRef.current = false setOpen(false) @@ -34,10 +36,12 @@ export function useWorktreeCardDetailsHoverControl() { }, []) return { - hoverOpen: open || reviewMenuOpen, - reviewMenuOpen, + hoverOpen: open || Boolean(openMenu), + issueMenuOpen: openMenu === 'issue', + reviewMenuOpen: openMenu === 'review', handleHoverOpenChange, - handleReviewMenuOpenChange, + handleIssueMenuOpenChange: (next: boolean) => setDetailMenuOpen('issue', next), + handleReviewMenuOpenChange: (next: boolean) => setDetailMenuOpen('review', next), closeHover } } diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts index 5e56d7df4ea..bb5cf7625c4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' describe('worktree card title display', () => { it('keeps custom workspace titles', () => { @@ -10,6 +13,14 @@ describe('worktree card title display', () => { reviewTitle: 'Fix stale PR' }) ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' Custom workspace ', + branchName: 'feature/custom', + reviewTitle: 'Fix stale PR' + }) + ).toBe(' Custom workspace ') }) it('uses linked work titles instead of repeating the branch as the card title', () => { @@ -49,4 +60,63 @@ describe('worktree card title display', () => { }) ).toBe('test454545') }) + + it('uses linked work titles when the stored title is nullish and the branch is usable', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: undefined, + branchName: 'feature/local-branch', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Fix stale GH PR') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: 'feature/local-branch', + issueTitle: 'Fix stale issue' + }) + ).toBe('Fix stale issue') + }) + + it('treats blank stored titles as absent', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' ', + branchName: 'feature/local-branch' + }) + ).toBe('') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: '', + branchName: 'feature/local-branch', + linearIssueTitle: 'Fix stale Linear issue' + }) + ).toBe('Fix stale Linear issue') + }) + + it('skips linked-title replacement when the branch name is nullish or blank', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: 'Custom workspace', + branchName: undefined, + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: ' ', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('') + }) + + it('coerces legacy visible titles before downstream title operations', () => { + expect(coerceWorktreeCardVisibleTitle(undefined).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(null).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(' Custom workspace ')).toBe(' Custom workspace ') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.ts index 5da84c1f5f4..be0aa9200d7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.ts @@ -1,11 +1,16 @@ type WorktreeCardTitleDisplayInput = { - storedDisplayName: string - branchName: string + storedDisplayName: string | null | undefined + branchName: string | null | undefined linearIssueTitle?: string | null issueTitle?: string | null reviewTitle?: string | null } +function normalizeComparableTitle(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + function normalizeTitle(value: string | null | undefined): string | null { const trimmed = value?.trim() if (!trimmed) { @@ -17,8 +22,17 @@ function normalizeTitle(value: string | null | undefined): string | null { return trimmed } -function isBranchTitle(displayName: string, branchName: string): boolean { - return displayName.trim() === branchName.trim() +function isBranchTitle( + normalizedDisplayName: string | null, + normalizedBranchName: string | null +): boolean { + return normalizedDisplayName !== null && normalizedDisplayName === normalizedBranchName +} + +export function coerceWorktreeCardVisibleTitle(value: string | null | undefined): string { + // Why: the legacy card path can bypass title selection but still feeds trim() + // and inline rename props, so nullish persisted titles stop at this boundary. + return typeof value === 'string' ? value : '' } export function getWorktreeCardTitleDisplay({ @@ -28,8 +42,19 @@ export function getWorktreeCardTitleDisplay({ issueTitle, reviewTitle }: WorktreeCardTitleDisplayInput): string { - if (!branchName || !isBranchTitle(storedDisplayName, branchName)) { - return storedDisplayName + const normalizedStoredDisplayName = normalizeComparableTitle(storedDisplayName) + const normalizedBranchName = normalizeComparableTitle(branchName) + const visibleStoredDisplayName = coerceWorktreeCardVisibleTitle(storedDisplayName) + + if (!normalizedBranchName) { + return normalizedStoredDisplayName ? visibleStoredDisplayName : '' + } + + if ( + normalizedStoredDisplayName && + !isBranchTitle(normalizedStoredDisplayName, normalizedBranchName) + ) { + return visibleStoredDisplayName } // Why: branch names are available in hover/details; the closed card title @@ -38,6 +63,6 @@ export function getWorktreeCardTitleDisplay({ normalizeTitle(linearIssueTitle) ?? normalizeTitle(issueTitle) ?? normalizeTitle(reviewTitle) ?? - storedDisplayName + (normalizedStoredDisplayName ? visibleStoredDisplayName : '') ) } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index cb667f6f1d5..8bf89d66a73 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1365,6 +1365,89 @@ describe('project groups', () => { ]) }) + it('renders repos whose Project Group metadata is missing as top-level repo rows', () => { + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const repoWithMissingGroup: Repo = { ...repo, projectGroupId: 'missing-group' } + + const rows = buildRows( + 'repo', + [worktree], + new Map([[repoWithMissingGroup.id, repoWithMissingGroup]]), + null, + new Set(), + new Map([[repoWithMissingGroup.id, 0]]), + undefined, + 'manual', + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [group] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + 'repo:repo-1' + ]) + expect(rows.find((row) => row.type === 'header' && row.key === 'repo:repo-1')).toMatchObject({ + projectGroupDepth: 0 + }) + }) + + it('does not render collapsed child-group repos as missing metadata fallbacks', () => { + const parentGroup: ProjectGroup = { + id: 'parent-group', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const childGroup: ProjectGroup = { + ...parentGroup, + id: 'child-group', + name: 'Services', + parentPath: '/platform/services', + parentGroupId: parentGroup.id + } + const repoInChildGroup: Repo = { ...repo, projectGroupId: childGroup.id } + + const rows = buildRows( + 'repo', + [worktree], + new Map([[repoInChildGroup.id, repoInChildGroup]]), + null, + new Set(['project-group:parent-group']), + new Map([[repoInChildGroup.id, 0]]), + undefined, + 'manual', + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [parentGroup, childGroup] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:parent-group' + ]) + }) + it('disambiguates duplicate top-level repo basenames without renaming repos', () => { const group: ProjectGroup = { id: 'group-1', @@ -2106,12 +2189,60 @@ describe('project groups', () => { it('returns both parent Project Group and repo keys for grouped repo reveals', () => { const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } expect( - getGroupKeysForWorktree('repo', worktree, new Map([[groupedRepo.id, groupedRepo]]), null) + getGroupKeysForWorktree( + 'repo', + worktree, + new Map([[groupedRepo.id, groupedRepo]]), + null, + undefined, + undefined, + [group] + ) ).toEqual(['project-group:group-1', 'repo:repo-1']) }) + it('returns only the repo key for missing Project Group metadata reveals', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'missing-group' } + const loadedGroup: ProjectGroup = { + id: 'loaded-group', + name: 'Loaded', + parentPath: '/loaded', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + expect( + getGroupKeysForWorktree( + 'repo', + worktree, + new Map([[groupedRepo.id, groupedRepo]]), + null, + undefined, + undefined, + [loadedGroup] + ) + ).toEqual(['repo:repo-1']) + }) + it('returns only the repo key for ungrouped repo reveals', () => { expect(getGroupKeysForWorktree('repo', worktree, repoMap, null)).toEqual(['repo:repo-1']) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 190ef5bcebc..2b5a89f18e2 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1153,8 +1153,17 @@ export function buildRows( appendProjectGroup(projectGroup, 0) } + const remainingRepoEntries = [...(groupByProjectGroupId.get(null) ?? [])] + for (const [projectGroupId, entries] of groupByProjectGroupId) { + if (projectGroupId === null || projectGroupsById.has(projectGroupId)) { + continue + } + // Why: startup can have repos from hosts whose project-group metadata was + // not fetched yet; missing metadata must not make those repos disappear. + remainingRepoEntries.push(...entries) + } appendOrderedGroups( - withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(groupByProjectGroupId.get(null) ?? [])), + withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(remainingRepoEntries)), 0 ) @@ -1217,9 +1226,15 @@ export function getGroupKeysForWorktree( const visited = new Set() let currentGroupId = repo?.projectGroupId ?? null while (currentGroupId && !visited.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + // Why: repos can arrive before their remote Project Group metadata; reveal + // keys must match the top-level fallback rows buildRows actually renders. + break + } visited.add(currentGroupId) groupIds.unshift(currentGroupId) - const parentId = groupsById.get(currentGroupId)?.parentGroupId ?? null + const parentId = group.parentGroupId ?? null currentGroupId = parentId && groupsById.has(parentId) ? parentId : null } return [...groupIds.map((id) => getProjectGroupHeaderKey(id)), groupKey] diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts index 50d820a529c..0e65ebbdd6c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + FLUSH_CARD_CONTENT_PULLBACK, FLUSH_CARD_MIN_CONTENT_INSET, + NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK, LINEAGE_CHILDREN_INLINE_OFFSET, LINEAGE_IMMEDIATE_PARENT_STEP, LINEAGE_NESTED_ROW_SURFACE_INSET, @@ -97,8 +99,15 @@ describe('worktree list indentation', () => { expect(getFlushWorktreeCardPaddingLeft(20)).toBe('max(2px, calc(20px - 4px))') }) + it('pulls experimental flush cards back further for the fixed status lane', () => { + expect(getFlushWorktreeCardPaddingLeft(20, true)).toBe( + `max(2px, calc(20px - ${FLUSH_CARD_CONTENT_PULLBACK + NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK}px))` + ) + }) + it('keeps flush card content off the sidebar edge without indentation', () => { expect(getFlushWorktreeCardPaddingLeft(0)).toBe('2px') + expect(getFlushWorktreeCardPaddingLeft(0, true)).toBe('2px') }) it('derives the lineage parent-child step from the pre-refactor grouped-card anchor', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.ts index fc3dc252e49..f312e197e3e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.ts @@ -6,6 +6,9 @@ const PROJECT_WORKTREE_CARD_EXTRA_INDENT = 2 // raw tree indent to sit under the group header. A smaller pullback nudges // content rightward for clearer nesting; this is the knob to tune that gap. export const FLUSH_CARD_CONTENT_PULLBACK = 4 +// Why: experimental cards reserve a fixed status lane inside the padded +// content box; pull the box back so title/meta text stay on the tree step. +export const NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK = 6 // Why: even at zero indent a flush card keeps this minimal left inset so its // surface never sits hard against the sidebar edge. export const FLUSH_CARD_MIN_CONTENT_INSET = 2 @@ -106,12 +109,43 @@ export function getWorktreeCardSurfaceInset(args: { return args.isGrouped ? clampDepth(args.groupDepth) * GROUPED_WORKTREE_CARD_SURFACE_INDENT : 0 } -export function getFlushWorktreeCardPaddingLeft(contentIndent: number): string { +export function getFlushWorktreeCardPaddingLeft( + contentIndent: number, + applyNewCardStyleStatusLaneOffset = false +): string { + const pullback = + FLUSH_CARD_CONTENT_PULLBACK + + (applyNewCardStyleStatusLaneOffset ? NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK : 0) return contentIndent > 0 - ? `max(${FLUSH_CARD_MIN_CONTENT_INSET}px, calc(${contentIndent}px - ${FLUSH_CARD_CONTENT_PULLBACK}px))` + ? `max(${FLUSH_CARD_MIN_CONTENT_INSET}px, calc(${contentIndent}px - ${pullback}px))` : `${FLUSH_CARD_MIN_CONTENT_INSET}px` } +export function getNewCardStyleParentContentMarginLeft(contentIndent: number): number { + if (contentIndent <= 0) { + return 0 + } + + const legacyInnerPadding = Math.max( + FLUSH_CARD_MIN_CONTENT_INSET, + contentIndent - FLUSH_CARD_CONTENT_PULLBACK + ) + const newInnerPadding = Math.max( + FLUSH_CARD_MIN_CONTENT_INSET, + contentIndent - FLUSH_CARD_CONTENT_PULLBACK - NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK + ) + const paddingShift = legacyInnerPadding - newInnerPadding + const remainingShift = NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK - paddingShift + if (remainingShift <= 0) { + return 0 + } + + // Why: shallow rows hit the flush-card padding floor; finish the status-lane + // offset with margin, but never pull content past the card's inner edge. + const rawMargin = -remainingShift + return Math.max(-newInnerPadding, rawMargin) +} + export function getLineageNestedRowGeometry(args: { experimentalNewWorktreeCardStyle: boolean inheritedCardContentIndent: number diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index e0449b6eeca..cb742a12649 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -5,6 +5,7 @@ import { extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, getStickyHeaderIndexes, + pruneStaleVirtualRowElementCache, type RenderRow } from './worktree-list-virtual-rows' @@ -144,3 +145,49 @@ describe('extractWorktreeVirtualRowIndexes', () => { expect(indexes).toContain(0) }) }) + +describe('pruneStaleVirtualRowElementCache', () => { + it('removes stale measured row elements before they retain old WorktreeCard scopes', () => { + const activeElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:active' : null + } as Element + const staleElement = { + isConnected: false, + getAttribute: (name: string) => (name === 'data-worktree-virtual-row-key' ? 'wt:stale' : null) + } as Element + const connectedStaleElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:connected-stale' : null + } as Element + const retainedScope = { + defaultHostId: 'runtime:env-1', + handlerName: 'handleOpenReviewInOrca' + } + Object.assign(staleElement, { __retainedWorktreeCardScopeForTest: retainedScope }) + + const virtualizer = { + elementsCache: new Map([ + ['wt:active', activeElement], + ['wt:stale', staleElement], + ['wt:connected-stale', connectedStaleElement] + ]), + measureElement: (element: Element | null) => { + if (element) { + throw new Error('stale cache pruning should not remeasure rows') + } + } + } + + pruneStaleVirtualRowElementCache({ + activeRowKeys: new Set(['wt:active']), + virtualizer + }) + + expect(virtualizer.elementsCache.get('wt:active')).toBe(activeElement) + expect(virtualizer.elementsCache.has('wt:stale')).toBe(false) + expect(virtualizer.elementsCache.get('wt:connected-stale')).toBe(connectedStaleElement) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index e98b9c1ee09..93daaf4271c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -76,6 +76,31 @@ export function getVirtualRowTransform(start: number): string { return `translateY(${start}px)` } +type VirtualRowElementCache = { + elementsCache: Map + measureElement: (node: TElement | null) => void +} + +export function pruneStaleVirtualRowElementCache({ + activeRowKeys, + virtualizer +}: { + activeRowKeys: ReadonlySet + virtualizer: VirtualRowElementCache +}): void { + virtualizer.measureElement(null) + for (const [key, element] of virtualizer.elementsCache) { + const rowKey = String(key) + if (activeRowKeys.has(rowKey) || element.isConnected) { + continue + } + // Why: measured row nodes retain their React fiber tree. Once TanStack's + // public null-measure cleanup has run, drop any disconnected stale key left + // behind so old WorktreeCard scopes do not survive runtime-host row churn. + virtualizer.elementsCache.delete(key) + } +} + export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { const indexes: number[] = [] rows.forEach((row, index) => { diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index f9f9e72afb5..67095fee7c8 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -68,6 +68,11 @@ import { getResourceManagerAriaLabel, getResourceManagerTooltipLines } from './resource-manager-terminal-copy' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions, + type ResourceSessionBindingInputs +} from './resource-session-bindings' import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 @@ -732,6 +737,8 @@ export function ResourceUsageStatusSegment({ const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const tabsByWorktreeForPtyBindings = useAppStore((s) => s.tabsByWorktree) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) @@ -778,6 +785,17 @@ export function ResourceUsageStatusSegment({ // While a runtime server is active, hiding local samples avoids showing or // killing sessions from the wrong machine. const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot + // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource + // Manager also reads restored wake hints, but only for classification. + const resourceSessionBindings = useMemo( + () => ({ + ptyIdsByTabId, + tabsByWorktree: tabsByWorktreeForPtyBindings, + terminalLayoutsByTabId, + workspaceSessionReady + }), + [ptyIdsByTabId, tabsByWorktreeForPtyBindings, terminalLayoutsByTabId, workspaceSessionReady] + ) // Why: after a kill confirms and the session unmounts, focus would otherwise // fall to . We park a ref on the popover body so we can restore focus @@ -947,6 +965,7 @@ export function ResourceUsageStatusSegment({ ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -960,6 +979,7 @@ export function ResourceUsageStatusSegment({ sessions, tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -969,28 +989,12 @@ export function ResourceUsageStatusSegment({ // Why: orphanCount drives the trigger badge (always visible in the status // bar, popover open or not) so it must compute outside the open-gate. - // Build the bound set with a single flat walk instead of nested Object - // iterations to keep this light on every store update. const orphanCount = useMemo(() => { if (!workspaceSessionReady || runtimeEnvironmentActive) { return 0 } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } - let n = 0 - for (const s of sessions) { - if (!bound.has(s.id)) { - n++ - } - } - return n - }, [sessions, ptyIdsByTabId, workspaceSessionReady, runtimeEnvironmentActive]) + return countUnboundDaemonSessions(sessions, resourceSessionBindings) + }, [sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = resourceSnapshot?.totalMemory ?? 0 @@ -1123,14 +1127,7 @@ export function ResourceUsageStatusSegment({ if (!workspaceSessionReady) { return } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } + const bound = buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds const orphans = sessions.filter((s) => !bound.has(s.id)) if (orphans.length === 0) { return @@ -1141,7 +1138,7 @@ export function ResourceUsageStatusSegment({ setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id))) await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) void refreshSessions() - }, [sessions, ptyIdsByTabId, workspaceSessionReady, refreshSessions]) + }, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions]) const runKillConfirmed = useCallback(async () => { if (!killConfirm) { diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index 06ed8b088a4..ad0d13dd0da 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -186,6 +186,27 @@ describe('mergeSnapshotAndSessions', () => { expect(out[0].worktrees[0].sessions[0].bound).toBe(true) }) + it('treats startup deferred reattach tab ptyId wake hints as bound sessions', () => { + const tabId = 'tab-restored' + const sessionId = 'orca::/Users/me/Triton@@deferred' + const ds: DaemonSession[] = [{ id: sessionId, cwd: '/Users/me/Triton', title: 'orca/Triton' }] + const restoredTab = { ...makeTab(tabId, 'Restored'), ptyId: sessionId } + const ctx = baseCtx({ + tabsByWorktree: { + 'orca::/Users/me/Triton': [restoredTab] + }, + ptyIdsByTabId: { [tabId]: [] } + }) + + const out = mergeSnapshotAndSessions(null, ds, ctx) + + expect(out[0].worktrees[0].sessions[0]).toMatchObject({ + sessionId, + bound: true, + tabId + }) + }) + it('repo aggregate sums only worktrees with numeric metrics; remote-by-connectionId flags chip', () => { // Why: a single repo can be both reflected as a snapshot worktree // (covered by the local collector) and a daemon-only session diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 82aa54835fa..6fcfa3b2acf 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -31,6 +31,7 @@ import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' +import { buildResourceSessionBindingIndex } from './resource-session-bindings' // ─── Helpers ──────────────────────────────────────────────────────── @@ -124,34 +125,6 @@ function resolveDaemonSessionLabel( return 'unknown' } -// Why: the previous implementation did O(N) linear scans over -// ptyIdsByTabId / tabsByWorktree for *every* session it processed. With a -// large workspace that's S * (T + W) work per merge — and the merge runs on -// every snapshot poll plus every store mutation. Pre-build O(1) lookup -// indices once per merge instead. -type MergeIndex = { - ptyIdToTabId: Map - tabIdToWorktreeId: Map -} - -function buildMergeIndex(ctx: MergeContext): MergeIndex { - const ptyIdToTabId = new Map() - for (const [tabId, ptyIds] of Object.entries(ctx.ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - if (ptyId) { - ptyIdToTabId.set(ptyId, tabId) - } - } - } - const tabIdToWorktreeId = new Map() - for (const [worktreeId, tabs] of Object.entries(ctx.tabsByWorktree)) { - for (const tab of tabs) { - tabIdToWorktreeId.set(tab.id, worktreeId) - } - } - return { ptyIdToTabId, tabIdToWorktreeId } -} - // ─── Public merge function ───────────────────────────────────────── export const UNATTRIBUTED_REPO_ID = '__unattributed__' @@ -164,13 +137,11 @@ export function mergeSnapshotAndSessions( ): UnifiedProjectGroup[] { const repos = new Map() const seenSessionIds = new Set() - const index = buildMergeIndex(ctx) - // Why: bound = the daemon session id appears as a pty id under some tab. - // ptyIdToTabId already encodes that membership in O(1), so the bound set - // is just its keys. - const boundPtyIds = ctx.workspaceSessionReady - ? new Set(index.ptyIdToTabId.keys()) - : new Set() + // Why: pre-build O(1) lookup indices once per merge. This includes live + // ptyIdsByTabId plus deferred-reattach wake hints, so restored inactive + // sessions do not appear as Resource Manager orphans before their pane mounts. + const index = buildResourceSessionBindingIndex(ctx) + const boundPtyIds = index.boundPtyIds function isRepoRemote(repoId: string): boolean { // Why: missing entry === we don't know about this repo (typically the diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.test.ts b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts new file mode 100644 index 00000000000..58b13ca0595 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions +} from './resource-session-bindings' + +function makeTab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo::/workspace', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + type: 'terminal', + paneCount: 1 + } as unknown as TerminalTab +} + +function makeLayout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId + } +} + +describe('resource session bindings', () => { + it('combines live PTYs with restored tab and split-pane wake hints', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: { + 'tab-active': ['pty-live', 'pty-live'] + }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-active'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: { + 'tab-restored': makeLayout({ + 'leaf-1': 'pty-leaf-a', + 'leaf-2': 'pty-leaf-b' + }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId).toEqual( + new Map([ + ['pty-live', 'tab-active'], + ['pty-restored', 'tab-restored'], + ['pty-leaf-a', 'tab-restored'], + ['pty-leaf-b', 'tab-restored'] + ]) + ) + expect([...index.boundPtyIds].sort()).toEqual([ + 'pty-leaf-a', + 'pty-leaf-b', + 'pty-live', + 'pty-restored' + ]) + }) + + it('ignores stale layout-only PTYs after their tab is gone', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: {}, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live', null)] + }, + terminalLayoutsByTabId: { + 'tab-closed': makeLayout({ 'leaf-1': 'pty-closed' }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId.has('pty-closed')).toBe(false) + expect(index.boundPtyIds.has('pty-closed')).toBe(false) + }) + + it('counts only daemon sessions without live or restorable tab bindings', () => { + const count = countUnboundDaemonSessions( + [ + { id: 'pty-live', cwd: '/workspace', title: 'live' }, + { id: 'pty-restored', cwd: '/workspace', title: 'restored' }, + { id: 'pty-orphan', cwd: '/tmp', title: 'orphan' } + ], + { + ptyIdsByTabId: { 'tab-live': ['pty-live'] }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true + } + ) + + expect(count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.ts b/src/renderer/src/components/status-bar/resource-session-bindings.ts new file mode 100644 index 00000000000..f90914f9669 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.ts @@ -0,0 +1,86 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { DaemonSession } from './resource-usage-merge-types' + +export type ResourceSessionBindingInputs = { + tabsByWorktree: Record + ptyIdsByTabId: Record + terminalLayoutsByTabId?: Record + workspaceSessionReady: boolean +} + +export type ResourceSessionBindingIndex = { + ptyIdToTabId: Map + tabIdToWorktreeId: Map + boundPtyIds: Set +} + +function addBinding( + ptyIdToTabId: Map, + tabId: string, + ptyId: string | null | undefined +): void { + if (!ptyId || ptyIdToTabId.has(ptyId)) { + return + } + ptyIdToTabId.set(ptyId, tabId) +} + +export function buildResourceSessionBindingIndex( + inputs: ResourceSessionBindingInputs +): ResourceSessionBindingIndex { + const ptyIdToTabId = new Map() + const tabIdToWorktreeId = new Map() + + for (const [worktreeId, tabs] of Object.entries(inputs.tabsByWorktree)) { + for (const tab of tabs) { + tabIdToWorktreeId.set(tab.id, worktreeId) + } + } + + for (const [tabId, ptyIds] of Object.entries(inputs.ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + // Why: startup-deferred reattach intentionally leaves inactive tabs out of + // ptyIdsByTabId, but their daemon sessions are still owned by tab/layout + // wake hints. Resource Manager should not classify those as orphans. + for (const tabs of Object.values(inputs.tabsByWorktree)) { + for (const tab of tabs) { + addBinding(ptyIdToTabId, tab.id, tab.ptyId) + } + } + + for (const [tabId, layout] of Object.entries(inputs.terminalLayoutsByTabId ?? {})) { + if (!tabIdToWorktreeId.has(tabId)) { + continue + } + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + return { + ptyIdToTabId, + tabIdToWorktreeId, + boundPtyIds: inputs.workspaceSessionReady ? new Set(ptyIdToTabId.keys()) : new Set() + } +} + +export function countUnboundDaemonSessions( + sessions: readonly DaemonSession[], + inputs: ResourceSessionBindingInputs +): number { + if (!inputs.workspaceSessionReady) { + return 0 + } + const { boundPtyIds } = buildResourceSessionBindingIndex(inputs) + let count = 0 + for (const session of sessions) { + if (!boundPtyIds.has(session.id)) { + count += 1 + } + } + return count +} diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 5cf962fcd10..88791b5d36e 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' /** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ export type Metric = number | null @@ -50,6 +50,8 @@ export type MergeContext = { tabsByWorktree: Record /** From useAppStore: maps tabId -> ptyIds[] for the bound check. */ ptyIdsByTabId: Record + /** From useAppStore: persisted per-leaf PTY wake hints for deferred reattach. */ + terminalLayoutsByTabId?: Record /** From useAppStore: per-tab live pane titles (for label resolution). */ runtimePaneTitlesByTabId: Record> /** From useAppStore: false until renderer state can distinguish bound/orphan. */ diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 714a502a200..940ad6df996 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' +import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { basename, normalizeRelativePath } from '@/lib/path' @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' export default function EditorFileTab({ file, @@ -341,25 +342,14 @@ export default function EditorFileTab({ When clean: close button is shown normally (visible on active tab, on hover for others). */}
{file.isDirty && ( - + )} {!isPinned && ( - + )}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx new file mode 100644 index 00000000000..fb135b92c9e --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -0,0 +1,56 @@ +import { X } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' + +export function EditorFileTabCloseButton({ + fileIsDirty, + showsSelectionChrome, + onClose +}: { + fileIsDirty: boolean + showsSelectionChrome: boolean + onClose: () => void +}): React.JSX.Element { + const closeShortcut = useShortcutKeyDetails('tab.close') + + return ( + + + + + + + {translate('auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', 'Close tab')} + + {closeShortcut.keys.length > 0 && ( + + )} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 934d93ecaa6..8167bdbda5c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W')) +const shortcutLabelMock = vi.hoisted(() => vi.fn()) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: { children?: unknown }) { @@ -63,10 +63,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed -// label so the test asserts the shortcut is surfaced, not its platform glyphs. +// Why: the menu reads live shortcut bindings; stub them to fixed labels so +// the test asserts each assigned action surfaces its own shortcut chip. vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: shortcutLabelMock + useOptionalShortcutLabel: shortcutLabelMock })) const useAppStoreMock = Object.assign( @@ -208,10 +208,23 @@ async function renderMenu(): Promise { }) } +function assignedShortcutLabel(actionId: string): string | null { + switch (actionId) { + case 'tab.rename': + return '⌘R' + case 'tab.close': + return '⌘W' + case 'tab.closeAll': + return '⌘⌥W' + default: + return null + } +} + describe('EditorFileTabContextMenu close-all shortcut', () => { beforeEach(() => { vi.resetModules() - shortcutLabelMock.mockReturnValue('⌘⌥W') + shortcutLabelMock.mockImplementation(assignedShortcutLabel) vi.stubGlobal('navigator', { userAgent: 'Mac' }) }) @@ -219,26 +232,37 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) - it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => { + it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => { const tree = expandNode(await renderMenu()) + const menuItems = findElementsByType(tree, 'DropdownMenuItem') - const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + const renameItem = menuItems.find((item) => extractText(item.props.children).includes('Rename')) + const closeItem = menuItems.find((item) => extractText(item.props.children) === 'Close⌘W') + const closeAllItem = menuItems.find((item) => extractText(item.props.children).includes('Close All Editor Tabs') ) + expect(renameItem).toBeTruthy() + expect(closeItem).toBeTruthy() expect(closeAllItem).toBeTruthy() - const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut') - expect(shortcut).toHaveLength(1) - expect(extractText(shortcut[0].props.children)).toBe('⌘⌥W') + const shortcutExpectations: [ReactElementLike | undefined, string][] = [ + [renameItem, '⌘R'], + [closeItem, '⌘W'], + [closeAllItem, '⌘⌥W'] + ] - // Why: the shortcut hint is exclusive to Close All; sibling items (Close, - // Close Tabs To The Right) must not sprout their own chips. - expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1) + for (const [item, expectedLabel] of shortcutExpectations) { + const shortcut = findElementsByType(item, 'DropdownMenuShortcut') + expect(shortcut).toHaveLength(1) + expect(extractText(shortcut[0].props.children)).toBe(expectedLabel) + } + + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) it('hides the shortcut chip when close-all is unassigned', async () => { - shortcutLabelMock.mockReturnValue('Unassigned') + shortcutLabelMock.mockReturnValue(null) const tree = expandNode(await renderMenu()) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 95943a77156..184c05d2d97 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' @@ -81,8 +81,9 @@ export function EditorFileTabContextMenu({ onCloseToRight, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { - const closeAllShortcut = useShortcutLabel('tab.closeAll') - const showCloseAllShortcut = closeAllShortcut !== 'Unassigned' + const renameShortcut = useOptionalShortcutLabel('tab.rename') + const closeShortcut = useOptionalShortcutLabel('tab.close') + const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll') return ( @@ -116,6 +117,7 @@ export function EditorFileTabContextMenu({ > {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} + {renameShortcut ? {renameShortcut} : null} @@ -128,13 +130,14 @@ export function EditorFileTabContextMenu({ !isPinned && onClose()} disabled={isPinned}> {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} + {closeShortcut ? {closeShortcut} : null} {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' )} - {showCloseAllShortcut ? ( + {closeAllShortcut ? ( {closeAllShortcut} ) : null} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 91d25847e87..3c32fdd7c99 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,5 +1,156 @@ -import { describe, expect, it } from 'vitest' -import { shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' + +const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted( + () => ({ + shortcutLabelMock: vi.fn<() => string | null>(), + storeState: { + settings: { + defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, + disabledTuiAgents: [] as string[] + }, + worktreesByRepo: {}, + repos: [], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + openSettingsPageMock: vi.fn(), + openSettingsTargetMock: vi.fn() + }) +) + +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] }) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock +})) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) + + return { useAppStore } +}) + +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + getAgentCatalog: () => [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'gemini', label: 'Gemini' } + ], + AgentIcon: ({ agent }: { agent: string }) => ReactActual.createElement('span', null, agent) + } +}) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) => + ReactActual.createElement('div', props, children), + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement('span', { 'data-dropdown-shortcut': 'true' }, children) + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: vi.fn() +})) + +function renderAgentMenuItems(): string { + return renderToStaticMarkup( + React.createElement(QuickLaunchAgentMenuItems, { + worktreeId: 'worktree-1', + groupId: 'group-1', + onFocusTerminal: vi.fn() + }) + ) +} + +function rowMarkup(html: string, label: string): string { + const start = html.indexOf(`title="Launch ${label} in a new terminal"`) + expect(start).toBeGreaterThanOrEqual(0) + const end = html.indexOf('
', start) + expect(end).toBeGreaterThan(start) + + return html.slice(start, end) +} + +beforeEach(() => { + shortcutLabelMock.mockReset() + shortcutLabelMock.mockReturnValue(null) + openSettingsPageMock.mockReset() + openSettingsTargetMock.mockReset() + storeState.settings.defaultTuiAgent = 'codex' + storeState.settings.disabledTuiAgents = [] + storeState.worktreesByRepo = {} + storeState.repos = [] + storeState.openSettingsPage = openSettingsPageMock + storeState.openSettingsTarget = openSettingsTargetMock +}) + +describe('QuickLaunchAgentMenuItems', () => { + it('renders the new-agent shortcut next to the configured default agent only', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + const html = renderAgentMenuItems() + + expect(html.match(/data-dropdown-shortcut="true"/g) ?? []).toHaveLength(1) + expect(rowMarkup(html, 'Codex')).toContain('⌘⌥T') + expect(rowMarkup(html, 'Claude')).not.toContain('⌘⌥T') + expect(rowMarkup(html, 'Gemini')).not.toContain('⌘⌥T') + }) + + it('hides the default-agent shortcut when the action is unbound', () => { + shortcutLabelMock.mockReturnValue(null) + + const html = renderAgentMenuItems() + + expect(html).not.toContain('data-dropdown-shortcut="true"') + }) + + it('does not label an auto-picked or blank default as configured', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + storeState.settings.defaultTuiAgent = null + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + + storeState.settings.defaultTuiAgent = 'blank' + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + }) +}) describe('shouldShowLaunchWatchdogTimeout', () => { it('does not report slow agent readiness once a PTY exists', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 5e4b0f9ce57..84681bbc8cc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react' import { Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -197,6 +199,8 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent + const showsDefaultAgentShortcut = + newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( - {label} + {label} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} ) })} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index b47ec987e66..b22e1912d9e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -106,7 +106,9 @@ vi.mock('lucide-react', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘⇧\\' + formatShortcutLabel: () => '⌘⇧\\', + useOptionalShortcutLabel: () => '⌘W', + useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false }) })) vi.mock('@/components/ui/dropdown-menu', () => ({ @@ -119,12 +121,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { return { type: 'DropdownMenuItem', props } }, - DropdownMenuSeparator: function DropdownMenuSeparator() { - return { type: 'DropdownMenuSeparator', props: {} } - }, DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { return { type: 'DropdownMenuLabel', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 80b625f6885..41426802753 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' type SortableTabProps = { tab: TerminalTab @@ -207,6 +209,7 @@ export default function SortableTab({ disabled: isEditing }) const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed) + const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = (
)} {!isEditing && !isPinned && ( - + + + + + + {translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')} + {closeShortcut.keys.length > 0 && ( + + )} + + )}
) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 391eb8ddb7c..71b067fa898 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -18,7 +18,8 @@ const storeMock = vi.hoisted(() => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘D' + formatShortcutLabel: () => '⌘D', + useOptionalShortcutLabel: () => '⌘D' })) vi.mock('@/components/ui/dropdown-menu', () => ({ diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 2997ab2a29c..291deb92466 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' -import { formatShortcutLabel } from '@/hooks/useShortcutLabel' +import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' @@ -126,6 +126,8 @@ export function SortableTabContextMenu({ } requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) } + const closeShortcut = useOptionalShortcutLabel('tab.close') + const renameShortcut = useOptionalShortcutLabel('tab.rename') return ( @@ -165,6 +167,7 @@ export function SortableTabContextMenu({ !isPinned && onClose(tab.id)} disabled={isPinned}> {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} + {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} @@ -178,6 +181,7 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} + {renameShortcut ? {renameShortcut} : null}
diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index fd5d8ca9a90..9e313bccd55 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -465,7 +465,7 @@ describe('TabBar context menu wiring', () => { ) expect(menuLabels[0]).toContain('New Markdown') - expect(menuLabels[1]).toBe('Open Markdown...') + expect(menuLabels[1]).toContain('Open Markdown...') expect(menuLabels[2]).toContain('New Terminal') expect(menuLabels[3]).toContain('New Browser Tab') }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index a6ecdc5917b..f61ff4aff33 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -51,7 +51,7 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, WINDOWS_GIT_BASH_SHELL @@ -272,6 +272,7 @@ function TabBarInner({ const newBrowserShortcut = useShortcutLabel('tab.newBrowser') const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') const newFileShortcut = useShortcutLabel('tab.newMarkdown') + const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) const persistedUIReady = useAppStore((s) => s.persistedUIReady) @@ -761,6 +762,9 @@ function TabBarInner({ > {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + {openMarkdownShortcut ? ( + {openMarkdownShortcut} + ) : null} ) : null const mobileEmulatorIntroMenuBlock = @@ -1045,7 +1049,7 @@ function TabBarInner({ // a different box than the tab's own `border-t`, producing a // heavier-looking L-corner at the leftmost tab when inactive. className={[ - 'terminal-tab-strip flex h-full min-w-0 max-w-full flex-1 items-stretch overflow-x-auto overflow-y-hidden border-r border-border', + 'terminal-tab-strip flex h-full min-w-0 max-w-full flex-1 items-stretch overflow-x-auto overflow-y-hidden border-r border-border/70', getTabStripScrollMaskClassName(tabStripOverflowState) ] .filter(Boolean) diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index f6fa605e219..920470534ad 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -173,6 +173,19 @@ function firstOpeningTag(markup: string): string { return match[0] } +function textSpanHtml(markup: string, text: string): string { + const textIndex = markup.indexOf(`>${text}`) + expect(textIndex).toBeGreaterThanOrEqual(0) + + const tagStart = markup.lastIndexOf('', textIndex) + expect(spanEnd).toBeGreaterThan(textIndex) + + return markup.slice(tagStart, spanEnd + ''.length) +} + function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' @@ -357,6 +370,7 @@ describe('tab title tooltips', () => { expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') expect(root).toContain('data-tab-id="editor-tab-1"') + expect(textSpanHtml(markup, 'VeryLongEditorFileName.tsx')).not.toContain('renamed') expectTabContainerWidth(markup, root) }) }) diff --git a/src/renderer/src/components/task-page-github-work-item-status-badge.tsx b/src/renderer/src/components/task-page-github-work-item-status-badge.tsx new file mode 100644 index 00000000000..1932170e352 --- /dev/null +++ b/src/renderer/src/components/task-page-github-work-item-status-badge.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { + getTaskPageGitHubWorkItemStateLabel, + getTaskPageGitHubWorkItemStateTone, + type GitHubWorkItemStatusItem +} from '@/components/task-page-github-work-item-status' + +export function TaskPageGitHubWorkItemStateBadge({ + item, + className +}: { + item: GitHubWorkItemStatusItem + className?: string +}): React.JSX.Element { + return ( + + {getTaskPageGitHubWorkItemStateLabel(item)} + + ) +} diff --git a/src/renderer/src/components/task-page-github-work-item-status.test.ts b/src/renderer/src/components/task-page-github-work-item-status.test.ts new file mode 100644 index 00000000000..62ced71280d --- /dev/null +++ b/src/renderer/src/components/task-page-github-work-item-status.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getTaskPageGitHubPRIconTone, + getTaskPageGitHubWorkItemStateLabel, + getTaskPageGitHubWorkItemStateTone, + isTaskPageGitHubDraftPR +} from './task-page-github-work-item-status' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('task-page-github-work-item-status', () => { + it('maps PR states to labels', () => { + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'draft' })).toBe('Draft') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'open' })).toBe('Open') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'closed' })).toBe('Closed') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'merged' })).toBe('Merged') + }) + + it('maps issue states to labels', () => { + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'open' })).toBe('Open') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'closed' })).toBe('Closed') + }) + + it('uses GitHub-like neutral draft tones and muted draft icons', () => { + const draftTone = getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'draft' }) + expect(draftTone).toContain('muted') + expect(draftTone).not.toContain('amber') + expect(getTaskPageGitHubPRIconTone({ type: 'pr', state: 'draft' })).toContain('muted') + expect(getTaskPageGitHubPRIconTone({ type: 'pr', state: 'open' })).toContain('emerald') + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'draft' })).toBe(true) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'open' })).toBe(false) + }) + + it('uses distinct tones for merged and open PRs', () => { + expect(getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'merged' })).toContain('purple') + expect(getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'open' })).toContain('emerald') + }) + + it('handles edge cases gracefully', () => { + // Issues don't have draft state - should fallback to open styling + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'open' })).toBe('Open') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'closed' })).toBe('Closed') + + // Unknown state should fallback to 'Open' for PRs + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'unknown' as 'open' })).toBe( + 'Open' + ) + + // Icon tones for issues should always be muted + expect(getTaskPageGitHubPRIconTone({ type: 'issue', state: 'open' })).toContain( + 'muted-foreground' + ) + expect(getTaskPageGitHubPRIconTone({ type: 'issue', state: 'closed' })).toContain( + 'muted-foreground' + ) + + // Draft PR check + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'draft' })).toBe(true) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'open' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'merged' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'closed' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'issue', state: 'open' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'issue', state: 'closed' })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts new file mode 100644 index 00000000000..a8028e6a43b --- /dev/null +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -0,0 +1,72 @@ +import { translate } from '@/i18n/i18n' +import type { GitHubWorkItem } from '../../../shared/types' + +export type GitHubWorkItemStatusItem = Pick + +export function getTaskPageGitHubWorkItemStateLabel(item: GitHubWorkItemStatusItem): string { + if (item.type === 'pr') { + if (item.state === 'merged') { + return translate('auto.components.github.pr.merge.state.83ecdbb4a6', 'Merged') + } + if (item.state === 'draft') { + return translate('auto.components.TaskPage.054bf695cc', 'Draft') + } + if (item.state === 'closed') { + return translate('auto.components.TaskPage.d09bf34db7', 'Closed') + } + return translate('auto.components.TaskPage.606a85c774', 'Open') + } + + return item.state === 'closed' + ? translate('auto.components.TaskPage.d09bf34db7', 'Closed') + : translate('auto.components.TaskPage.606a85c774', 'Open') +} + +// Why: mirror GitHub Primer StateLabel tones — draft is neutral gray, open is +// green, merged purple, closed red. No custom amber/dashed treatment. +// Note: Draft uses a different pattern (muted-foreground bg on border bg) because +// it's a non-actionable "meta" state, while other states use solid color fills. +export function getTaskPageGitHubWorkItemStateTone(item: GitHubWorkItemStatusItem): string { + if (item.type === 'pr') { + if (item.state === 'merged') { + return 'border-purple-600/20 bg-purple-600 text-purple-50 dark:border-purple-400/20 dark:bg-purple-500 dark:text-white' + } + if (item.state === 'draft') { + return 'border-border/60 bg-muted-foreground/70 text-background dark:bg-muted-foreground/60 dark:text-foreground' + } + if (item.state === 'closed') { + return 'border-rose-600/20 bg-rose-600 text-rose-50 dark:border-rose-400/20 dark:bg-rose-500 dark:text-white' + } + return 'border-emerald-600/20 bg-emerald-600 text-emerald-50 dark:border-emerald-400/20 dark:bg-emerald-500 dark:text-white' + } + + if (item.state === 'closed') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + } + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' +} + +export function isTaskPageGitHubDraftPR(item: GitHubWorkItemStatusItem): boolean { + return item.type === 'pr' && item.state === 'draft' +} + +export function getTaskPageGitHubPRIconTone(item: GitHubWorkItemStatusItem): string { + if (item.type !== 'pr') { + return 'text-muted-foreground' + } + + // Exhaustive switch ensures TypeScript will error if new states are added + switch (item.state) { + case 'draft': + return 'text-muted-foreground' + case 'open': + return 'text-emerald-600 dark:text-emerald-400' + case 'merged': + return 'text-purple-600 dark:text-purple-300' + case 'closed': + return 'text-rose-600 dark:text-rose-300' + default: + // Fallback for any unexpected state + return 'text-muted-foreground' + } +} diff --git a/src/renderer/src/components/task-page-jira-load-state.test.ts b/src/renderer/src/components/task-page-jira-load-state.test.ts new file mode 100644 index 00000000000..a5a151386c0 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-load-state.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { createTaskPageJiraLoadFailureState } from './task-page-jira-load-state' + +describe('TaskPage Jira load state', () => { + it('explains Jira forbidden errors while clearing stale issues', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Forbidden'))).toEqual({ + issues: [], + error: { + title: + 'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.', + details: 'Forbidden' + } + }) + }) + + it('keeps raw provider detail separate from the Jira status summary', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Error 403: XSRF check failed'))).toEqual({ + issues: [], + error: { + title: + 'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.', + details: 'XSRF check failed' + } + }) + }) + + it('explains malformed JQL errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Malformed JQL'))).toEqual({ + issues: [], + error: { + title: "Jira couldn't run this JQL query. Check the syntax and try again.", + details: 'Malformed JQL' + } + }) + }) + + it('explains network errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Network request failed'))).toEqual({ + issues: [], + error: { + title: "Couldn't reach Jira. Check your connection and try again.", + details: 'Network request failed' + } + }) + }) + + it('explains Jira server errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Service Unavailable'))).toEqual({ + issues: [], + error: { + title: 'Error 503: Jira had a server error while loading issues. Try again in a moment.', + details: 'Service Unavailable' + } + }) + }) + + it('uses the generic load error for non-Error rejections', () => { + expect(createTaskPageJiraLoadFailureState('failed')).toEqual({ + issues: [], + error: { + title: "Couldn't load Jira issues. Try again in a moment.", + details: 'Failed to load Jira issues.' + } + }) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-load-state.ts b/src/renderer/src/components/task-page-jira-load-state.ts new file mode 100644 index 00000000000..77826b78126 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-load-state.ts @@ -0,0 +1,76 @@ +import type { JiraIssue } from '../../../shared/types' + +export type TaskPageJiraLoadError = { + title: string + details: string | null +} + +export type TaskPageJiraLoadFailureState = { + issues: JiraIssue[] + error: TaskPageJiraLoadError +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to load Jira issues.' +} + +function getErrorCode(message: string): number | null { + const explicit = /^Error\s+(\d{3})\b/i.exec(message)?.[1] + if (explicit) { + return Number(explicit) + } + if (/\bforbidden\b/i.test(message)) { + return 403 + } + if (/\bunauthorized\b|\bunauthenticated\b/i.test(message)) { + return 401 + } + if (/\btoo many requests\b|\brate limit\b/i.test(message)) { + return 429 + } + if (/\bservice unavailable\b/i.test(message)) { + return 503 + } + return null +} + +function getErrorDetails(message: string, code: number | null): string | null { + const normalized = + code === null ? message : message.replace(new RegExp(`^Error\\s+${code}:\\s*`, 'i'), '') + return normalized.trim() || null +} + +function getIssueSearchErrorSummary(message: string, code: number | null): string { + if (code === 401) { + return 'Jira authentication failed. Reconnect Jira in Settings, then try again.' + } + if (code === 403) { + return 'Jira denied access to this issue search. Check project permissions or try a different JQL query.' + } + if (code === 429) { + return 'Jira rate-limited this issue search. Try again in a moment.' + } + if (code !== null && code >= 500) { + return 'Jira had a server error while loading issues. Try again in a moment.' + } + if (/\bjql\b|\bsyntax\b/i.test(message)) { + return "Jira couldn't run this JQL query. Check the syntax and try again." + } + if (/\bnetwork\b|\bfetch failed\b|\btimed? ?out\b|\beconn/i.test(message)) { + return "Couldn't reach Jira. Check your connection and try again." + } + return "Couldn't load Jira issues. Try again in a moment." +} + +export function createTaskPageJiraLoadFailureState(error: unknown): TaskPageJiraLoadFailureState { + const message = getErrorMessage(error) + const code = getErrorCode(message) + const summary = getIssueSearchErrorSummary(message, code) + return { + issues: [], + error: { + title: code === null ? summary : `Error ${code}: ${summary}`, + details: getErrorDetails(message, code) + } + } +} diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts index a694b99c44c..1b1d98786a7 100644 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -21,10 +21,11 @@ describe('TaskPage source switching host boundary', () => { ) const modalSection = sourceBetween( TASK_PAGE_SOURCE, - '', + '', ' useAppStore.getState().pendingStartupByTabId[tabId]) + const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState( + () => startup !== undefined && !isVisible + ) const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState>( () => new Set() ) - const shouldMeasureHiddenStartup = startup !== undefined && !isVisible const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand) const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId]) const consumeTabSetupSplit = useAppStore((store) => store.consumeTabSetupSplit) @@ -502,6 +506,14 @@ export default function TerminalPane({ } }, [startup, tabId, consumeTabStartupCommand]) + useLayoutEffect(() => { + if (isVisible && shouldMeasureHiddenStartup) { + // Why: hidden startup measurement is only for first launch. Keeping it + // after first visibility lets inactive agent tabs refit and SIGWINCH. + setShouldMeasureHiddenStartup(false) + } + }, [isVisible, shouldMeasureHiddenStartup]) + const clearSessionRestoredBannerForPane = useCallback((paneId: number): void => { setSessionRestoredBannerPaneIds((prev) => { const next = removeSessionRestoredBannerPaneId(prev, paneId) @@ -1318,6 +1330,7 @@ export default function TerminalPane({ cwd, isActive, isVisible, + isWorktreeActive, // Why: hidden startup probes are opacity-hidden but measurable; ordinary // hidden tabs are display:none and refit on visibility resume instead. isSyncFitEnabled: isVisible || shouldMeasureHiddenStartup, @@ -1765,7 +1778,10 @@ export default function TerminalPane({ sessionRestoredBannerPaneIds, reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup) }) - if (needsFit) { + if (needsFit && (isVisible || shouldMeasureHiddenStartup)) { + // Why: inactive terminal tabs can drop active-only header reservation. + // Fitting that hidden geometry changes PTY rows and wakes TUIs with + // SIGWINCH; the visible resume path owns any real layout correction. fitPanes(manager) } }, [ diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 4bcf72a9350..3c13648f6dd 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -26,6 +26,8 @@ const HAS_CSS_ANCHOR_POSITIONING = CSS.supports('position-anchor', '--orca-terminal-overlay-probe') && CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') && CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)') +const MIN_OVERLAY_FIT_WIDTH_PX = 48 +const MIN_OVERLAY_FIT_HEIGHT_PX = 24 function shouldUseCssAnchorPositioning(): boolean { return ( @@ -47,6 +49,7 @@ type TerminalOverlaySlotProps = { worktreeId: string worktreePath: string groupId: string | undefined + isWorktreeActive: boolean isVisible: boolean isActive: boolean activityTerminalPortal: ActivityTerminalPortalTarget | null @@ -62,6 +65,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ worktreeId, worktreePath, groupId, + isWorktreeActive, isVisible, isActive, activityTerminalPortal, @@ -75,9 +79,14 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ const [measuredFallbackRect, setMeasuredFallbackRect] = useState( null ) - const [shouldMeasureHiddenStartup] = useState( + const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState( () => useAppStore.getState().pendingStartupByTabId[terminalTabId] !== undefined ) + useLayoutEffect(() => { + if (isVisible && shouldMeasureHiddenStartup) { + setShouldMeasureHiddenStartup(false) + } + }, [isVisible, shouldMeasureHiddenStartup]) useLayoutEffect(() => { if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) { return @@ -128,21 +137,37 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ }, [anchorName, groupId, isVisible]) useLayoutEffect(() => { - if (!isVisible || !anchorName || shouldUseCssAnchorPositioning()) { + if (!isVisible || !anchorName) { return } - // Why: worktree switches resume visibility before fallback positioning - // settles. Re-fit on show and again after the measured rect lands so the - // PTY never stays pinned at a stale ~2-col width. - const frameId = requestAnimationFrame(() => { + const dispatchFitIfMeasurable = (): void => { + const rect = overlayRef.current?.getBoundingClientRect() + if ( + !rect || + rect.width < MIN_OVERLAY_FIT_WIDTH_PX || + rect.height < MIN_OVERLAY_FIT_HEIGHT_PX + ) { + return + } window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + } + + // Why: tab switches can resume visibility before anchor/fallback geometry + // settles. Re-fit only after the overlay has real dimensions so the PTY + // never stays pinned at a stale ~2-col width. + const frameId = requestAnimationFrame(() => { + dispatchFitIfMeasurable() }) const retryId = window.setTimeout(() => { - window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + dispatchFitIfMeasurable() }, 50) + const settledRetryId = window.setTimeout(() => { + dispatchFitIfMeasurable() + }, 150) return () => { cancelAnimationFrame(frameId) window.clearTimeout(retryId) + window.clearTimeout(settledRetryId) } }, [anchorName, isVisible, measuredFallbackRect]) @@ -202,6 +227,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ // TerminalPane mounted here preserves alt-screen TUI state while this // flag still lets hidden tabs throttle rendering. isVisible={isVisible || activityTerminalPortal !== null} + isWorktreeActive={isWorktreeActive || activityTerminalPortal !== null} isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => { if (consumeSuppressedPtyExit(ptyId)) { @@ -332,6 +358,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId={worktreeId} worktreePath={worktreePath} groupId={assignment?.groupId} + isWorktreeActive={isWorktreeActive} isVisible={isVisible} isActive={isActive} activityTerminalPortal={activityTerminalPortal} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index af5821fdb1e..da2705e5288 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4057,6 +4057,60 @@ describe('connectPanePty', () => { expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) }) + it('clears launch config when an agent startup spawn produces no PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchConfig, + launchToken: 'launch-token-1', + launchAgent: 'codex' + } + }) as never + ) + try { + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { + agentType: 'codex', + launchToken: 'launch-token-1', + tabId: 'tab-1', + leafId: LEAF_1 + }) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + + const registerOrder = mockStoreState.registerAgentLaunchConfig.mock.invocationCallOrder[0] + const clearOrder = mockStoreState.clearAgentLaunchConfig.mock.invocationCallOrder[0] + expect(registerOrder).toBeDefined() + expect(clearOrder).toBeDefined() + expect(registerOrder!).toBeLessThan(clearOrder!) + } finally { + binding.dispose() + } + }) + it('prefers live-entry launch config for pane cold restore when status survived PTY loss', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') @@ -4732,6 +4786,48 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden Grok telemetry startup output parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { + command: 'wrapped-agent', + telemetry: { + agent_kind: 'grok', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('keeps hidden bare Codex startup commands parsing briefly', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -4767,6 +4863,41 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden bare Grok startup commands parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: '/Users/me/.grok/bin/grok --permission-mode bypassPermissions' } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('skips arbitrary hidden startup output parsing', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -6025,6 +6156,7 @@ describe('connectPanePty', () => { const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< typeof vi.fn > + const signalPty = window.api.pty.signal as unknown as ReturnType const firstSnapshot = createDeferred<{ data: string cols: number @@ -6065,10 +6197,12 @@ describe('connectPanePty', () => { seq: hidden.length + visibleLive.length + hiddenAgain.length, rawLength: hiddenAgain.length }) + transport.resize.mockClear() + signalPty.mockClear() firstSnapshot.resolve({ data: 'snapshot-before-hidden-again\r\n', - cols: 120, - rows: 40, + cols: 100, + rows: 30, seq: hidden.length + visibleLive.length }) await flushAsyncTicks(20) @@ -6079,6 +6213,8 @@ describe('connectPanePty', () => { expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenAgain, expect.any(Function)) + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') ;(deps.isVisibleRef as { current: boolean }).current = true visibilityState = 'visible' @@ -6139,6 +6275,84 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const signalPty = window.api.pty.signal as unknown as ReturnType + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 120, + rows: 40, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + transport.resize.mockClear() + signalPty.mockClear() + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) + expect(transport.resize).not.toHaveBeenCalledWith(120, 40) + expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') + disposable.dispose() + }) + + it('does not forward terminal resizes while the pane is hidden', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + + const disposable = connectPanePty(pane as never, manager as never, deps as never) + const onResizeMock = pane.terminal.onResize as unknown as { + mock: { calls: [[(event: { cols: number; rows: number }) => void] | []] } + } + const resizeHandler = onResizeMock.mock.calls[0]?.[0] + if (!resizeHandler) { + throw new Error('Expected terminal resize handler to be registered') + } + + transport.resize.mockClear() + resizeHandler({ cols: 121, rows: 41 }) + + expect(transport.resize).not.toHaveBeenCalled() + + isVisibleRef.current = true + resizeHandler({ cols: 122, rows: 42 }) + + expect(transport.resize).toHaveBeenCalledWith(122, 42) + disposable.dispose() + }) + it('writes foreground chunks that are newer than the restored main snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -6383,6 +6597,54 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('coalesces remote replay payloads that overlap before parsing starts', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const pendingParses: (() => void)[] = [] + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + if (callback) { + pendingParses.push(callback) + } + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('first replay') + capturedReplayCallback.current?.('second replay') + await flushAsyncTicks(2) + + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenNthCalledWith( + 1, + '\x1b[2J\x1b[3J\x1b[H', + expect.any(Function) + ) + + for (let index = 0; index < 8; index += 1) { + await flushAsyncTicks(2) + pendingParses.shift()?.() + } + await flushAsyncTicks(4) + + expect(pane.terminal.write).not.toHaveBeenCalledWith('first replay', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('second replay', expect.any(Function)) + expect(manager.rebuildPaneWebgl).toHaveBeenCalledTimes(1) + disposable.dispose() + }) + it('does not switch renderers for Arabic output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 70d9edcc364..4133374fa2d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -84,7 +84,7 @@ import { import { executeTerminalStartupCommandPaste } from './terminal-startup-command-paste' import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' -import { isCodexTerminalStartupCommand } from './terminal-startup-command-classifier' +import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' @@ -316,8 +316,8 @@ function shouldKeepHiddenStartupRendererQueriesLive( startup: PtyConnectionDeps['startup'] ): boolean { return ( - startup?.telemetry?.agent_kind === 'codex' || - isCodexTerminalStartupCommand(startup?.command ?? '') + Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || + isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') ) } @@ -813,6 +813,7 @@ export function connectPanePty( let pendingTerminalBellNotification = false let reattachIdleAgentCursorResetTimer: ReturnType | null = null let synchronizedForegroundOutputActive = false + let suppressSnapshotReplayPtyResize = false // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -940,6 +941,9 @@ export function connectPanePty( leafId: pane.leafId }) } + const clearRegisteredStartupLaunchConfig = (): void => { + useAppStore.getState().clearAgentLaunchConfig(cacheKey) + } const pendingSpawnKey = cacheKey const neutralTerminalTitle = (): string => { const state = useAppStore.getState() @@ -1956,7 +1960,19 @@ export function connectPanePty( ) } + const isRendererPtyResizeAuthoritative = (): boolean => { + if (deps.isVisibleRef.current) { + return true + } + // Why: hidden-tab layout churn is not authoritative; visible resume + // owns correction, and hidden SIGWINCH can reset full-screen TUIs. + return false + } + const forwardPtyResize = (cols: number, rows: number): void => { + if (!isRendererPtyResizeAuthoritative()) { + return + } // Why: when a mobile-fit override is active OR mobile is currently the // driver of this PTY, the PTY is already at phone dims and any desktop // resize is wrong. Suppress resize forwarding to avoid spurious SIGWINCH @@ -1982,6 +1998,12 @@ export function connectPanePty( pane.container.addEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => { + if (suppressSnapshotReplayPtyResize) { + return + } + if (!isRendererPtyResizeAuthoritative()) { + return + } if (shouldSuppressDesktopPtyResize()) { return } @@ -2411,6 +2433,14 @@ export function connectPanePty( } if (resolvedPtyId) { clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + } else if ( + paneStartup?.launchConfig || + (startupOverride && 'launchConfig' in startupOverride) + ) { + // Why: delayed draft/follow-up delivery keys off this launch + // registry. If spawn produced no PTY, the launch is no longer a + // viable delivery target and must not wait for a future pane. + clearRegisteredStartupLaunchConfig() } const gen = await preSignalPromise if (typeof gen === 'number' && resolvedPtyId) { @@ -2427,6 +2457,9 @@ export function connectPanePty( return resolvedPtyId }) .catch(async () => { + if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) { + clearRegisteredStartupLaunchConfig() + } const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -2491,8 +2524,13 @@ export function connectPanePty( return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) } - const replayDataCallback = (data: string): void => { - void (async () => { + let replayWriteQueue = Promise.resolve() + let pendingReplayData: string | null = null + let replayDrainQueued = false + const drainReplayDataQueue = async (): Promise => { + while (pendingReplayData !== null) { + const data = pendingReplayData + pendingReplayData = null // Relay replay buffer holds the last 100 KB of output, which may // overlap with content already rendered in xterm before the // disconnect. Clear first to prevent duplication on SSH reconnect. @@ -2500,13 +2538,30 @@ export function connectPanePty( await writeReplayDataAsync(data) await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) if (disposed) { + pendingReplayData = null return } // Why: remote-runtime snapshots can arrive after WebGL attached to an // empty buffer; rebuilding after replay parses seeds the glyph atlas // from the now-populated xterm state. manager.rebuildPaneWebgl(pane.id) - })() + } + } + const replayDataCallback = (data: string): void => { + pendingReplayData = data + if (replayDrainQueued) { + return + } + replayDrainQueued = true + replayWriteQueue = replayWriteQueue + .catch(() => undefined) + .then(drainReplayDataQueue) + .finally(() => { + replayDrainQueued = false + if (pendingReplayData !== null) { + replayDataCallback(pendingReplayData) + } + }) } type PendingHiddenOutputRestoreChunk = { @@ -3093,17 +3148,27 @@ export function connectPanePty( seq?: number }): void { const scrollState = captureScrollStateForSnapshotReplay() - discardTerminalOutput(pane.terminal) - if ( + const colsBeforeReplay = pane.terminal.cols + const rowsBeforeReplay = pane.terminal.rows + const hasSnapshotDimensions = Number.isFinite(snapshot.cols) && Number.isFinite(snapshot.rows) && snapshot.cols > 0 && - snapshot.rows > 0 && + snapshot.rows > 0 + discardTerminalOutput(pane.terminal) + if ( + hasSnapshotDimensions && (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) ) { // Why: serialized terminal snapshots encode layout at their source // dimensions. Replay at those dimensions first, then fit back below. - pane.terminal.resize(snapshot.cols, snapshot.rows) + // This xterm-only resize must not SIGWINCH the live TUI. + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + suppressSnapshotReplayPtyResize = false + } } writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(snapshot.data) @@ -3113,9 +3178,16 @@ export function connectPanePty( const currentPtyId = transport.getPtyId() if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { safeFit(pane) - transport.resize(pane.terminal.cols, pane.terminal.rows) - if (!isRemoteRuntimePtyId(currentPtyId)) { - window.api.pty.signal(currentPtyId, 'SIGWINCH') + const replayChangedDimensions = hasSnapshotDimensions + ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows + : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay + if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { + transport.resize(pane.terminal.cols, pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild + // their internal scroll viewport to the top on tab return. + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } } scheduleReattachIdleAgentCursorReset() } diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 9f7dc5f23d2..2ec75fc3fac 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -11,7 +11,11 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' -import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' +import { + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity, + resolveTerminalCursorInactiveStyle +} from '@/lib/pane-manager/pane-terminal-options' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' @@ -235,6 +239,12 @@ export function applyTerminalAppearance( pane.terminal.options.fontFamily = buildFontFamily(settings.terminalFontFamily) pane.terminal.options.fontWeight = terminalFontWeights.fontWeight pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold + pane.terminal.options.scrollSensitivity = normalizeTerminalScrollSensitivity( + settings.terminalScrollSensitivity + ) + pane.terminal.options.fastScrollSensitivity = normalizeTerminalFastScrollSensitivity( + settings.terminalFastScrollSensitivity + ) // Why: xterm's macOptionIsMeta only flips on the 'true' mode. 'left' and // 'right' are handled in the keydown policy (terminal-shortcut-policy), // which needs Option to stay composable at the xterm level for the diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index 375abf4d453..ddb842b66f1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -1,7 +1,12 @@ import type { ILink } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearRuntimeCompatibilityCacheForTests, + markRuntimeEnvironmentCompatible +} from '@/runtime/runtime-rpc-client' import { createTerminalHandleLinkProvider, + extractOrchestrationTaskLinks, extractTerminalHandleLinks, findTerminalHandleTarget, focusRendererTerminalHandle @@ -14,6 +19,9 @@ const mocks = vi.hoisted(() => ({ tabsByWorktree: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {}, setActiveWorktree: vi.fn(), markWorktreeVisited: vi.fn(), setActiveView: vi.fn(), @@ -73,7 +81,11 @@ function setPlatform(userAgent: string): void { vi.stubGlobal('navigator', { userAgent }) } -async function collectLinks(rows: TestBufferLine[], bufferLineNumber = 1): Promise { +async function collectLinks( + rows: TestBufferLine[], + bufferLineNumber = 1, + runtimeEnvironmentId: string | null = null +): Promise { const terminal = { buffer: { active: { @@ -84,7 +96,7 @@ async function collectLinks(rows: TestBufferLine[], bufferLineNumber = 1): Promi } const provider = createTerminalHandleLinkProvider({ getTerminal: () => terminal as never, - getRuntimeEnvironmentId: () => null, + getRuntimeEnvironmentId: () => runtimeEnvironmentId, linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement }) return await new Promise((resolve) => { @@ -130,6 +142,26 @@ describe('extractTerminalHandleLinks', () => { }) }) +describe('extractOrchestrationTaskLinks', () => { + it('detects task IDs from orchestration dispatch output', () => { + const line = 'Ran orca orchestration dispatch --task task_88f323f654c0 --to term_worker' + + expect(extractOrchestrationTaskLinks(line)).toEqual([ + { + taskId: 'task_88f323f654c0', + startIndex: 39, + endIndex: 56 + } + ]) + }) + + it('trims sentence punctuation without matching inside longer tokens', () => { + expect(extractOrchestrationTaskLinks('Open task_worker, not xtask_worker.')).toEqual([ + { taskId: 'task_worker', startIndex: 5, endIndex: 16 } + ]) + }) +}) + describe('findTerminalHandleTarget', () => { it('finds split-pane remote runtime handles from leaf PTY mappings', () => { expect( @@ -183,6 +215,9 @@ describe('focusRendererTerminalHandle', () => { mocks.storeState.terminalLayoutsByTabId = { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } } + mocks.storeState.agentStatusByPaneKey = {} + mocks.storeState.retainedAgentsByPaneKey = {} + mocks.storeState.runtimeAgentOrchestrationByPaneKey = {} }) afterEach(() => { @@ -207,13 +242,27 @@ describe('createTerminalHandleLinkProvider', () => { mocks.storeState.tabsByWorktree = {} mocks.storeState.ptyIdsByTabId = {} mocks.storeState.terminalLayoutsByTabId = {} + mocks.storeState.agentStatusByPaneKey = {} + mocks.storeState.retainedAgentsByPaneKey = {} + mocks.storeState.runtimeAgentOrchestrationByPaneKey = {} vi.stubGlobal('window', { api: { runtime: { - call: vi.fn().mockResolvedValue({ - ok: true, - result: { focus: { handle: 'term_worker', tabId: 'tab-1', worktreeId: 'wt-1' } } + call: vi.fn().mockImplementation(({ method }) => { + if (method === 'orchestration.dispatchShow') { + return Promise.resolve({ + ok: true, + result: { dispatch: { assignee_handle: 'term_worker' } } + }) + } + return Promise.resolve({ + ok: true, + result: { focus: { handle: 'term_worker', tabId: 'tab-1', worktreeId: 'wt-1' } } + }) }) + }, + runtimeEnvironments: { + call: vi.fn() } } }) @@ -221,6 +270,7 @@ describe('createTerminalHandleLinkProvider', () => { afterEach(() => { vi.unstubAllGlobals() + clearRuntimeCompatibilityCacheForTests() }) it('provides wrapped terminal handle links and focuses through runtime on activation', async () => { @@ -244,6 +294,133 @@ describe('createTerminalHandleLinkProvider', () => { }) }) + it('provides wrapped task links and focuses their dispatched terminal through runtime', async () => { + const rows = [makeBufferLine('Task: task_work'), makeBufferLine('er', { isWrapped: true })] + const links = await collectLinks(rows, 1) + + expect(links.map((link) => link.text)).toEqual(['task_worker']) + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.runtime.call).toHaveBeenNthCalledWith(1, { + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' } + }) + expect(window.api.runtime.call).toHaveBeenNthCalledWith(2, { + method: 'terminal.focus', + params: { terminal: 'term_worker' } + }) + }) + + it('returns task and terminal links in line order', async () => { + const links = await collectLinks([ + makeBufferLine('Dispatch --task task_worker --to term_worker') + ]) + + expect(links.map((link) => link.text)).toEqual(['task_worker', 'term_worker']) + }) + + it('uses runtime dispatch for task links instead of stale renderer task snapshots', async () => { + mocks.storeState.tabsByWorktree = { + 'wt-1': [ + { + id: 'tab-stale', + worktreeId: 'wt-1', + ptyId: 'term_stale', + title: 'Stale worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.agentStatusByPaneKey = { + 'tab-stale:11111111-1111-4111-8111-111111111111': { + paneKey: 'tab-stale:11111111-1111-4111-8111-111111111111', + tabId: 'tab-stale', + worktreeId: 'wt-1', + orchestration: { + taskId: 'task_worker', + dispatchId: 'ctx_stale' + } + } + } + const links = await collectLinks([makeBufferLine('Task: task_worker')]) + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(window.api.runtime.call).toHaveBeenNthCalledWith(1, { + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' } + }) + expect(window.api.runtime.call).toHaveBeenNthCalledWith(2, { + method: 'terminal.focus', + params: { terminal: 'term_worker' } + }) + }) + + it('focuses task links through their owning runtime environment', async () => { + markRuntimeEnvironmentCompatible('env-1') + window.api.runtimeEnvironments.call = vi.fn().mockImplementation(({ method }) => { + if (method === 'orchestration.dispatchShow') { + return Promise.resolve({ + ok: true, + result: { dispatch: { assignee_handle: 'term_remote' } } + }) + } + return Promise.resolve({ + ok: true, + result: { focus: { handle: 'term_remote', tabId: 'tab-remote', worktreeId: 'wt-remote' } } + }) + }) + const links = await collectLinks([makeBufferLine('Task: task_remote')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(window.api.runtime.call).not.toHaveBeenCalled() + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'orchestration.dispatchShow', + params: { task: 'task_remote' }, + timeoutMs: undefined + }) + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'terminal.focus', + params: { terminal: 'term_remote' }, + timeoutMs: undefined + }) + }) + it('contains runtime focus failures for stale terminal handles', async () => { const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) window.api.runtime.call = vi.fn().mockRejectedValue(new Error('terminal not found')) diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index ec0f30d4245..d692e68b897 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -6,6 +6,14 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' +import { + extractOrchestrationTaskLinks, + focusRuntimeOrchestrationTask, + ORCHESTRATION_TASK_PREFIX +} from './terminal-orchestration-task-links' + +export { extractOrchestrationTaskLinks } from './terminal-orchestration-task-links' +export type { ParsedOrchestrationTaskLink } from './terminal-orchestration-task-links' export type ParsedTerminalHandleLink = { handle: string @@ -35,39 +43,50 @@ const MAX_TERMINAL_HANDLE_BODY_LENGTH = 128 const TERMINAL_HANDLE_BOUNDARY_CHAR = /[A-Za-z0-9_-]/ export function extractTerminalHandleLinks(lineText: string): ParsedTerminalHandleLink[] { - if (!lineText.includes(TERMINAL_HANDLE_PREFIX)) { + return extractPrefixedTokenLinks(lineText, TERMINAL_HANDLE_PREFIX).map((link) => ({ + handle: link.token, + startIndex: link.startIndex, + endIndex: link.endIndex + })) +} + +function extractPrefixedTokenLinks( + lineText: string, + prefix: string +): { token: string; startIndex: number; endIndex: number }[] { + if (!lineText.includes(prefix)) { return [] } - const links: ParsedTerminalHandleLink[] = [] + const links: { token: string; startIndex: number; endIndex: number }[] = [] let searchStart = 0 while (searchStart < lineText.length) { - const startIndex = lineText.indexOf(TERMINAL_HANDLE_PREFIX, searchStart) + const startIndex = lineText.indexOf(prefix, searchStart) if (startIndex === -1) { break } - const bodyStart = startIndex + TERMINAL_HANDLE_PREFIX.length - const tokenEnd = findTerminalHandleTokenEnd(lineText, bodyStart) + const bodyStart = startIndex + prefix.length + const tokenEnd = findPrefixedTokenEnd(lineText, bodyStart) searchStart = Math.max(tokenEnd, bodyStart + 1) const bodyLength = tokenEnd - bodyStart if (bodyLength === 0 || bodyLength > MAX_TERMINAL_HANDLE_BODY_LENGTH) { continue } - const handle = lineText.slice(startIndex, tokenEnd) + const token = lineText.slice(startIndex, tokenEnd) if ( TERMINAL_HANDLE_BOUNDARY_CHAR.test(lineText[startIndex - 1] ?? '') || TERMINAL_HANDLE_BOUNDARY_CHAR.test(lineText[tokenEnd] ?? '') ) { continue } - links.push({ handle, startIndex, endIndex: tokenEnd }) + links.push({ token, startIndex, endIndex: tokenEnd }) } return links } -function findTerminalHandleTokenEnd(lineText: string, startIndex: number): number { +function findPrefixedTokenEnd(lineText: string, startIndex: number): number { let index = startIndex while (index < lineText.length && TERMINAL_HANDLE_BOUNDARY_CHAR.test(lineText[index])) { index += 1 @@ -131,12 +150,29 @@ export function createTerminalHandleLinkProvider( return } const logicalLine = buildWrappedLogicalLine(terminal.buffer.active, bufferLineNumber) - if (!logicalLine || !logicalLine.text.includes('term_')) { + if ( + !logicalLine || + (!logicalLine.text.includes(TERMINAL_HANDLE_PREFIX) && + !logicalLine.text.includes(ORCHESTRATION_TASK_PREFIX)) + ) { callback(undefined) return } - const links = extractTerminalHandleLinks(logicalLine.text) + const terminalLinks = extractTerminalHandleLinks(logicalLine.text).map((parsed) => ({ + kind: 'terminal' as const, + text: parsed.handle, + startIndex: parsed.startIndex, + endIndex: parsed.endIndex + })) + const taskLinks = extractOrchestrationTaskLinks(logicalLine.text).map((parsed) => ({ + kind: 'task' as const, + text: parsed.taskId, + startIndex: parsed.startIndex, + endIndex: parsed.endIndex + })) + const links = [...terminalLinks, ...taskLinks] + .sort((a, b) => a.startIndex - b.startIndex) .map((parsed): ILink | null => { const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex) if (!range) { @@ -144,24 +180,17 @@ export function createTerminalHandleLinkProvider( } return { range, - text: parsed.handle, + text: parsed.text, activate: (event) => { if (!isTerminalHandleLinkActivation(event)) { return } event?.preventDefault() - if (!focusRendererTerminalHandle(parsed.handle)) { - void focusRuntimeTerminalHandle( - parsed.handle, - deps.getRuntimeEnvironmentId() - ).catch((error: unknown) => { - console.warn('[terminal-handle-link] focus failed:', error) - }) - } + void activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) terminal.clearSelection() }, hover: () => { - deps.linkTooltip.textContent = `${parsed.handle} (${getTerminalHandleFocusHint()})` + deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint()})` deps.linkTooltip.style.display = '' }, leave: () => { @@ -176,6 +205,25 @@ export function createTerminalHandleLinkProvider( } } +async function activateParsedLink( + parsed: { kind: 'terminal' | 'task'; text: string }, + runtimeEnvironmentId: string | null +): Promise { + try { + if (parsed.kind === 'terminal') { + if (!focusRendererTerminalHandle(parsed.text)) { + await focusRuntimeTerminalHandle(parsed.text, runtimeEnvironmentId) + } + return + } + // Why: a task can be retried onto a new dispatch; runtime DB is the + // authority for the latest terminal assigned to a stable task ID. + await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId) + } catch (error: unknown) { + console.warn('[terminal-handle-link] focus failed:', error) + } +} + function ptyIdMatchesTerminalHandle(ptyId: string, handle: string): boolean { return ptyId === handle || getRemoteRuntimeTerminalHandle(ptyId) === handle } diff --git a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts new file mode 100644 index 00000000000..99acf41f12c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts @@ -0,0 +1,73 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' + +export type ParsedOrchestrationTaskLink = { + taskId: string + startIndex: number + endIndex: number +} + +export const ORCHESTRATION_TASK_PREFIX = 'task_' + +const MAX_ORCHESTRATION_TASK_BODY_LENGTH = 128 +const ORCHESTRATION_TASK_BOUNDARY_CHAR = /[A-Za-z0-9_-]/ + +export function extractOrchestrationTaskLinks(lineText: string): ParsedOrchestrationTaskLink[] { + if (!lineText.includes(ORCHESTRATION_TASK_PREFIX)) { + return [] + } + + const links: ParsedOrchestrationTaskLink[] = [] + let searchStart = 0 + while (searchStart < lineText.length) { + const startIndex = lineText.indexOf(ORCHESTRATION_TASK_PREFIX, searchStart) + if (startIndex === -1) { + break + } + + const bodyStart = startIndex + ORCHESTRATION_TASK_PREFIX.length + const tokenEnd = findOrchestrationTaskTokenEnd(lineText, bodyStart) + searchStart = Math.max(tokenEnd, bodyStart + 1) + const bodyLength = tokenEnd - bodyStart + if (bodyLength === 0 || bodyLength > MAX_ORCHESTRATION_TASK_BODY_LENGTH) { + continue + } + + const taskId = lineText.slice(startIndex, tokenEnd) + if ( + ORCHESTRATION_TASK_BOUNDARY_CHAR.test(lineText[startIndex - 1] ?? '') || + ORCHESTRATION_TASK_BOUNDARY_CHAR.test(lineText[tokenEnd] ?? '') + ) { + continue + } + links.push({ taskId, startIndex, endIndex: tokenEnd }) + } + return links +} + +export async function focusRuntimeOrchestrationTask( + taskId: string, + runtimeEnvironmentId: string | null +): Promise { + const environmentId = runtimeEnvironmentId?.trim() + const target = environmentId + ? ({ kind: 'environment', environmentId } as const) + : ({ kind: 'local' } as const) + const result = await callRuntimeRpc<{ + dispatch: { assignee_handle?: string | null } | null + }>(target, 'orchestration.dispatchShow', { task: taskId }) + const terminal = result.dispatch?.assignee_handle?.trim() + if (!terminal) { + throw new Error(`No dispatched terminal for orchestration task ${taskId}`) + } + // Why: task IDs are stable orchestration DB records, but terminal.focus owns + // the app-side navigation contract for local and SSH runtime terminals. + await callRuntimeRpc(target, 'terminal.focus', { terminal }) +} + +function findOrchestrationTaskTokenEnd(lineText: string, startIndex: number): number { + let index = startIndex + while (index < lineText.length && ORCHESTRATION_TASK_BOUNDARY_CHAR.test(lineText[index])) { + index += 1 + } + return index +} diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts index c6851a92066..3444bf412d6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getTerminalStartupCommandToken, isCodexTerminalStartupCommand, + isKnownTuiAgentTerminalStartupCommand, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS } from './terminal-startup-command-classifier' @@ -22,6 +23,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('codex') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) @@ -30,6 +32,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('C:\\Program Files\\Orca\\codex.cmd') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) }) it('recognizes POSIX Codex wrapper names', () => { @@ -37,6 +40,18 @@ describe('terminal startup command classifier', () => { expect(isCodexTerminalStartupCommand('/usr/local/bin/not-codex --continue')).toBe(false) }) + it('recognizes non-Codex Orca agent startup commands', () => { + expect(isKnownTuiAgentTerminalStartupCommand('grok --permission-mode bypassPermissions')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/Users/me/.grok/bin/grok --resume abc')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/not-grok --resume abc')).toBe( + false + ) + }) + it('bounds pathological single-token startup commands', () => { const split = vi.spyOn(String.prototype, 'split') const command = 'codex'.repeat(TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) @@ -45,6 +60,7 @@ describe('terminal startup command classifier', () => { TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS ) expect(isCodexTerminalStartupCommand(command)).toBe(false) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(false) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts index 9bfd858185c..f171efc01cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts @@ -1,7 +1,24 @@ +import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' + const TERMINAL_STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i // Why: startup commands can carry pasted scripts; classifier work should stay bounded. export const TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS = 4096 +const KNOWN_TUI_AGENT_EXECUTABLES = new Set() + +for (const config of Object.values(TUI_AGENT_CONFIG)) { + for (const candidate of [ + config.detectCmd, + config.expectedProcess, + ...getTuiAgentDetectCommands(config) + ]) { + const executable = normalizeTerminalStartupCommandExecutableName(candidate) + if (executable) { + KNOWN_TUI_AGENT_EXECUTABLES.add(executable) + } + } +} + export function getTerminalStartupCommandToken(command: string): string { const scanLimit = Math.min(command.length, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) let index = 0 @@ -38,10 +55,23 @@ export function isCodexTerminalStartupCommand(command: string): boolean { return executable === 'codex' || executable.startsWith('codex-') } +export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean { + const executable = getTerminalStartupCommandExecutableName(command) + return ( + KNOWN_TUI_AGENT_EXECUTABLES.has(executable) || + executable.startsWith('codex-') || + executable.startsWith('grok-') + ) +} + function getTerminalStartupCommandExecutableName(command: string): string { const token = getTerminalStartupCommandToken(command) const segmentStart = getTerminalStartupCommandPathSegmentStart(token) - return token.slice(segmentStart).toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') + return normalizeTerminalStartupCommandExecutableName(token.slice(segmentStart)) +} + +function normalizeTerminalStartupCommandExecutableName(executable: string): string { + return executable.toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') } function getTerminalStartupCommandPathSegmentStart(token: string): number { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts new file mode 100644 index 00000000000..87db96c3a22 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -0,0 +1,150 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' +import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' +import { + flushTerminalOutput, + requestTerminalBacklogRecovery +} from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' +import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' + +const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 + +export type TerminalHiddenReason = 'surface' | 'tab' + +type ResumeTerminalVisibilityArgs = { + manager: PaneManager + isActive: boolean + wasVisible: boolean + shouldUseLightTabResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map + withSuppressedScrollTracking: (callback: () => void) => void +} + +type HideTerminalVisibilityArgs = { + manager: PaneManager + wasVisible: boolean + wasWorktreeActive: boolean + isWorktreeActive: boolean + hasCompletedVisibleResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map +} + +type HideTerminalVisibilityResult = { + hiddenReason: TerminalHiddenReason | null + renderingSuspended: boolean +} + +export function resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking +}: ResumeTerminalVisibilityArgs): void { + // Why: WebGL resume can disturb xterm's viewport bookkeeping before the + // post-resume fit runs. Capture numeric viewport positions first; the + // restore path avoids content matching so duplicate agent log lines do + // not jump to the wrong history entry. + const viewportPositions = captureViewportPositions(!wasVisible) + withSuppressedScrollTracking(() => { + if (shouldUseLightTabResume) { + // Why: intra-worktree tab switches only toggle the overlay. Keeping + // synchronous drain and atlas rebuilds off this path avoids racing the + // overlay's delayed geometry fit. Still request hidden-output recovery: + // agent TUIs can suppress hidden bytes until the pane is foregrounded. + requestLightTabBacklogRecovery(manager) + if (isActive) { + focusActivePane(manager) + } + } else { + resumeTerminalVisibilityHeavy(manager, isActive) + } + restoreTerminalViewportPositions(manager, viewportPositions) + if (!shouldUseLightTabResume) { + // Why: this clear wipes the glyph atlas shared with other same-config + // terminals; the global reset rebuilds their render models too. + resetAllTerminalWebglAtlases() + } + }) +} + +export function hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume, + captureViewportPositions +}: HideTerminalVisibilityArgs): HideTerminalVisibilityResult { + const surfaceBecameHidden = wasWorktreeActive && !isWorktreeActive + if (wasVisible) { + // Why: hidden DOM/layout churn can mutate xterm's viewport before the + // pane becomes visible again. Preserve the last visible position. + captureViewportPositions(false) + } + if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) { + // Suspend WebGL when going hidden. xterm.write() continues to land in + // the (now DOM-renderer-fallback or paused-canvas) terminal; the + // suspend is purely a GPU resource decision. + manager.suspendRendering() + return { hiddenReason: 'surface', renderingSuspended: true } + } + if (!hasCompletedVisibleResume && wasVisible && wasWorktreeActive && isWorktreeActive) { + // Why: the visibility hook starts wasVisible=true so terminal tabs that + // first mount hidden still release WebGL contexts instead of exhausting + // Chromium's small context budget. + manager.suspendRendering() + return { hiddenReason: 'tab', renderingSuspended: true } + } + if (wasVisible && isWorktreeActive) { + return { hiddenReason: 'tab', renderingSuspended: false } + } + if (!isWorktreeActive) { + return { hiddenReason: 'surface', renderingSuspended: false } + } + return { hiddenReason: null, renderingSuspended: false } +} + +function requestLightTabBacklogRecovery(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + } +} + +function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): void { + // Why: hidden panes can accumulate large PTY bursts while Chromium is + // occluded. Drain a bounded slice before fitting; the scheduler keeps + // ordering and continues the rest asynchronously so return-to-app does + // not beachball behind an entire backlog. + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) + } + // Resume WebGL immediately so the terminal shows its last-known state + // on the first painted frame. macOS context creation is ~5 ms; on + // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume + // would paint a stretched DOM-fallback flash, which is worse UX. + manager.resumeRendering() + // Single fit on resume. Background bytes have been pushed into xterm + // above, so this fit only absorbs container dimension changes that + // happened while hidden (e.g. sidebar toggle on another worktree). + if (isActive) { + fitAndFocusPanes(manager) + } else { + fitPanes(manager) + } +} + +function restoreTerminalViewportPositions( + manager: PaneManager, + viewportPositions: Map +): void { + for (const pane of manager.getPanes()) { + const position = viewportPositions.get(pane.id) + if (position) { + restoreScrollStateAfterLayout(pane.terminal, position) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 82a185783ec..df9f4ba45fc 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ captureScrollState: vi.fn(), fitAndFocusPanes: vi.fn(), fitPanes: vi.fn(), + focusActivePane: vi.fn(), flushTerminalOutput: vi.fn(), getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), @@ -58,7 +59,8 @@ vi.mock('react', async (importOriginal) => { vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: mocks.fitAndFocusPanes, - fitPanes: mocks.fitPanes + fitPanes: mocks.fitPanes, + focusActivePane: mocks.focusActivePane })) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ @@ -113,6 +115,7 @@ function useMountForFileDrop( cwd?: string isActive?: boolean isVisible?: boolean + isWorktreeActive?: boolean isSyncFitEnabled?: boolean paneCount?: number } = {} @@ -150,6 +153,7 @@ function useMountForFileDrop( cwd: options.cwd, isActive: options.isActive ?? true, isVisible: options.isVisible ?? true, + isWorktreeActive: options.isWorktreeActive ?? options.isVisible ?? true, isSyncFitEnabled: options.isSyncFitEnabled ?? options.isVisible ?? true, paneCount: options.paneCount ?? 0, managerRef: { current: manager as never }, @@ -278,6 +282,257 @@ describe('useTerminalPaneGlobalEffects', () => { expect(isVisibleRef.current).toBe(true) }) + it('uses a light resume for tab switches while the worktree stays active', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + manager.suspendRendering.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('keeps visible active-state updates on the light resume path', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('suspends rendering when a terminal tab first mounts hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.suspendRendering.mockClear() + manager.resumeRendering.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + }) + + it('suspends a tab-hidden terminal when its worktree surface becomes hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + manager.suspendRendering.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: true + }) + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + }) + it('reports the active local PTY to the main output scheduler', () => { const manager = { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 5fc62839abc..23a03049dc1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -8,22 +8,19 @@ import { } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' -import { fitAndFocusPanes, fitPanes } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' -import { - flushTerminalOutput, - requestTerminalBacklogRecovery -} from '@/lib/pane-manager/pane-terminal-output-scheduler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' import { useAppStore } from '@/store' -import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibility-memory' import { useTerminalContainerFitSync } from './use-terminal-container-fit-sync' import { handleTerminalProgrammaticTextPaste } from './terminal-programmatic-text-paste' - -const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 +import { + hideTerminalVisibility, + resumeTerminalVisibility, + type TerminalHiddenReason +} from './terminal-visibility-resume' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -31,6 +28,7 @@ type UseTerminalPaneGlobalEffectsArgs = { cwd?: string isActive: boolean isVisible: boolean + isWorktreeActive?: boolean isSyncFitEnabled: boolean paneCount: number managerRef: React.RefObject @@ -47,6 +45,7 @@ export function useTerminalPaneGlobalEffects({ cwd, isActive, isVisible, + isWorktreeActive = isVisible, isSyncFitEnabled, paneCount, managerRef, @@ -65,6 +64,10 @@ export function useTerminalPaneGlobalEffects({ // otherwise leak WebGL contexts — openTerminal() unconditionally creates // one — and exhaust Chromium's ~8-context budget across worktrees. const wasVisibleRef = useRef(true) + const wasWorktreeActiveRef = useRef(isWorktreeActive) + const hasCompletedVisibleResumeRef = useRef(false) + const renderingSuspendedByVisibilityRef = useRef(false) + const hiddenReasonRef = useRef(null) const { captureViewportPositions, withSuppressedScrollTracking, @@ -83,61 +86,47 @@ export function useTerminalPaneGlobalEffects({ if (!manager) { return } + const wasVisible = wasVisibleRef.current + const wasWorktreeActive = wasWorktreeActiveRef.current isActiveRef.current = isActive isVisibleRef.current = isVisible if (isVisible) { - // Why: WebGL resume can disturb xterm's viewport bookkeeping before the - // post-resume fit runs. Capture numeric viewport positions first; the - // restore path avoids content matching so duplicate agent log lines do - // not jump to the wrong history entry. - const viewportPositions = captureViewportPositions(!wasVisibleRef.current) - withSuppressedScrollTracking(() => { - // Why: hidden panes can accumulate large PTY bursts while Chromium is - // occluded. Drain a bounded slice before fitting; the scheduler keeps - // ordering and continues the rest asynchronously so return-to-app does - // not beachball behind an entire backlog. - for (const pane of manager.getPanes()) { - requestTerminalBacklogRecovery(pane.terminal) - flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) - } - // Resume WebGL immediately so the terminal shows its last-known state - // on the first painted frame. macOS context creation is ~5 ms; on - // Windows (ANGLE → D3D11) it can be 100–500 ms but a deferred resume - // would paint a stretched DOM-fallback flash, which is worse UX. - manager.resumeRendering() - // Single fit on resume. Background bytes have been pushed into xterm - // above, so this fit only absorbs container dimension changes that - // happened while hidden (e.g. sidebar toggle on another worktree). - if (isActive) { - fitAndFocusPanes(manager) - } else { - fitPanes(manager) - } - for (const pane of manager.getPanes()) { - const position = viewportPositions.get(pane.id) - if (position) { - restoreScrollStateAfterLayout(pane.terminal, position) - } - } - // Why: this clear wipes the glyph atlas shared with other same-config - // terminals; the global reset rebuilds their render models too. - resetAllTerminalWebglAtlases() + const shouldUseLightTabResume = + isWorktreeActive && + hasCompletedVisibleResumeRef.current && + !renderingSuspendedByVisibilityRef.current && + (wasVisible || hiddenReasonRef.current === 'tab') + resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking }) + renderingSuspendedByVisibilityRef.current = false wasVisibleRef.current = true + wasWorktreeActiveRef.current = isWorktreeActive + hasCompletedVisibleResumeRef.current = true + hiddenReasonRef.current = null applyPendingFollowOutputRequests() return - } else if (wasVisibleRef.current) { - // Why: hidden DOM/layout churn can mutate xterm's viewport before the - // pane becomes visible again. Preserve the last visible position. - captureViewportPositions(false) - // Suspend WebGL when going hidden. xterm.write() continues to land in - // the (now DOM-renderer-fallback or paused-canvas) terminal; the - // suspend is purely a GPU resource decision. - manager.suspendRendering() + } else { + const hiddenState = hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume: hasCompletedVisibleResumeRef.current, + captureViewportPositions + }) + renderingSuspendedByVisibilityRef.current = hiddenState.renderingSuspended + hiddenReasonRef.current = hiddenState.hiddenReason } wasVisibleRef.current = false + wasWorktreeActiveRef.current = isWorktreeActive // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive, isVisible]) + }, [isActive, isVisible, isWorktreeActive]) useEffect(() => { if (!isVisible) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index a1a92971924..cfd5b6f398a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -4,7 +4,12 @@ import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import { PaneManager } from '@/lib/pane-manager/pane-manager' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' -import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' +import { + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity, + resolveTerminalCursorInactiveStyle +} from '@/lib/pane-manager/pane-terminal-options' +import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { useAppStore } from '@/store' import { @@ -63,6 +68,7 @@ import { connectPanePty } from './pty-connection' import type { PtyTransport } from './pty-transport' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { getConnectionId } from '@/lib/connection-context' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' @@ -1049,7 +1055,8 @@ export function useTerminalPaneLifecycle({ osRelease: platformInfo?.osRelease, connectionId: getConnectionId(worktreeId), cwd: startupCwd, - shellOverride: currentTab?.shellOverride + shellOverride: currentTab?.shellOverride, + executionHostId: getExecutionHostIdForWorktree(storeState, worktreeId) }) return { ...windowsPtyCompatibilityOptions, @@ -1067,11 +1074,19 @@ export function useTerminalPaneLifecycle({ cursorStyle, cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle), cursorBlink: currentSettings?.terminalCursorBlink ?? true, + scrollSensitivity: normalizeTerminalScrollSensitivity( + currentSettings?.terminalScrollSensitivity + ), + fastScrollSensitivity: normalizeTerminalFastScrollSensitivity( + currentSettings?.terminalFastScrollSensitivity + ), macOptionIsMeta: effectiveMacOptionAsAltRef.current === 'true', lineHeight: currentSettings?.terminalLineHeight ?? 1, wordSeparator: currentSettings?.terminalWordSeparator } }, + terminalTuiScrollSensitivity: () => + normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity), onLinkClick: (event, url) => { if (!event) { return diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2cd7249a40e..e04dc73d819 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -9,6 +9,7 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getAgentCatalog } from '@/lib/agent-catalog' +import { createBrowserUuid } from '@/lib/browser-uuid' import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink, @@ -3110,6 +3111,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } : undefined const backendSpawnedStartup = result.startupTerminal?.spawned === true + if (startupPlan && !backendSpawnedStartup && !startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this + // queued startup, so both halves share one renderer-session token. + startupPlan.launchToken = createBrowserUuid() + } const activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', setup: result.setup, @@ -3121,6 +3127,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, + ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: tuiAgent, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx new file mode 100644 index 00000000000..7e9febda32a --- /dev/null +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { useDetectedAgents, type AgentDetectionTarget } from './useDetectedAgents' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' + +const detectRemoteAgents = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const initialAppState = useAppStore.getInitialState() +const roots: Root[] = [] + +function HookProbe({ target }: { target: AgentDetectionTarget }): null { + useDetectedAgents(target) + return null +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderProbe(target: AgentDetectionTarget): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { target })) + }) + await flushEffects() + return root +} + +beforeEach(() => { + useAppStore.setState(initialAppState, true) + detectRemoteAgents.mockReset().mockResolvedValue([]) + runtimeEnvironmentCall.mockReset().mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : [] + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + globalThis.window.api = { + preflight: { detectRemoteAgents }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } as unknown as Window['api'] +}) + +afterEach(async () => { + for (const root of roots) { + await act(async () => { + root.unmount() + }) + } + roots.length = 0 +}) + +describe('useDetectedAgents (ssh call site)', () => { + it('fires remote detection once on mount and does not thrash after an empty result', async () => { + const root = await renderProbe({ kind: 'ssh', connectionId: 'ssh-1' }) + + // The effect fires detection once; an empty [] is stored (not null), so the + // detectedIds===null guard prevents a re-detect loop on the same surface. + expect(detectRemoteAgents).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().remoteDetectedAgentIds['ssh-1']).toEqual([]) + + // Re-rendering the same connection must not trigger another probe. + await act(async () => { + root.render(createElement(HookProbe, { target: { kind: 'ssh', connectionId: 'ssh-1' } })) + }) + await flushEffects() + + expect(detectRemoteAgents).toHaveBeenCalledTimes(1) + }) + + it('retries a cached empty SSH result when the launch surface is reopened', async () => { + const firstRoot = await renderProbe({ kind: 'ssh', connectionId: 'ssh-1' }) + + expect(detectRemoteAgents).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().remoteDetectedAgentIds['ssh-1']).toEqual([]) + + await act(async () => { + firstRoot.unmount() + }) + roots.splice(roots.indexOf(firstRoot), 1) + detectRemoteAgents.mockResolvedValueOnce(['kilo']) + + await renderProbe({ kind: 'ssh', connectionId: 'ssh-1' }) + + expect(detectRemoteAgents).toHaveBeenCalledTimes(2) + expect(useAppStore.getState().remoteDetectedAgentIds['ssh-1']).toEqual(['kilo']) + }) +}) + +describe('useDetectedAgents (runtime call site)', () => { + it('retries a cached empty runtime result when the launch surface is reopened', async () => { + let detectCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + let result: unknown + if (method === 'status.get') { + result = { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + } else { + detectCalls += 1 + result = detectCalls === 1 ? [] : ['kilo'] + } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + const firstRoot = await renderProbe({ kind: 'runtime', environmentId: 'env-1' }) + + expect(detectCalls).toBe(1) + expect(useAppStore.getState().runtimeDetectedAgentIds['env-1']).toEqual([]) + + await act(async () => { + firstRoot.unmount() + }) + roots.splice(roots.indexOf(firstRoot), 1) + + await renderProbe({ kind: 'runtime', environmentId: 'env-1' }) + + expect(detectCalls).toBe(2) + expect(useAppStore.getState().runtimeDetectedAgentIds['env-1']).toEqual(['kilo']) + }) +}) diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index 950e6893931..d4be36d0e64 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../shared/types' @@ -50,6 +50,7 @@ export function useDetectedAgents( connectionId: AgentDetectionTarget | string | null | undefined = null ): UseDetectedAgentsResult { const target = normalizeAgentDetectionTarget(connectionId) + const retriedEmptyTargetRef = useRef(null) // Why: undefined means "store not yet hydrated" — we don't know if the // worktree is local or remote yet. This prevents flashing local agents for // remote worktrees during hydration. @@ -96,12 +97,30 @@ export function useDetectedAgents( if (isUnknown) { return } + const emptyRetryKey = + targetKind === 'ssh' && targetId + ? `ssh:${targetId}` + : targetKind === 'runtime' && targetId + ? `runtime:${targetId}` + : null if (targetKind === 'ssh' && targetId) { if (detectedIds === null) { + retriedEmptyTargetRef.current = emptyRetryKey + void ensureRemote(targetId) + } else if (detectedIds.length === 0 && retriedEmptyTargetRef.current !== emptyRetryKey) { + // Why: a newly opened remote launch surface should get one fresh probe + // after a prior empty result, but must not spin while the host has no agents. + retriedEmptyTargetRef.current = emptyRetryKey void ensureRemote(targetId) } } else if (targetKind === 'runtime' && targetId) { if (detectedIds === null) { + retriedEmptyTargetRef.current = emptyRetryKey + void ensureRuntime(targetId) + } else if (detectedIds.length === 0 && retriedEmptyTargetRef.current !== emptyRetryKey) { + // Why: remote `orca serve` users can install/fix PATH without reconnecting; + // retry once per mounted surface so the menu can pick that up. + retriedEmptyTargetRef.current = emptyRetryKey void ensureRuntime(targetId) } } else { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b7b64771ebe..d19cea07d4e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1028,13 +1028,18 @@ export function useIpcEvents(): void { unsubs.push( window.api.repos.onChanged(() => { + const state = useAppStore.getState() if (isRuntimeEnvironmentActive()) { - // Why: this event comes from the local Electron store. While a - // runtime server is selected, repo hydration must be driven by the - // selected server instead of local-disk changes. + // Why: the all-host sidebar includes local repos even when a runtime + // is focused, so local store changes must refresh the local slice + // without dropping the runtime-owned slices already shown. + void (async () => { + await state.fetchReposForAllHosts() + await state.fetchProjectGroupsForAllHosts() + await state.fetchFolderWorkspacesForAllHosts() + })() return } - const state = useAppStore.getState() void state.fetchProjectGroups() void state.fetchFolderWorkspaces() void state.fetchRepos() diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 6a92064f25b..9f15743ad40 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -32,6 +32,26 @@ export function useShortcutLabel(actionId: KeybindingActionId): string { return formatShortcutLabel(actionId, keybindings) } +// Why: returns null for unbound actions instead of the display sentinel +// 'Unassigned', so callers decide whether to render a hint without coupling +// UI logic to formatter copy (which may change or become localized). +export function formatOptionalShortcutLabel( + actionId: KeybindingActionId, + overrides?: KeybindingOverrides +): string | null { + const platform = getShortcutPlatform() + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) +} + +export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { + const keybindings = useAppStore((state) => state.keybindings) + return formatOptionalShortcutLabel(actionId, keybindings) +} + export function formatShortcutKeys( actionId: KeybindingActionId, overrides?: KeybindingOverrides diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 243e47d9f23..c5d470f74ae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1603,7 +1603,8 @@ "ff90d0abc7": "Start workspace from {{value0}}", "fe28c9821f": "view", "8d1e17a3ef": "Open {{value0}} in GitHub", - "4ac8ff2275": "Open {{value0}} in Jira" + "4ac8ff2275": "Open {{value0}} in Jira", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "Close", @@ -1847,7 +1848,10 @@ "22df63c393": "Sub-issue data is unavailable for your token.", "067119985c": "GitHub search, e.g. assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} isn't added to Orca. Add it to start work, or open in GitHub.", - "1aa7c952b9": "Project view" + "1aa7c952b9": "Project view", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2498,7 +2502,8 @@ "SortableTab": { "ab19f603eb": "Rename tab {{value0}}", "6df69d9388": "Close tab {{value0}}", - "fdb2691425": "Collapse pane" + "fdb2691425": "Collapse pane", + "95db5f2f7d": "Close tab" }, "SortableTabContextMenu": { "35e8892fd0": "Tab Color", @@ -2622,6 +2627,10 @@ "down": "Down", "up": "Up", "moveToPaneColumn": "Move Tab to Split" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Close tab", + "a768f428f1": "Close tab" } } }, @@ -3870,6 +3879,7 @@ "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { + "copyLink": "Copy link", "reviewHeader": "{{value0}} #{{value1}}" }, "WorktreeCardMeta": { @@ -3886,6 +3896,12 @@ "807b13b9ec": "Edit issue", "b22f058067": "View on GitHub", "e97d8f2876": "Issue #{{value0}}", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link", "3ea2702e62": "Linked {{value0}} #{{value1}}", "b105fd3057": "Linked Linear {{value0}}", "3f2649eeb8": "Linked issue #{{value0}}", @@ -6305,7 +6321,19 @@ "ab20575a8a": "off", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Scroll Speed", + "description": "Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed.", + "helper": "Adjust how wheel input feels in scrollback and in mouse-aware terminal apps.", + "reset": "Reset", + "normal": "Normal", + "normalDescription": "Scrollback wheel multiplier.", + "fast": "Fast", + "fastDescription": "Extra multiplier while scrolling with a modifier key.", + "tui": "TUI", + "tuiDescription": "Discrete wheel reports for full-screen terminal apps." + } }, "TerminalSettingsPreview": { "a63953a48a": "Preview {{value0}} theme", @@ -7941,7 +7969,11 @@ "0fe0073f0c": "Default terminal font size for new panes and live updates.", "5930244899": "Font Size", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Scroll Speed", + "description": "Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed." + } }, "windows": { "search": { @@ -9164,7 +9196,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "Workspace", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "Project", + "currentProjectLower": "current project", + "project": "Project" }, "AiVaultSessionDetails": { "originalAsk": "Original ask", @@ -10419,7 +10454,8 @@ "4cc7b83ffe": "Filter files...", "21783df79f": "Collapse file tree", "481e63ca52": "Files", - "d5ac717d65": "uncommitted" + "d5ac717d65": "uncommitted", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "in Source Control", @@ -10446,7 +10482,7 @@ "f786fd54e1": "Inline", "ea08dae15b": "Collapse All", "19c45cfdc0": "Expand All", - "982d14bfa5": "Open Uncommitted Diff", + "982d14bfa5": "Open All Changes", "3d909843bb": "Open Branch Diff", "8368d256ec": "combined-branch", "8f68ad9ca9": "Show {{value0}} AI {{value1}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 806d0bca0e5..1f7ecd1d99a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1603,7 +1603,8 @@ "ff90d0abc7": "Iniciar espacio de trabajo desde {{value0}}", "fe28c9821f": "vista", "8d1e17a3ef": "Abra {{value0}} en GitHub", - "4ac8ff2275": "Abrir {{value0}} en Jira" + "4ac8ff2275": "Abrir {{value0}} en Jira", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "Cerca", @@ -1847,7 +1848,10 @@ "22df63c393": "Los datos de la subemisión no están disponibles para su token.", "067119985c": "Búsqueda de GitHub, p. cesionario:@yo es:abierto", "1850fceac8": "{{value0}}/{{value1}} no se agrega a Orca. Agréguelo para comenzar a trabajar o ábralo en GitHub.", - "1aa7c952b9": "Project view" + "1aa7c952b9": "Project view", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2498,7 +2502,8 @@ "SortableTab": { "ab19f603eb": "Cambiar nombre de pestaña {{value0}}", "6df69d9388": "Cerrar pestaña {{value0}}", - "fdb2691425": "Contraer panel" + "fdb2691425": "Contraer panel", + "95db5f2f7d": "Cerrar pestaña" }, "SortableTabContextMenu": { "35e8892fd0": "Color de pestaña", @@ -2622,6 +2627,10 @@ "down": "Abajo", "up": "Arriba", "moveToPaneColumn": "Mover pestaña a división" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Cerrar pestaña", + "a768f428f1": "Cerrar pestaña" } } }, @@ -3856,7 +3865,8 @@ "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "Metadatos del espacio de trabajo", @@ -3883,7 +3893,13 @@ "checkingAutomationAvailability": "Comprobando disponibilidad de la automatización...", "automationMissing": "La automatización ya no está disponible.", "automationRunMissing": "El historial de ejecuciones ya no está disponible.", - "automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización." + "automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización.", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "Estado: Abierto", @@ -6268,7 +6284,19 @@ "ab20575a8a": "apagado", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Velocidad de desplazamiento", + "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa.", + "helper": "Ajusta cómo se siente la entrada de la rueda en el historial y en apps de terminal compatibles con mouse.", + "reset": "Restablecer", + "normal": "Normal", + "normalDescription": "Multiplicador de rueda del historial.", + "fast": "Rápido", + "fastDescription": "Multiplicador adicional al desplazarse con una tecla modificadora.", + "tui": "TUI", + "tuiDescription": "Eventos discretos de rueda para apps de terminal de pantalla completa." + } }, "TerminalSettingsPreview": { "a63953a48a": "Vista previa del tema {{value0}}", @@ -7904,7 +7932,11 @@ "keyword_custom": "custom" }, "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "Velocidad de desplazamiento", + "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa." + } }, "windows": { "search": { @@ -9164,7 +9196,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "Workspace", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "Proyecto", + "currentProjectLower": "proyecto actual", + "project": "Proyecto" }, "AiVaultSessionDetails": { "updated": "Updated", @@ -10419,7 +10454,8 @@ "4cc7b83ffe": "Filtrar archivos...", "21783df79f": "Contraer árbol de archivos", "481e63ca52": "Archivos", - "d5ac717d65": "no comprometido" + "d5ac717d65": "no comprometido", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "en control de fuente", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 83f6f76283b..8499ddd598f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1603,7 +1603,8 @@ "ff90d0abc7": "{{value0}} からワークスペースを開始", "fe28c9821f": "view", "8d1e17a3ef": "{{value0}} を GitHub で開く", - "4ac8ff2275": "{{value0}} を Jira で開く" + "4ac8ff2275": "{{value0}} を Jira で開く", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "閉じる", @@ -1847,7 +1848,10 @@ "22df63c393": "トークンではサブ Issue データを利用できません。", "067119985c": "GitHub 検索、例:担当者:@me は:開いています", "1850fceac8": "{{value0}}/{{value1}} は Orca に追加されません。これを追加して作業を開始するか、GitHub で開きます。", - "1aa7c952b9": "Project view" + "1aa7c952b9": "Project view", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2314,7 +2318,7 @@ "TerminalContextMenu": { "b4cdd9314e": "クリアスクリーン", "8c17d6786d": "ペインを閉じる", - "copyTerminalId": "Copy Terminal ID", + "copyTerminalId": "ターミナル ID をコピー", "2cf85a6a55": "ペインIDのコピー", "39809d152f": "タイトルを設定…", "06c2b0f043": "ペインのサイズを均等化する", @@ -2406,9 +2410,9 @@ "a29b9faa01": "ペイン ID がコピーされました", "terminal": { "id": { - "copied": "Terminal ID をコピーしました", + "copied": "ターミナル ID をコピーしました", "copy": { - "failed": "Terminal ID をコピーできません" + "failed": "ターミナル ID をコピーできません" } } } @@ -2498,7 +2502,8 @@ "SortableTab": { "ab19f603eb": "タブ {{value0}} の名前を変更します", "6df69d9388": "タブ {{value0}} を閉じる", - "fdb2691425": "ペインを折りたたむ" + "fdb2691425": "ペインを折りたたむ", + "95db5f2f7d": "タブを閉じる" }, "SortableTabContextMenu": { "35e8892fd0": "タブの色", @@ -2622,6 +2627,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "タブを分割へ移動" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "タブを閉じる", + "a768f428f1": "タブを閉じる" } } }, @@ -3837,7 +3846,8 @@ "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "ワークスペースのメタデータ", @@ -3864,7 +3874,13 @@ "checkingAutomationAvailability": "自動化の利用可否を確認中...", "automationMissing": "自動化は利用できなくなりました。", "automationRunMissing": "実行履歴は利用できなくなりました。", - "automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。" + "automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状態: オープン", @@ -6290,7 +6306,19 @@ "ab20575a8a": "オフ", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "スクロール速度", + "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。", + "helper": "ホイール入力の感触をスクロールバックとマウス対応のターミナルアプリで調整します。", + "reset": "リセット", + "normal": "通常", + "normalDescription": "スクロールバックのホイール倍率。", + "fast": "高速", + "fastDescription": "修飾キーを押しながらスクロールするときの追加倍率。", + "tui": "TUI", + "tuiDescription": "全画面ターミナルアプリ向けの離散的なホイール通知。" + } }, "TerminalSettingsPreview": { "a63953a48a": "{{value0}} テーマのプレビュー", @@ -7926,7 +7954,11 @@ "0fe0073f0c": "新規ペインとライブ アップデートのデフォルトの terminal フォント サイズ。", "5930244899": "フォントサイズ", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "scrollSpeed": { + "title": "スクロール速度", + "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。" + } }, "windows": { "search": { @@ -9164,7 +9196,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "ワークスペース", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "プロジェクト", + "currentProjectLower": "現在のプロジェクト", + "project": "プロジェクト" }, "AiVaultSessionDetails": { "updated": "Updated", @@ -10419,7 +10454,8 @@ "4cc7b83ffe": "ファイルをフィルタリング...", "21783df79f": "ファイルツリーを折りたたむ", "481e63ca52": "ファイル", - "d5ac717d65": "commit されていない" + "d5ac717d65": "commit されていない", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "ソース管理内", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index da5277b8327..39ac2499aeb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1603,7 +1603,8 @@ "ff90d0abc7": "{{value0}}에서 워크스페이스 시작", "fe28c9821f": "보기", "8d1e17a3ef": "GitHub에서 {{value0}} 열기", - "4ac8ff2275": "Jira에서 {{value0}} 열기" + "4ac8ff2275": "Jira에서 {{value0}} 열기", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "닫기", @@ -1847,7 +1848,10 @@ "22df63c393": "토큰에 대한 하위 이슈 데이터를 사용할 수 없습니다.", "067119985c": "GitHub 검색, 예: assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}}이(가) Orca에 추가되어 있지 않습니다. 작업을 시작하려면 추가하거나 GitHub에서 여세요.", - "1aa7c952b9": "프로젝트 보기" + "1aa7c952b9": "프로젝트 보기", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2314,7 +2318,7 @@ "TerminalContextMenu": { "b4cdd9314e": "화면 지우기", "8c17d6786d": "창 닫기", - "copyTerminalId": "Copy Terminal ID", + "copyTerminalId": "터미널 ID 복사", "2cf85a6a55": "창 ID 복사", "39809d152f": "제목 설정…", "06c2b0f043": "창 크기 균등화", @@ -2406,7 +2410,7 @@ "a29b9faa01": "창 ID가 복사되었습니다.", "terminal": { "id": { - "copied": "터미널 ID가 복사되었습니다", + "copied": "터미널 ID를 복사했습니다", "copy": { "failed": "터미널 ID를 복사할 수 없습니다" } @@ -2498,7 +2502,8 @@ "SortableTab": { "ab19f603eb": "탭 이름 바꾸기 {{value0}}", "6df69d9388": "탭 닫기 {{value0}}", - "fdb2691425": "창 축소" + "fdb2691425": "창 축소", + "95db5f2f7d": "탭 닫기" }, "SortableTabContextMenu": { "35e8892fd0": "탭 색상", @@ -2622,6 +2627,10 @@ "down": "아래", "up": "위", "moveToPaneColumn": "탭을 분할로 이동" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "탭 닫기", + "a768f428f1": "탭 닫기" } } }, @@ -3837,7 +3846,8 @@ "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "워크스페이스 메타데이터", @@ -3864,7 +3874,13 @@ "checkingAutomationAvailability": "자동화 사용 가능 여부 확인 중...", "automationMissing": "자동화를 더 이상 사용할 수 없습니다.", "automationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.", - "automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다." + "automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다.", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "상태: 열림", @@ -6253,7 +6269,19 @@ "ab20575a8a": "끄기", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "실행 중인 Terminals을 닫기 전에 확인", - "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인 창을 표시합니다." + "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인 창을 표시합니다.", + "scrollSpeed": { + "title": "스크롤 속도", + "description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다.", + "helper": "휠 입력이 스크롤백과 마우스 인식 터미널 앱에서 느껴지는 방식을 조정합니다.", + "reset": "재설정", + "normal": "일반", + "normalDescription": "스크롤백 휠 배율.", + "fast": "빠름", + "fastDescription": "수정 키로 스크롤할 때 적용되는 추가 배율.", + "tui": "TUI", + "tuiDescription": "전체 화면 터미널 앱을 위한 개별 휠 보고." + } }, "TerminalSettingsPreview": { "a63953a48a": "{{value0}} 테마 미리보기", @@ -7889,7 +7917,11 @@ "keyword_custom": "custom" }, "ask_before_closing_running_terminals_title": "실행 중인 Terminals을 닫기 전에 확인", - "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인을 표시합니다." + "ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인을 표시합니다.", + "scrollSpeed": { + "title": "스크롤 속도", + "description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다." + } }, "windows": { "search": { @@ -9164,7 +9196,10 @@ "hideEmptySessions": "빈 세션 숨기기", "workspaceScope": "워크스페이스", "worktreeScope": "워크트리", - "globalScope": "전역" + "globalScope": "전역", + "projectScope": "프로젝트", + "currentProjectLower": "현재 프로젝트", + "project": "프로젝트" }, "AiVaultSessionDetails": { "updated": "업데이트됨", @@ -10419,7 +10454,8 @@ "4cc7b83ffe": "파일 필터링...", "21783df79f": "파일 트리 축소", "481e63ca52": "파일", - "d5ac717d65": "commit 안 됨" + "d5ac717d65": "commit 안 됨", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "소스 제어에서", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bc032593599..f23031056bb 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1603,7 +1603,8 @@ "ff90d0abc7": "从 {{value0}} 开始工作区", "fe28c9821f": "视图", "8d1e17a3ef": "在 GitHub 中打开 {{value0}}", - "4ac8ff2275": "在 Jira 中打开 {{value0}}" + "4ac8ff2275": "在 Jira 中打开 {{value0}}", + "40eaf2c27c": "Details" }, "Terminal": { "73768427cf": "关闭", @@ -1847,7 +1848,10 @@ "22df63c393": "您的 Token 无法获取子议题数据。", "067119985c": "GitHub 搜索,例如 assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} 未添加到 Orca。添加它以开始工作,或在 GitHub 中打开。", - "1aa7c952b9": "项目视图" + "1aa7c952b9": "项目视图", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2314,7 +2318,7 @@ "TerminalContextMenu": { "b4cdd9314e": "清晰的屏幕", "8c17d6786d": "关闭窗格", - "copyTerminalId": "复制 Terminal ID", + "copyTerminalId": "复制终端 ID", "2cf85a6a55": "复制窗格 ID", "39809d152f": "设置标题...", "06c2b0f043": "均衡窗格大小", @@ -2498,7 +2502,8 @@ "SortableTab": { "ab19f603eb": "重命名选项卡 {{value0}}", "6df69d9388": "关闭选项卡 {{value0}}", - "fdb2691425": "折叠窗格" + "fdb2691425": "折叠窗格", + "95db5f2f7d": "关闭选项卡" }, "SortableTabContextMenu": { "35e8892fd0": "标签颜色", @@ -2622,6 +2627,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "将标签页移至拆分" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "关闭选项卡", + "a768f428f1": "关闭选项卡" } } }, @@ -3837,7 +3846,8 @@ "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "工作区元数据", @@ -3864,7 +3874,13 @@ "checkingAutomationAvailability": "正在检查自动化可用性...", "automationMissing": "自动化已不可用。", "automationRunMissing": "运行历史已不可用。", - "automationAvailabilityUnavailable": "无法检查自动化可用性。" + "automationAvailabilityUnavailable": "无法检查自动化可用性。", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状态:开放", @@ -6253,7 +6269,19 @@ "ab20575a8a": "关", "ab3a1f9068": "执行程序", "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", - "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。" + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", + "scrollSpeed": { + "title": "滚动速度", + "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。", + "helper": "调整滚轮输入在回滚和支持鼠标的终端应用中的手感。", + "reset": "重置", + "normal": "普通", + "normalDescription": "回滚滚轮倍数。", + "fast": "快速", + "fastDescription": "按住修饰键滚动时的额外倍数。", + "tui": "TUI", + "tuiDescription": "面向全屏终端应用的离散滚轮报告。" + } }, "TerminalSettingsPreview": { "a63953a48a": "预览 {{value0}} 主题", @@ -7889,7 +7917,11 @@ "keyword_custom": "自定义" }, "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", - "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。" + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", + "scrollSpeed": { + "title": "滚动速度", + "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。" + } }, "windows": { "search": { @@ -9164,7 +9196,10 @@ "hideEmptySessions": "隐藏空会话", "workspaceScope": "工作区", "worktreeScope": "工作树", - "globalScope": "全局" + "globalScope": "全局", + "projectScope": "项目", + "currentProjectLower": "当前项目", + "project": "项目" }, "AiVaultSessionDetails": { "updated": "更新时间", @@ -10419,7 +10454,8 @@ "4cc7b83ffe": "搜索文件...", "21783df79f": "折叠文件树", "481e63ca52": "文件", - "d5ac717d65": "未提交" + "d5ac717d65": "未提交", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "在源代码控制中", diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts new file mode 100644 index 00000000000..d5a4c6ff9b9 --- /dev/null +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -0,0 +1,49 @@ +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' +import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import type { GlobalSettings } from '../../../shared/types' + +type RuntimeOwnerSettings = Pick | null | undefined + +export async function sendFollowupPromptWhenAgentReady(args: { + ptyId: string + expectedProcess: string + prompt: string + settings: RuntimeOwnerSettings +}): Promise { + const { ptyId, expectedProcess, prompt, settings } = args + if (!(await waitForAgentForeground(ptyId, expectedProcess, settings))) { + return false + } + try { + return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`) + } catch { + return false + } +} + +// Why: delayed follow-ups must not type into an arbitrary shell. Require a +// positive expected-process match before writing user/task text to the PTY. +async function waitForAgentForeground( + ptyId: string, + expectedProcess: string, + settings: RuntimeOwnerSettings +): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => globalThis.setTimeout(resolve, 150)) + } + try { + const process = await inspectRuntimeTerminalProcess(settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' + if (isExpectedAgentProcess(foreground, expectedProcess)) { + return true + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + } + return false +} diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index ae687021224..2c6ae9ab6f0 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -6,6 +6,7 @@ import { chunkAgentDraftPasteContent, getSettingsForAgentTabRuntimeOwner, iterateAgentDraftPasteContentChunks, + pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady, sendAgentDraftPasteContent, sendBracketedPasteToRunningAgent @@ -275,6 +276,30 @@ describe('pasteDraftWhenAgentReady', () => { ) }) + it('honors the fallback inspection deadline for pty-bound draft paste', async () => { + const onTimeout = vi.fn() + testState.inspectRuntimeTerminalProcess.mockReturnValue(new Promise(() => {})) + + const promise = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: ISSUE_URL, + agent: 'codex', + forcePaste: true, + timeoutMs: 1, + onTimeout + }) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(1) + await flushMicrotasks(5) + await vi.advanceTimersByTimeAsync(1000) + + await expect(promise).resolves.toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + it('routes tab-owned paste writes through the worktree runtime owner', async () => { testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 4847004d309..6fc186a1326 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,7 +1,10 @@ import type { TuiAgent } from '../../../shared/types' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { useAppStore } from '@/store' -import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, @@ -12,6 +15,7 @@ import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' export { AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES, AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, @@ -123,6 +127,45 @@ export async function pasteDraftWhenAgentReady(args: { }) } +export async function pasteDraftToAgentPtyWhenReady(args: { + tabId: string + ptyId: string + content: string + agent?: TuiAgent + submit?: boolean + forcePaste?: boolean + timeoutMs?: number + onTimeout?: () => void +}): Promise { + const { tabId, ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args + const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null + + if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + return false + } + + const budget = timeoutMs ?? READINESS_TIMEOUT_MS + const settings = getSettingsForAgentTabRuntimeOwner(tabId) + const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) + if (!ready) { + const fallbackReady = agentConfig + ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) + : false + if (!fallbackReady) { + onTimeout?.() + return false + } + } + + return await sendBracketedPasteToAgent({ + settings, + ptyId, + content, + submit: submit === true + }) +} + export async function submitPromptToAgentTab(args: { tabId: string content: string @@ -191,3 +234,53 @@ async function waitForPtyId(tabId: string, timeoutMs: number): Promise | null | undefined +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const process = await withDeadline( + inspectRuntimeTerminalProcess(settings, ptyId), + Math.max(0, deadline - Date.now()) + ) + if (!process) { + return false + } + const foreground = process.foregroundProcess?.toLowerCase() ?? '' + if (isExpectedAgentProcess(foreground, expectedProcess)) { + return true + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + const delayMs = Math.min(120, Math.max(0, deadline - Date.now())) + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)) + } + } + return false +} + +function withDeadline(promise: Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + return Promise.resolve(null) + } + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => resolve(null), timeoutMs) + promise.then( + (value) => { + window.clearTimeout(timer) + resolve(value) + }, + (error) => { + window.clearTimeout(timer) + reject(error) + } + ) + }) +} diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts new file mode 100644 index 00000000000..deba11170a6 --- /dev/null +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -0,0 +1,212 @@ +import { useAppStore } from '@/store' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +type AppStoreSnapshot = ReturnType + +type PendingAgentStartupDelivery = { + worktreeId: string + tabId: string + launchToken: string + startup: AgentStartupPlan + deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise +} + +const pendingAgentStartupDeliveries = new Map() +const consumedAgentStartupDeliveries = new Set() +const staleStartupRecheckTimers = new Map>() +let unsubscribePendingAgentStartupDeliveries: (() => void) | null = null + +export function resolveAgentStartupTabId( + state: AppStoreSnapshot, + worktreeId: string, + primaryTabId: string | null | undefined +): string | null { + // Why: the caller may know the exact tab that received the queued startup + // command. Prefer it over focus-derived state, which can change mid-create. + return ( + primaryTabId ?? + state.activeTabIdByWorktree[worktreeId] ?? + state.tabsByWorktree[worktreeId]?.[0]?.id ?? + null + ) +} + +export function getAgentStartupTabPtyId( + state: AppStoreSnapshot, + tabId: string, + launchToken: string +): string | null { + const livePtyIds = new Set(state.ptyIdsByTabId[tabId] ?? []) + if (livePtyIds.size === 0) { + return null + } + for (const [paneKey, entry] of Object.entries(state.agentLaunchConfigByPaneKey ?? {})) { + const identity = entry.identity + if (identity.tabId !== tabId || identity.launchToken !== launchToken) { + continue + } + const leafId = identity.leafId ?? parsePaneKey(paneKey)?.leafId + if (!leafId) { + continue + } + const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + if (ptyId && livePtyIds.has(ptyId)) { + return ptyId + } + } + return null +} + +function worktreeStillOwnsStartupTab( + state: AppStoreSnapshot, + worktreeId: string, + tabId: string +): boolean { + return (state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) +} + +function getPendingStartupLaunchToken(state: AppStoreSnapshot, tabId: string): string | undefined { + return state.pendingStartupByTabId?.[tabId]?.launchToken +} + +function hasRegisteredStartupLaunch( + state: AppStoreSnapshot, + tabId: string, + launchToken: string +): boolean { + return Object.values(state.agentLaunchConfigByPaneKey ?? {}).some( + (entry) => entry.identity.tabId === tabId && entry.identity.launchToken === launchToken + ) +} + +function ensurePendingAgentStartupSubscription(): void { + if (unsubscribePendingAgentStartupDeliveries) { + return + } + unsubscribePendingAgentStartupDeliveries = useAppStore.subscribe(() => { + flushPendingAgentStartupDeliveries() + }) +} + +function stopPendingAgentStartupSubscriptionIfIdle(): void { + if (pendingAgentStartupDeliveries.size > 0 || !unsubscribePendingAgentStartupDeliveries) { + return + } + unsubscribePendingAgentStartupDeliveries() + unsubscribePendingAgentStartupDeliveries = null +} + +export function queuePendingAgentStartupDelivery(delivery: PendingAgentStartupDelivery): void { + const key = deliveryKey(delivery) + if (consumedAgentStartupDeliveries.has(key)) { + return + } + pendingAgentStartupDeliveries.set(key, delivery) + ensurePendingAgentStartupSubscription() + flushPendingAgentStartupDeliveries() +} + +export function resetAgentStartupDelayedDeliveryForTests(): void { + pendingAgentStartupDeliveries.clear() + consumedAgentStartupDeliveries.clear() + for (const timer of staleStartupRecheckTimers.values()) { + globalThis.clearTimeout(timer) + } + staleStartupRecheckTimers.clear() + unsubscribePendingAgentStartupDeliveries?.() + unsubscribePendingAgentStartupDeliveries = null +} + +export function beginAgentStartupDeliveryAttempt(args: { + worktreeId: string + tabId: string + launchToken: string +}): boolean { + const key = deliveryKey(args) + if (consumedAgentStartupDeliveries.has(key)) { + return false + } + consumedAgentStartupDeliveries.add(key) + pendingAgentStartupDeliveries.delete(key) + clearStaleStartupRecheck(key) + return true +} + +function deliveryKey( + delivery: Pick +): string { + return `${delivery.worktreeId}\0${delivery.tabId}\0${delivery.launchToken}` +} + +function flushPendingAgentStartupDeliveries(): void { + const state = useAppStore.getState() + for (const [key, delivery] of pendingAgentStartupDeliveries) { + const { tabId, launchToken } = delivery + if (!worktreeStillOwnsStartupTab(state, delivery.worktreeId, tabId)) { + pendingAgentStartupDeliveries.delete(key) + continue + } + const queuedLaunchToken = getPendingStartupLaunchToken(state, tabId) + const launchRegistered = hasRegisteredStartupLaunch(state, tabId, launchToken) + if (queuedLaunchToken !== launchToken && !launchRegistered && queuedLaunchToken !== undefined) { + pendingAgentStartupDeliveries.delete(key) + clearStaleStartupRecheck(key) + continue + } + if (queuedLaunchToken === undefined && !launchRegistered) { + scheduleStaleStartupRecheck(key) + continue + } + const ptyId = getAgentStartupTabPtyId(state, tabId, launchToken) + if (!ptyId) { + continue + } + // Why: once the launch-bound PTY exists, the bounded readiness/paste path + // owns success or failure. Consume before awaiting so store churn cannot + // duplicate a linked-work-item draft. + if (beginAgentStartupDeliveryAttempt(delivery)) { + void delivery.deliver(tabId, ptyId, delivery.startup).catch((error) => { + console.warn('Queued agent startup delivery failed', error) + }) + } + } + stopPendingAgentStartupSubscriptionIfIdle() +} + +function scheduleStaleStartupRecheck(key: string): void { + if (staleStartupRecheckTimers.has(key)) { + return + } + staleStartupRecheckTimers.set( + key, + globalThis.setTimeout(() => { + staleStartupRecheckTimers.delete(key) + const delivery = pendingAgentStartupDeliveries.get(key) + if (!delivery) { + stopPendingAgentStartupSubscriptionIfIdle() + return + } + const state = useAppStore.getState() + const queuedLaunchToken = getPendingStartupLaunchToken(state, delivery.tabId) + if ( + queuedLaunchToken === undefined && + !hasRegisteredStartupLaunch(state, delivery.tabId, delivery.launchToken) + ) { + pendingAgentStartupDeliveries.delete(key) + } else { + flushPendingAgentStartupDeliveries() + } + stopPendingAgentStartupSubscriptionIfIdle() + }, 1000) + ) +} + +function clearStaleStartupRecheck(key: string): void { + const timer = staleStartupRecheckTimers.get(key) + if (!timer) { + return + } + globalThis.clearTimeout(timer) + staleStartupRecheckTimers.delete(key) +} diff --git a/src/renderer/src/lib/floating-terminal.test.ts b/src/renderer/src/lib/floating-terminal.test.ts new file mode 100644 index 00000000000..ea52f857a88 --- /dev/null +++ b/src/renderer/src/lib/floating-terminal.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from './floating-terminal' + +describe('floating terminal open-maximized intent', () => { + afterEach(() => { + vi.useRealTimers() + // Drain any leftover intent so it cannot bleed into an unrelated test. + consumeFloatingTerminalOpenMaximizedIntent() + }) + + it('returns true exactly once after a request', () => { + requestFloatingTerminalOpenMaximized() + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + // One-shot: a second consume without a new request is false. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('returns false when no request was made', () => { + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('expires a stale intent so an abandoned open does not leak into a later open', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + // Why: the open was abandoned (prevented/interrupted before the panel + // mounted); a much-later ordinary open must not consume the stale intent. + vi.advanceTimersByTime(2001) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('still honors an intent consumed within the same interaction window', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + vi.advanceTimersByTime(50) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/floating-terminal.ts b/src/renderer/src/lib/floating-terminal.ts index 85a53f5d77f..b496590cff2 100644 --- a/src/renderer/src/lib/floating-terminal.ts +++ b/src/renderer/src/lib/floating-terminal.ts @@ -1 +1,28 @@ export const TOGGLE_FLOATING_TERMINAL_EVENT = 'orca-toggle-floating-terminal' + +// Why: maximize/restore lives in the panel's own keydown handler, but that +// handler is unmounted while the panel is closed. When Cmd+Opt+Shift+A is +// pressed with the panel closed, App opens it and records a one-shot intent +// here so the freshly mounted panel starts maximized instead of at its last +// saved size. A module singleton (not a prop) bridges the closed→mounted gap +// that React state cannot, and is consumed exactly once. +let openMaximizedIntentAt: number | null = null + +// Why: the panel mounts within the same interaction as the request, so an +// intent older than this window means the open was abandoned (prevented or +// interrupted before mount). Expiring it stops a stale intent from leaking +// into a later ordinary open and maximizing it unexpectedly. +const OPEN_MAXIMIZED_INTENT_TTL_MS = 2000 + +export function requestFloatingTerminalOpenMaximized(): void { + openMaximizedIntentAt = Date.now() +} + +export function consumeFloatingTerminalOpenMaximizedIntent(): boolean { + if (openMaximizedIntentAt === null) { + return false + } + const requestedAt = openMaximizedIntentAt + openMaximizedIntentAt = null + return Date.now() - requestedAt <= OPEN_MAXIMIZED_INTENT_TTL_MS +} diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 4626a06d259..d753e0977d0 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -1,27 +1,72 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInspectRuntimeTerminalProcess, mockSendRuntimePtyInputVerified, - mockPasteDraftWhenAgentReady, + mockPasteDraftToAgentPtyWhenReady, mockTrack, - store + store, + storeListeners, + startupLeafId } = vi.hoisted(() => ({ mockInspectRuntimeTerminalProcess: vi.fn(), mockSendRuntimePtyInputVerified: vi.fn(), - mockPasteDraftWhenAgentReady: vi.fn(), + mockPasteDraftToAgentPtyWhenReady: vi.fn(), mockTrack: vi.fn(), + storeListeners: new Set<(state: unknown, previousState: unknown) => void>(), + startupLeafId: '11111111-1111-4111-8111-111111111111', store: { settings: {}, activeTabIdByWorktree: { 'wt-1': 'tab-1' } as Record, tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } as Record, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + pendingStartupByTabId: {} as Record, + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { '11111111-1111-4111-8111-111111111111': 'pty-1' } + } + } as Record< + string, + { + root: null + activeLeafId: null + expandedLeafId: null + ptyIdsByLeafId?: Record + } + >, + agentLaunchConfigByPaneKey: { + 'tab-1:11111111-1111-4111-8111-111111111111': { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + launchToken: 'launch-token-1' + } + } + } as Record< + string, + { + launchConfig: { agentCommand: string; agentArgs: string; agentEnv: Record } + registeredAt: number + identity: { tabId?: string; leafId?: string; launchToken?: string } + } + > } })) vi.mock('@/store', () => ({ useAppStore: { - getState: () => store + getState: () => store, + subscribe: (listener: (state: unknown, previousState: unknown) => void) => { + storeListeners.add(listener) + return () => { + storeListeners.delete(listener) + } + } } })) @@ -31,7 +76,12 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ - pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady + getSettingsForAgentTabRuntimeOwner: () => store.settings, + pasteDraftToAgentPtyWhenReady: mockPasteDraftToAgentPtyWhenReady +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'launch-token-1' })) vi.mock('@/lib/telemetry', () => ({ @@ -44,6 +94,7 @@ import { getWorkspaceSeedName, isGitLabIssueUrl } from './new-workspace' +import { resetAgentStartupDelayedDeliveryForTests } from './agent-startup-delayed-delivery' describe('getWorkspaceSeedName', () => { it('prefers an explicit name', () => { @@ -171,17 +222,40 @@ describe('isGitLabIssueUrl', () => { describe('ensureAgentStartupInTerminal prompt delivery', () => { beforeEach(() => { + vi.useRealTimers() vi.clearAllMocks() + storeListeners.clear() store.settings = {} store.activeTabIdByWorktree = { 'wt-1': 'tab-1' } store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } store.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-1' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } mockInspectRuntimeTerminalProcess.mockResolvedValue({ foregroundProcess: 'aider', hasChildProcesses: true }) mockSendRuntimePtyInputVerified.mockResolvedValue(true) - mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockPasteDraftToAgentPtyWhenReady.mockResolvedValue(true) + }) + + afterEach(() => { + vi.useRealTimers() + resetAgentStartupDelayedDeliveryForTests() }) it('sends a follow-up prompt through the terminal runtime without renderer telemetry', async () => { @@ -249,10 +323,12 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ tabId: 'tab-1', + ptyId: 'pty-1', content: 'review this before sending', - agent: 'claude' + agent: 'claude', + forcePaste: true }) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) @@ -261,6 +337,21 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { store.activeTabIdByWorktree = { 'wt-1': 'setup-tab' } store.tabsByWorktree = { 'wt-1': [{ id: 'setup-tab' }, { id: 'agent-tab' }] } store.ptyIdsByTabId = { 'setup-tab': ['setup-pty'], 'agent-tab': ['agent-pty'] } + store.terminalLayoutsByTabId = { + 'agent-tab': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'agent-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`agent-tab:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'agent-tab', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } await ensureAgentStartupInTerminal({ worktreeId: 'wt-1', @@ -275,12 +366,358 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ tabId: 'agent-tab', + ptyId: 'agent-pty', content: 'Linear context draft', - agent: 'codex' + agent: 'codex', + forcePaste: true }) }) + + it('pastes a draft after the seeded tab receives a delayed PTY', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'https://github.com/stablyai/orca/pull/2051' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + ptyId: 'pty-delayed', + content: 'https://github.com/stablyai/orca/pull/2051', + agent: 'codex', + forcePaste: true + }) + }) + + it('keeps waiting when a non-startup split PTY appears before the startup PTY', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + const splitLeafId = '22222222-2222-4222-8222-222222222222' + store.ptyIdsByTabId = { 'tab-1': ['split-pty'] } + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [splitLeafId]: 'split-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${splitLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: splitLeafId, launchToken: 'other-token' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + + store.ptyIdsByTabId = { 'tab-1': ['split-pty', 'startup-pty'] } + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [splitLeafId]: 'split-pty', [startupLeafId]: 'startup-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${splitLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: splitLeafId, launchToken: 'other-token' } + }, + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 2, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + ptyId: 'startup-pty', + content: 'linked draft', + agent: 'codex', + forcePaste: true + }) + }) + + it('does not duplicate delayed delivery across repeated store updates', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + }) + + it('does not duplicate immediate delivery for the same launch token', async () => { + const startup = { + agent: 'codex' as const, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + }) + + it('keeps one delayed subscription and tears it down after delivery drains', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + const startup = { + agent: 'codex' as const, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + + const first = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + const second = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + + await vi.advanceTimersByTimeAsync(5_000) + await Promise.all([first, second]) + + expect(storeListeners.size).toBe(1) + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(storeListeners.size).toBe(0) + }) + + it('does not let a newer same-tab launch satisfy an older pending delivery', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-old' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'old linked draft', + launchToken: 'launch-token-old' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-new' } } + for (const listener of storeListeners) { + listener(store, store) + } + + store.ptyIdsByTabId = { 'tab-1': ['pty-new'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-new' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-old' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + }) + + it('does not write a delayed follow-up prompt on readiness timeout', async () => { + vi.useFakeTimers() + mockInspectRuntimeTerminalProcess.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) }) describe('getSetupConfig', () => { diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 2402b68e84e..149c0e7708f 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,16 +1,21 @@ import { useAppStore } from '@/store' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { - inspectRuntimeTerminalProcess, - sendRuntimePtyInputVerified -} from '@/runtime/runtime-terminal-inspection' + getSettingsForAgentTabRuntimeOwner, + pasteDraftToAgentPtyWhenReady +} from '@/lib/agent-paste-draft' +import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' -import { isShellProcess } from '@/lib/tui-agent-startup' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' +import { + beginAgentStartupDeliveryAttempt, + getAgentStartupTabPtyId, + queuePendingAgentStartupDelivery, + resolveAgentStartupTabId +} from '@/lib/agent-startup-delayed-delivery' import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' -import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { slugifyForWorkspaceName } from '../../../shared/workspace-name' +import { createBrowserUuid } from '@/lib/browser-uuid' export { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name' export { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' export { getWorkspaceIntentName } from '../../../shared/workspace-name' @@ -244,6 +249,7 @@ export async function ensureAgentStartupInTerminal(args: { if (startup.followupPrompt === null && draftPrompt === null) { return } + const launchToken = ensureStartupLaunchToken(startup) // Why: poll until a terminal tab + PTY exists for the worktree before we // can interact with it. Activation creates the tab synchronously but the @@ -252,79 +258,79 @@ export async function ensureAgentStartupInTerminal(args: { let ptyId: string | null = null for (let attempt = 0; attempt < 30; attempt += 1) { if (attempt > 0) { - await new Promise((resolve) => window.setTimeout(resolve, 150)) + await new Promise((resolve) => globalThis.setTimeout(resolve, 150)) } const state = useAppStore.getState() - // Why: workspace activation tells us the exact tab that received the agent - // startup command. Use it for draft paste instead of re-deriving from - // active tab state, which can move while setup/background panes mount. - tabId = - primaryTabId ?? - state.activeTabIdByWorktree[worktreeId] ?? - state.tabsByWorktree[worktreeId]?.[0]?.id ?? - null + tabId = resolveAgentStartupTabId(state, worktreeId, primaryTabId) if (!tabId) { continue } - ptyId = state.ptyIdsByTabId[tabId]?.[0] ?? null + ptyId = getAgentStartupTabPtyId(state, tabId, launchToken) if (ptyId) { break } } if (!tabId || !ptyId) { + if (tabId) { + // Why: background-created workspaces can remain unmounted longer than a + // bounded readiness wait. Keep the draft/follow-up tied to the seeded + // tab until opening the workspace creates its PTY. + queuePendingAgentStartupDelivery({ + worktreeId, + tabId, + launchToken, + startup, + deliver: deliverAgentStartupToTerminal + }) + } return } + if (beginAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken })) { + await deliverAgentStartupToTerminal(tabId, ptyId, startup) + } +} + +async function deliverAgentStartupToTerminal( + tabId: string, + ptyId: string, + startup: AgentStartupPlan +): Promise { + const draftPrompt = startup.draftPrompt ?? null + const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) // Why: followupPrompt is the legacy path for stdin-after-start agents // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { - await waitForAgentForeground(ptyId, startup.expectedProcess) - await sendFollowupPrompt(ptyId, startup.followupPrompt) + await sendFollowupPromptWhenAgentReady({ + ptyId, + expectedProcess: startup.expectedProcess, + prompt: startup.followupPrompt, + settings: runtimeSettings + }) } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the // agent's input buffer (no per-char echo, no auto-submit). Shared with the // launch-work-item-direct flow so both behave identically. if (draftPrompt) { - await pasteDraftWhenAgentReady({ + await pasteDraftToAgentPtyWhenReady({ tabId, + ptyId, content: draftPrompt, - agent: startup.agent + agent: startup.agent, + // Why: startup.draftPrompt is only attached after native draft launch + // planning is unavailable, so this paste is the first delivery attempt. + forcePaste: true }) } } -async function sendFollowupPrompt(ptyId: string, prompt: string): Promise { - try { - return await sendRuntimePtyInputVerified(useAppStore.getState().settings, ptyId, `${prompt}\r`) - } catch { - return false - } -} - -// Why: legacy followupPrompt path used `agentOwnsForeground` exclusively (with -// a hasChildProcesses fallback after several polls). Preserve that behavior so -// stdin-after-start agents still receive their prompt under the same -// conditions. Returns when the agent appears ready or the budget expires. -async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise { - for (let attempt = 0; attempt < 30; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => window.setTimeout(resolve, 150)) - } - try { - const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) - const foreground = process.foregroundProcess?.toLowerCase() ?? '' - if (isExpectedAgentProcess(foreground, expectedProcess)) { - return - } - if (attempt >= 4 && !isShellProcess(foreground)) { - if (process.hasChildProcesses) { - return - } - } - } catch { - // Ignore transient PTY inspection failures and keep polling. - } +function ensureStartupLaunchToken(startup: AgentStartupPlan): string { + if (!startup.launchToken) { + // Why: delayed delivery retries must reuse the startup plan's launch token + // so they match the pane launch registration for this agent. + startup.launchToken = createBrowserUuid() } + return startup.launchToken } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 25174f917bf..f6a2e367de0 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -6,9 +6,13 @@ import { markComplexScriptOutput, resetTerminalWebglSuggestion } from './pane-webgl-renderer' -import { openTerminal } from './pane-lifecycle' +import { attachLigatures, openTerminal } from './pane-lifecycle' import { buildDefaultTerminalOptions, + DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, + DEFAULT_TERMINAL_SCROLL_SENSITIVITY, + normalizeTerminalFastScrollSensitivity, + normalizeTerminalScrollSensitivity, resolveTerminalCursorInactiveStyle } from './pane-terminal-options' @@ -38,6 +42,7 @@ function createPane(): ManagedPaneInternal { stablePaneId: leafId, terminal: { loadAddon: vi.fn(), + attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), rows: 24 } as never, @@ -83,6 +88,24 @@ describe('buildDefaultTerminalOptions', () => { expect(buildDefaultTerminalOptions().scrollbar?.width).toBe(7) }) + it('slightly increases default terminal wheel scrolling while preserving fast scroll', () => { + const options = buildDefaultTerminalOptions() + + expect(options.scrollSensitivity).toBe(DEFAULT_TERMINAL_SCROLL_SENSITIVITY) + expect(options.fastScrollSensitivity).toBe(DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY) + }) + + it('normalizes configurable terminal scroll sensitivity values', () => { + expect(normalizeTerminalScrollSensitivity(undefined)).toBe(DEFAULT_TERMINAL_SCROLL_SENSITIVITY) + expect(normalizeTerminalScrollSensitivity(0)).toBe(0.1) + expect(normalizeTerminalScrollSensitivity(20)).toBe(10) + expect(normalizeTerminalFastScrollSensitivity(undefined)).toBe( + DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY + ) + expect(normalizeTerminalFastScrollSensitivity(0)).toBe(1) + expect(normalizeTerminalFastScrollSensitivity(25)).toBe(20) + }) + it('enables xterm contrast correction for low-contrast CLI colors', () => { expect(buildDefaultTerminalOptions().minimumContrastRatio).toBe(4.5) }) @@ -325,6 +348,18 @@ describe('attachWebgl', () => { }) }) +describe('attachLigatures', () => { + it('refreshes existing rows after loading the ligatures addon', () => { + const pane = createPane() + + attachLigatures(pane) + + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(pane.ligaturesAddon).not.toBeNull() + }) +}) + describe('openTerminal — Unicode 11 ordering', () => { beforeEach(() => { vi.stubGlobal('requestAnimationFrame', () => 1) @@ -386,6 +421,7 @@ describe('openTerminal — Unicode 11 ordering', () => { events.push('loadAddon:webLinks') } }), + attachCustomWheelEventHandler: vi.fn(), write: vi.fn(() => { events.push('write') }), diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 4feec04fc30..f2fc5846add 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -25,6 +25,7 @@ import { import { clearPendingSplitScrollRestore } from './pane-split-scroll' import { buildDefaultTerminalOptions } from './pane-terminal-options' import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider' +import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { ENABLE_WEBGL_RENDERER, @@ -130,6 +131,7 @@ export function createPaneDOM( container, xtermContainer, linkTooltip, + terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity, terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto', gpuRenderingEnabled: ENABLE_WEBGL_RENDERER, webglAttachmentDeferred: false, @@ -179,6 +181,7 @@ export function openTerminal(pane: ManagedPaneInternal): void { terminal, xtermContainer, linkTooltip, + terminalTuiScrollSensitivity, fitAddon, searchAddon, serializeAddon, @@ -197,6 +200,9 @@ export function openTerminal(pane: ManagedPaneInternal): void { terminal.loadAddon(serializeAddon) terminal.loadAddon(unicode11Addon) terminal.loadAddon(webLinksAddon) + attachTerminalMouseWheelMultiplier(terminal, { + getTuiMouseWheelMultiplier: terminalTuiScrollSensitivity + }) // Activate Orca's Unicode 11 width shim *before* any caller-driven write. CJK / emoji / // ZWJ codepoints get baked into the buffer at the active unicode version on @@ -280,6 +286,9 @@ export function attachLigatures(pane: ManagedPaneInternal): void { const ligaturesAddon = new LigaturesAddon() pane.terminal.loadAddon(ligaturesAddon) pane.ligaturesAddon = ligaturesAddon + // Why: ligatures can be enabled after rows already rendered, especially + // from Settings. Force existing glyph runs to be recomputed immediately. + pane.terminal.refresh(0, pane.terminal.rows - 1) // Why: the WebGL renderer builds its glyph texture atlas at activation // time, so `font-feature-settings` applied after WebGL loaded won't // reach the GPU-rendered cells until the atlas is rebuilt. The upstream diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 6213b32003b..fb95e66b524 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -38,6 +38,7 @@ export type PaneManagerOptions = { * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void terminalOptions?: (paneId: number) => Partial + terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void initialRenderingSuspended?: boolean terminalGpuAcceleration?: GlobalSettings['terminalGpuAcceleration'] @@ -104,6 +105,7 @@ export type ScrollState = { export type ManagedPaneInternal = { xtermContainer: HTMLElement linkTooltip: HTMLElement + terminalTuiScrollSensitivity?: () => number | undefined terminalGpuAcceleration: GlobalSettings['terminalGpuAcceleration'] gpuRenderingEnabled: boolean webglAttachmentDeferred: boolean diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts new file mode 100644 index 00000000000..5ced0c616d0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + normalizeTerminalTuiMouseWheelMultiplier, + shouldMultiplyTerminalMouseWheel +} from './pane-terminal-mouse-wheel' + +const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 + +function terminalElement(mouseReporting = true): HTMLElement { + return { + classList: { + contains: (className: string) => mouseReporting && className === 'enable-mouse-events' + } + } as HTMLElement +} + +function wheelEvent(init: Partial = {}): WheelEvent { + return { + deltaY: 100, + deltaMode: DOM_DELTA_PIXEL, + ...init + } as WheelEvent +} + +describe('terminal mouse wheel multiplier', () => { + it('uses a three-report multiplier for TUI mouse wheel scrolling', () => { + expect(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER).toBe(3) + }) + + it('normalizes TUI wheel multipliers to the supported report range', () => { + expect(normalizeTerminalTuiMouseWheelMultiplier(undefined)).toBe(3) + expect(normalizeTerminalTuiMouseWheelMultiplier(0)).toBe(1) + expect(normalizeTerminalTuiMouseWheelMultiplier(4.4)).toBe(4) + expect(normalizeTerminalTuiMouseWheelMultiplier(20)).toBe(10) + }) + + it('multiplies discrete wheel events when mouse reporting is active', () => { + expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement())).toBe(true) + }) + + it('leaves normal terminal scrollback alone', () => { + expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false) + }) + + it('leaves trackpad-like pixel scrolling one-to-one', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + deltaY: 12, + deltaMode: DOM_DELTA_PIXEL + }), + terminalElement() + ) + ).toBe(false) + }) + + it('multiplies non-pixel wheel deltas as discrete input', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + deltaY: 1, + deltaMode: DOM_DELTA_LINE + }), + terminalElement() + ) + ).toBe(true) + }) + + it('ignores horizontal shift-wheel events', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + shiftKey: true + }), + terminalElement() + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts new file mode 100644 index 00000000000..6e3ca37e996 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -0,0 +1,124 @@ +import type { Terminal } from '@xterm/xterm' + +const XTERM_MOUSE_REPORTING_CLASS = 'enable-mouse-events' +const REPLAYED_WHEEL_EVENT_PROPERTY = '__orcaReplayedTerminalWheelEvent' +const DOM_DELTA_PIXEL = 0 + +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 3 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 + +type TerminalWheelTarget = Pick + +type TerminalMouseWheelMultiplierOptions = { + getTuiMouseWheelMultiplier?: () => number | undefined +} + +type ReplayedWheelEvent = WheelEvent & { + [REPLAYED_WHEEL_EVENT_PROPERTY]?: boolean +} + +function isReplayedWheelEvent(event: WheelEvent): boolean { + return (event as ReplayedWheelEvent)[REPLAYED_WHEEL_EVENT_PROPERTY] === true +} + +function markReplayedWheelEvent(event: WheelEvent): void { + Object.defineProperty(event, REPLAYED_WHEEL_EVENT_PROPERTY, { + configurable: true, + value: true + }) +} + +function isDiscreteWheelEvent(event: WheelEvent): boolean { + if (event.deltaMode !== DOM_DELTA_PIXEL) { + return true + } + + return Math.abs(event.deltaY) >= 50 +} + +export function shouldMultiplyTerminalMouseWheel( + event: WheelEvent, + terminalElement: HTMLElement | null | undefined +): boolean { + if ( + isReplayedWheelEvent(event) || + !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || + event.deltaY === 0 || + event.shiftKey || + !isDiscreteWheelEvent(event) + ) { + return false + } + + return true +} + +function cloneWheelEvent(event: WheelEvent): WheelEvent { + const clone = new WheelEvent(event.type, { + bubbles: event.bubbles, + cancelable: event.cancelable, + composed: event.composed, + view: event.view, + detail: event.detail, + screenX: event.screenX, + screenY: event.screenY, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + button: event.button, + buttons: event.buttons, + relatedTarget: event.relatedTarget, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaZ: event.deltaZ, + deltaMode: event.deltaMode + }) + markReplayedWheelEvent(clone) + return clone +} + +export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER + } + return Math.round( + Math.min( + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) + ) + ) +} + +export function attachTerminalMouseWheelMultiplier( + terminal: TerminalWheelTarget, + options: TerminalMouseWheelMultiplierOptions = {} +): void { + terminal.attachCustomWheelEventHandler((event) => { + if (!shouldMultiplyTerminalMouseWheel(event, terminal.element)) { + return true + } + + const target = + event.currentTarget instanceof EventTarget ? event.currentTarget : terminal.element + if (!target) { + return true + } + + // Why: mouse-reporting TUIs receive wheel input as reports, not viewport + // scrollback, so normal xterm scrollSensitivity cannot tune their speed. + queueMicrotask(() => { + const multiplier = normalizeTerminalTuiMouseWheelMultiplier( + options.getTuiMouseWheelMultiplier?.() + ) + for (let i = 1; i < multiplier; i++) { + target.dispatchEvent(cloneWheelEvent(event)) + } + }) + + return true + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 82418eaff4a..8c154468331 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -3,6 +3,21 @@ import type { ITerminalOptions } from '@xterm/xterm' type TerminalCursorStyle = NonNullable type TerminalCursorInactiveStyle = NonNullable +export const DEFAULT_TERMINAL_SCROLL_SENSITIVITY = 1.15 +export const DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY = 5 + +export function normalizeTerminalScrollSensitivity(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(10, Math.max(0.1, value)) + : DEFAULT_TERMINAL_SCROLL_SENSITIVITY +} + +export function normalizeTerminalFastScrollSensitivity(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(20, Math.max(1, value)) + : DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY +} + export function resolveTerminalCursorInactiveStyle( cursorStyle: TerminalCursorStyle | undefined ): TerminalCursorInactiveStyle { @@ -26,6 +41,10 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { fontWeight: '300', fontWeightBold: '500', scrollback: 10000, + // Why: Orca's default terminal cells are taller than many users' baseline + // terminal, so a small multiplier keeps row-per-wheel movement familiar. + scrollSensitivity: DEFAULT_TERMINAL_SCROLL_SENSITIVITY, + fastScrollSensitivity: DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, allowTransparency: false, // Why: agent CLIs sometimes render body text with ANSI white/bright-white // on light themes; xterm can keep those cells readable across renderers. diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index b6789ba992f..249e5dda8b6 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -13,7 +13,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: null, cwd: 'C:\\repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({ windowsPty: { backend: 'conpty', buildNumber: 26100 } @@ -27,7 +28,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: 'bad-release', connectionId: null, cwd: 'C:\\repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({ windowsPty: { backend: 'conpty' } @@ -41,7 +43,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: 'ssh-1', cwd: 'C:\\repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({}) }) @@ -59,7 +62,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: null, cwd, - shellOverride: null + shellOverride: null, + executionHostId: 'local' }) ).toEqual({}) } @@ -72,7 +76,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '10.0.26100', connectionId: null, cwd: 'C:\\repo', - shellOverride: 'C:\\Windows\\System32\\wsl.exe' + shellOverride: 'C:\\Windows\\System32\\wsl.exe', + executionHostId: 'local' }) ).toEqual({}) }) @@ -84,7 +89,38 @@ describe('buildWindowsPtyCompatibilityOptions', () => { osRelease: '23.0.0', connectionId: null, cwd: '/repo', - shellOverride: null + shellOverride: null, + executionHostId: 'local' + }) + ).toEqual({}) + }) + + it('does NOT return ConPTY options for a serve/remote-runtime pane even when the raw Windows heuristic matches', () => { + // Regression: a serve pane on a Windows client has no SSH connectionId and a + // Linux cwd, so the raw heuristic matches; the execution-host gate must still + // exclude it so a remote Linux PTY is not given the native-Windows ConPTY backend. + const serveContext = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: '/home/me/workspaces/repo', + shellOverride: null + } as const + expect(isLocalNativeWindowsPty(serveContext)).toBe(true) + expect( + buildWindowsPtyCompatibilityOptions({ ...serveContext, executionHostId: 'runtime:my-serve' }) + ).toEqual({}) + }) + + it('does NOT return ConPTY options for an SSH-runtime pane', () => { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: null, + executionHostId: 'ssh:my-host' }) ).toEqual({}) }) diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 62f8362d194..5886dd84307 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -31,10 +31,18 @@ function parseWindowsBuildNumber(osRelease: string | null | undefined): number | return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined } +/** + * xterm options that select the native-Windows ConPTY backend, returned only for + * a genuine local Windows pane and `{}` otherwise. + * + * Why it requires `executionHostId`: a serve/remote-runtime pane on a Windows + * client looks local to the raw heuristic (no SSH `connectionId`, Linux `cwd`), + * so gating on the execution host keeps the ConPTY backend off remote PTYs. + */ export function buildWindowsPtyCompatibilityOptions( - context: WindowsPtyCompatibilityContext + context: WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId } ): Partial { - if (!isLocalNativeWindowsPty(context)) { + if (!isLocalNativeWindowsConpty(context)) { return {} } const buildNumber = parseWindowsBuildNumber(context.osRelease) @@ -46,6 +54,11 @@ export function buildWindowsPtyCompatibilityOptions( } } +/** + * Raw client-side heuristic for a native-Windows ConPTY pane (Windows UA, no SSH + * connection, non-WSL cwd/shell). Necessary but not sufficient: it cannot tell a + * local pane from a serve pane, so callers gate it with `isLocalNativeWindowsConpty`. + */ export function isLocalNativeWindowsPty(context: WindowsPtyCompatibilityContext): boolean { if (!isWindowsUserAgent(context.userAgent)) { return false diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index 8cbeab85a86..f0ea8dd30d0 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -1,45 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => { - const state = { - tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record, - browserTabsByWorktree: {} as Record, - openFiles: [] as { worktreeId: string }[] - } - const activateAndRevealWorktree = vi.fn() - const activateAndRevealFolderWorkspace = vi.fn() - const markInputQuietSchedulerInput = vi.fn() - const pendingCallbacks: (() => void)[] = [] - const pendingCancels: ReturnType[] = [] - const scheduleAfterInputQuiet = vi.fn((callback: () => void) => { - let cancelled = false - const cancel = vi.fn(() => { - cancelled = true - }) - pendingCallbacks.push(() => { - if (!cancelled) { - callback() - } - }) - pendingCancels.push(cancel) - return cancel - }) - return { - activateAndRevealWorktree, - activateAndRevealFolderWorkspace, - markInputQuietSchedulerInput, - pendingCallbacks, - pendingCancels, - scheduleAfterInputQuiet, - state - } -}) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mocks.state - } +const mocks = vi.hoisted(() => ({ + activateAndRevealFolderWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn() })) vi.mock('@/lib/worktree-activation', () => ({ @@ -47,54 +10,32 @@ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activateAndRevealWorktree })) -vi.mock('@/lib/input-quiet-scheduler', () => ({ - markInputQuietSchedulerInput: mocks.markInputQuietSchedulerInput, - scheduleAfterInputQuiet: mocks.scheduleAfterInputQuiet -})) - -import { - activateWorktreeFromSidebar, - cancelPendingSidebarWorktreeActivation -} from './sidebar-worktree-activation' +import { activateWorktreeFromSidebar } from './sidebar-worktree-activation' describe('sidebar worktree activation', () => { beforeEach(() => { - delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ - cancelPendingSidebarWorktreeActivation() mocks.activateAndRevealWorktree.mockClear() mocks.activateAndRevealFolderWorkspace.mockClear() - mocks.markInputQuietSchedulerInput.mockClear() - mocks.scheduleAfterInputQuiet.mockClear() - mocks.pendingCallbacks.length = 0 - mocks.pendingCancels.length = 0 - mocks.state.tabsByWorktree = {} - mocks.state.ptyIdsByTabId = {} - mocks.state.browserTabsByWorktree = {} - mocks.state.openFiles = [] }) - it('cancels a queued slept-workspace activation', () => { - mocks.state.tabsByWorktree = { 'wt-parent': [{ id: 'tab-1' }] } - mocks.state.ptyIdsByTabId = { 'tab-1': [] } - - activateWorktreeFromSidebar('wt-parent') - cancelPendingSidebarWorktreeActivation() - - expect(mocks.pendingCancels[0]).toHaveBeenCalledTimes(1) - mocks.pendingCallbacks[0]?.() - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - }) - - it('does not defer a workspace with a live PTY', () => { - mocks.state.tabsByWorktree = { 'wt-live': [{ id: 'tab-1' }] } - mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-1'] } - + it('activates a clicked worktree immediately without sidebar reveal', () => { activateWorktreeFromSidebar('wt-live') - expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled() expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-live', { revealInSidebar: false }) + expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled() + }) + + it('does not defer slept worktree selection behind terminal wake work', () => { + activateWorktreeFromSidebar('wt-slept') + + // Why: setActiveWorktree already defers terminal prep where needed. The + // sidebar click itself must switch app state immediately. + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-slept', { + revealInSidebar: false + }) }) it('routes folder workspace activation through the guarded folder path', () => { @@ -102,19 +43,5 @@ describe('sidebar worktree activation', () => { expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1') expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled() - }) - - it('does not defer slept workspace activation in the web client', () => { - ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true - mocks.state.tabsByWorktree = { 'wt-web-slept': [{ id: 'tab-1' }] } - mocks.state.ptyIdsByTabId = { 'tab-1': [] } - - activateWorktreeFromSidebar('wt-web-slept') - - expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled() - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-web-slept', { - revealInSidebar: false - }) }) }) diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index 8c7eec2df13..4c47fe18c1c 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -1,75 +1,17 @@ -import { useAppStore } from '@/store' import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -const SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS = 450 -const SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS = 120 - -let pendingSidebarWorktreeActivation: { - worktreeId: string - cancel: () => void -} | null = null - -export function cancelPendingSidebarWorktreeActivation(): void { - pendingSidebarWorktreeActivation?.cancel() - pendingSidebarWorktreeActivation = null -} - -function shouldDeferSidebarWorktreeActivation(worktreeId: string): boolean { - // Why: web clients should activate immediately to avoid host/session churn and wake lag. - if ((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true) { - return false - } - const state = useAppStore.getState() - const tabs = state.tabsByWorktree[worktreeId] ?? [] - if (tabs.length === 0) { - return false - } - if ((state.browserTabsByWorktree[worktreeId] ?? []).length > 0) { - return false - } - if (state.openFiles.some((file) => file.worktreeId === worktreeId)) { - return false - } - return tabs.every((tab) => !tabHasLivePty(state.ptyIdsByTabId, tab.id)) -} - export function activateWorktreeFromSidebar(worktreeId: string): void { - cancelPendingSidebarWorktreeActivation() const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) return } - const activate = (): void => { - if (pendingSidebarWorktreeActivation?.worktreeId === worktreeId) { - pendingSidebarWorktreeActivation = null - } - // Why: sidebar clicks already happen on a visible row; revealing again can - // jump duplicate pinned/canonical entries back to the first mounted copy. - activateAndRevealWorktree(worktreeId, { revealInSidebar: false }) - } - - if (!shouldDeferSidebarWorktreeActivation(worktreeId)) { - activate() - return - } - - markInputQuietSchedulerInput() - // Why: a slept workspace may remount terminals. Keep that work cancellable so - // a quick "changed my mind" click is never queued behind the first wake. - pendingSidebarWorktreeActivation = { - worktreeId, - cancel: scheduleAfterInputQuiet(activate, { - delayMs: 0, - quietMs: SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS, - idleTimeoutMs: SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS - }) - } + // Why: sidebar clicks already happen on a visible row; revealing again can + // jump duplicate pinned/canonical entries back to the first mounted copy. + activateAndRevealWorktree(worktreeId, { revealInSidebar: false }) } diff --git a/src/renderer/src/lib/source-control-commit-draft-session.ts b/src/renderer/src/lib/source-control-commit-draft-session.ts new file mode 100644 index 00000000000..45811235d0a --- /dev/null +++ b/src/renderer/src/lib/source-control-commit-draft-session.ts @@ -0,0 +1,20 @@ +type CommitDraftsByWorktree = Record + +let sessionCommitDraftsByWorktree: CommitDraftsByWorktree = {} + +export function loadSessionCommitDrafts(): CommitDraftsByWorktree { + return sessionCommitDraftsByWorktree +} + +export function saveSessionCommitDrafts(nextDrafts: CommitDraftsByWorktree): void { + sessionCommitDraftsByWorktree = nextDrafts +} + +export function clearSessionCommitDraftForWorktree(worktreeId: string): void { + if (!(worktreeId in sessionCommitDraftsByWorktree)) { + return + } + const next = { ...sessionCommitDraftsByWorktree } + delete next[worktreeId] + sessionCommitDraftsByWorktree = next +} diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 052777529c4..576a2a341ad 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -32,6 +32,7 @@ function buildStartupOpt( command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}), launchConfig: plan.launchConfig, + ...(plan.launchToken ? { launchToken: plan.launchToken } : {}), ...(request.agent ? { launchAgent: request.agent } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), // Why: command-code shows its prompt in the tab status before the first @@ -144,6 +145,11 @@ async function executeWorktreeCreation( } const backendSpawned = result.startupTerminal?.spawned === true + if (request.startupPlan && !backendSpawned && !request.startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this queued + // startup, so both halves of the handoff share one renderer-session token. + request.startupPlan.launchToken = createBrowserUuid() + } const startupOpt = buildStartupOpt(request, backendSpawned) if (worktree.path) { diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 849cb4dfa34..35bb8eb522c 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -3,6 +3,7 @@ import { getExplicitRuntimeEnvironmentIdForWorktree, getExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds, getSettingsForWorktreeRuntimeOwner, type WorktreeRuntimeOwnerState } from './worktree-runtime-owner' @@ -118,3 +119,45 @@ describe('getExplicitRuntimeEnvironmentIdForWorktree', () => { ).toBe('runtime:worktree-env') }) }) + +describe('getRuntimeSessionMirrorEnvironmentIds', () => { + it('includes focused runtime plus explicit repo, worktree, and folder owners', () => { + const multiRuntimeState: WorktreeRuntimeOwnerState = { + ...state, + worktreesByRepo: { + ...state.worktreesByRepo, + 'runtime-repo': [ + ...(state.worktreesByRepo?.['runtime-repo'] ?? []), + { + id: 'runtime-repo::wt-runtime-override', + repoId: 'runtime-repo', + hostId: 'runtime:worktree-env' + } + ] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(multiRuntimeState)).toEqual([ + 'focused-env', + 'folder-env', + 'owner-env', + 'worktree-env' + ]) + }) + + it('does not include local or SSH owners', () => { + const localOnlyState: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'ssh-repo', connectionId: 'remote', executionHostId: 'ssh:remote' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-local', repoId: 'local-repo', hostId: 'local' }], + 'ssh-repo': [{ id: 'ssh-repo::wt-ssh', repoId: 'ssh-repo', hostId: 'ssh:remote' }] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index e8c0e50f795..be54a7b9eb2 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -162,6 +162,35 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( return getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) } +export function getRuntimeSessionMirrorEnvironmentIds(state: WorktreeRuntimeOwnerState): string[] { + const ids = new Set() + const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + if (activeRuntimeEnvironmentId) { + ids.add(activeRuntimeEnvironmentId) + } + for (const repo of state.repos ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) + if (environmentId) { + ids.add(environmentId) + } + } + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { + for (const worktree of worktrees) { + const environmentId = getRuntimeEnvironmentIdFromWorktreeHost(worktree.hostId) + if (environmentId) { + ids.add(environmentId) + } + } + } + for (const group of state.projectGroups ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(group.executionHostId) + if (environmentId) { + ids.add(environmentId) + } + } + return [...ids].sort() +} + export function getExecutionHostIdForWorktree( state: WorktreeRuntimeOwnerState, worktreeId: string | null | undefined diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 90293c7ec7f..687b18e24fa 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -306,19 +306,17 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe(false) }) - it('only starts the all-session mirror for paired web clients', () => { + it('starts the all-session mirror for desktop and paired web clients', () => { expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: true + workspaceSessionReady: true }) ).toBe(true) expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: false + workspaceSessionReady: false }) ).toBe(false) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 43fc267001c..8ec5c326daf 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -32,7 +32,10 @@ import type { OpenFile } from '../store/slices/editor' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds +} from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal, HOST_TERMINAL_SURFACE_SEPARATOR, @@ -255,10 +258,9 @@ export function shouldSyncRuntimeSessionTabs(args: { export function shouldSyncAllRuntimeSessionTabs(args: { activeRuntimeEnvironmentId: string | null | undefined workspaceSessionReady: boolean - isWebClient: boolean }): boolean { const environmentId = args.activeRuntimeEnvironmentId?.trim() - return Boolean(environmentId && args.workspaceSessionReady && args.isWebClient) + return Boolean(environmentId && args.workspaceSessionReady) } export function resetWebSessionTabsSnapshotFreshnessForTests(): void { @@ -2328,134 +2330,142 @@ export function applyFreshWebSessionTabsSnapshots( export function useWebSessionTabsSync(): void { const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const runtimeSessionMirrorEnvironmentKey = useAppStore((state) => + getRuntimeSessionMirrorEnvironmentIds(state).join('\u0000') ) const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) ) const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true useEffect(() => { - const environmentId = activeRuntimeEnvironmentId?.trim() + const environmentIds = runtimeSessionMirrorEnvironmentKey + ? runtimeSessionMirrorEnvironmentKey.split('\u0000').filter((id) => id.trim()) + : [] // Why: startup hydration writes browser-local session state; applying the // host snapshot before that point gets clobbered and leaves the sidebar stale. - // Desktop clients should not mirror every remote session just because a - // remote is connected; project discovery runs through separate repo APIs. - if ( - !shouldSyncAllRuntimeSessionTabs({ - activeRuntimeEnvironmentId, - workspaceSessionReady, - isWebClient - }) || - !environmentId - ) { + // Selectedness is not liveness: desktop and web clients both mirror the + // runtime's session bindings so background worktrees do not look asleep. + if (!workspaceSessionReady || environmentIds.length === 0) { return } let disposed = false - let unsubscribe: (() => void) | null = null + const unsubscribes: (() => void)[] = [] // Why: the streaming RPC emits an initial snapshots event, but startup can // render a paired web session before that event is applied. A one-shot // fetch makes initial parity deterministic; the stream remains the live // update path afterward. - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'session.tabs.listAll', - params: {}, - timeoutMs: 15_000 - }) - .then((response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) - return - } - const result = response.result - if (!isSessionTabsListAllResult(result)) { - console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) - ) - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to load initial session tabs:', - error instanceof Error ? error.message : String(error) - ) - } - }) - - void window.api.runtimeEnvironments - .subscribe( - { + for (const environmentId of environmentIds) { + if ( + !shouldSyncAllRuntimeSessionTabs({ + activeRuntimeEnvironmentId: environmentId, + workspaceSessionReady + }) + ) { + continue + } + void window.api.runtimeEnvironments + .call({ selector: environmentId, - method: 'session.tabs.subscribeAll', + method: 'session.tabs.listAll', params: {}, timeoutMs: 15_000 - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn( - '[web-session-tabs-sync] global subscription failed:', - response.error.message - ) - return - } - const event = response.result as SessionTabsStreamEvent - if (event.type === 'snapshots') { - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) - ) - return - } - if (event.type !== 'snapshot' && event.type !== 'updated') { - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshot(state, event, environmentId) - ) - }, - onError: (error) => { - console.warn('[web-session-tabs-sync] global subscription error:', error.message) + }) + .then((response: RuntimeRpcResponse) => { + if (disposed) { + return } - } - ) - .then((handle) => { - if (disposed) { - handle.unsubscribe() - return - } - unsubscribe = handle.unsubscribe - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to subscribe globally:', - error instanceof Error ? error.message : String(error) + if (response.ok === false) { + console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) + return + } + const result = response.result + if (!isSessionTabsListAllResult(result)) { + console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) ) - } - }) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to load initial session tabs:', + error instanceof Error ? error.message : String(error) + ) + } + }) + + void window.api.runtimeEnvironments + .subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribeAll', + params: {}, + timeoutMs: 15_000 + }, + { + onResponse: (response: RuntimeRpcResponse) => { + if (disposed) { + return + } + if (response.ok === false) { + console.warn( + '[web-session-tabs-sync] global subscription failed:', + response.error.message + ) + return + } + const event = response.result as SessionTabsStreamEvent + if (event.type === 'snapshots') { + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) + ) + return + } + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshot(state, event, environmentId) + ) + }, + onError: (error) => { + console.warn('[web-session-tabs-sync] global subscription error:', error.message) + } + } + ) + .then((handle) => { + if (disposed) { + handle.unsubscribe() + return + } + unsubscribes.push(handle.unsubscribe) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to subscribe globally:', + error instanceof Error ? error.message : String(error) + ) + } + }) + } return () => { disposed = true - unsubscribe?.() + for (const unsubscribe of unsubscribes) { + unsubscribe() + } // Why: environment ids can churn as paired runtimes reconnect or switch; // stale freshness/mapping entries should not live for the renderer lifetime. - clearWebSessionTabsTrackingForEnvironment(environmentId) + for (const environmentId of environmentIds) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } } - }, [activeRuntimeEnvironmentId, isWebClient, workspaceSessionReady]) + }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) useEffect(() => { const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index ff6406b5edb..6fcb98ab180 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -158,14 +158,23 @@ describe('store selectors', () => { sortOrder: 2, createdAt: 3 }, + { + id: 'simulator-1', + entityId: 'simulator-1', + worktreeId, + contentType: 'simulator', + label: 'Mobile Emulator', + sortOrder: 3, + createdAt: 4 + }, { id: 'unified-stale-terminal', entityId: 'missing-term', worktreeId, contentType: 'terminal', label: 'Stale', - sortOrder: 3, - createdAt: 4 + sortOrder: 4, + createdAt: 5 } ] as AppState['unifiedTabsByWorktree'][string] const state = { @@ -175,10 +184,10 @@ describe('store selectors', () => { unifiedTabsByWorktree: { [worktreeId]: unifiedTabs } } satisfies Parameters[0] - expect(selectFloatingVisibleTabCount(state)).toBe(3) + expect(selectFloatingVisibleTabCount(state)).toBe(4) expect(openFileScans).toBe(1) - expect(selectFloatingVisibleTabCount({ ...state })).toBe(3) + expect(selectFloatingVisibleTabCount({ ...state })).toBe(4) expect(openFileScans).toBe(1) }) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 53a03d2e3d9..cadec9abe45 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -224,6 +224,10 @@ export function selectFloatingVisibleTabCount(state: FloatingVisibleTabCountStat count += terminalIds.has(tab.entityId) ? 1 : 0 } else if (tab.contentType === 'browser') { count += browserIds.has(tab.entityId) ? 1 : 0 + } else if (tab.contentType === 'simulator') { + // Why: simulator unified tabs have no separate backing record; the tab + // itself is the visible floating workspace item. + count += 1 } else { count += editorIds.has(tab.entityId) ? 1 : 0 } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 6554ce392d4..0ef08fccd9b 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -451,6 +451,23 @@ describe('createDetectedAgentsSlice remote detection', () => { expect(detectRemoteAgents).toHaveBeenCalledTimes(1) }) + it('re-runs remote detection after an empty result instead of pinning it', async () => { + const store = createTestStore() + // An empty [] is truthy, so a prior "no agents found" must not be cached: + // a later install / PATH fix has to be picked up without a reconnect. + detectRemoteAgents.mockResolvedValueOnce([]) + + await expect(store.getState().ensureRemoteDetectedAgents('ssh-1')).resolves.toEqual([]) + expect(store.getState().remoteDetectedAgentIds['ssh-1']).toEqual([]) + expect(detectRemoteAgents).toHaveBeenCalledTimes(1) + + detectRemoteAgents.mockResolvedValueOnce(['kilo']) + + await expect(store.getState().ensureRemoteDetectedAgents('ssh-1')).resolves.toEqual(['kilo']) + expect(detectRemoteAgents).toHaveBeenCalledTimes(2) + expect(store.getState().remoteDetectedAgentIds['ssh-1']).toEqual(['kilo']) + }) + it('detects runtime environment agents through the owning runtime', async () => { const store = createTestStore() @@ -478,4 +495,43 @@ describe('createDetectedAgentsSlice remote detection', () => { timeoutMs: undefined }) }) + + it('re-runs runtime detection after an empty result instead of pinning it', async () => { + const store = createTestStore() + // An empty [] is truthy, so a prior "no agents found" must not be cached: + // a later install / PATH fix has to be picked up without a reconnect. This is + // the Orca-serve path (kind: 'runtime'), distinct from the SSH remote path. + let detectCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + let result: unknown + if (method === 'status.get') { + result = { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + } else { + detectCalls += 1 + result = detectCalls === 1 ? [] : ['kilo'] + } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect(store.getState().ensureRuntimeDetectedAgents('env-1')).resolves.toEqual([]) + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual([]) + + await expect(store.getState().ensureRuntimeDetectedAgents('env-1')).resolves.toEqual(['kilo']) + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['kilo']) + expect(detectCalls).toBe(2) + }) }) diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index f84c42a4966..4c6cdf3725b 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -185,7 +185,10 @@ export const createDetectedAgentsSlice: StateCreator { const existing = get().remoteDetectedAgentIds[connectionId] - if (existing) { + // Why: an empty result ([]) is truthy, so a prior "no agents found" detection + // must not be treated as cached — re-detect so a later install / PATH fix is + // picked up without a reconnect. Non-empty results still short-circuit. + if (existing?.length) { return Promise.resolve(existing) } const inflight = remoteDetectPromises.get(connectionId) @@ -242,7 +245,10 @@ export const createDetectedAgentsSlice: StateCreator { const existing = get().runtimeDetectedAgentIds[environmentId] - if (existing) { + // Why: an empty result ([]) is truthy, so a prior "no agents found" detection + // must not be treated as cached — re-detect so a later install / PATH fix is + // picked up without a reconnect. Non-empty results still short-circuit. + if (existing?.length) { return Promise.resolve(existing) } const inflight = runtimeDetectPromises.get(environmentId) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 6423b997134..315f4740310 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -11,7 +11,12 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { GitStatusEntry, Tab } from '../../../../shared/types' +import type { + GitBranchChangeEntry, + GitBranchCompareSummary, + GitStatusEntry, + Tab +} from '../../../../shared/types' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -2648,6 +2653,80 @@ describe('createEditorSlice combined diff exclusions', () => { }) ) }) + + it('includes untracked files in the all changes snapshot', () => { + const store = createEditorStore() + const stagedEntry: GitStatusEntry = { + path: 'src/staged.ts', + status: 'modified', + area: 'staged' + } + const untrackedEntry: GitStatusEntry = { + path: 'src/new.ts', + status: 'untracked', + area: 'untracked' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [stagedEntry, untrackedEntry] + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + uncommittedEntriesSnapshot: [stagedEntry, untrackedEntry] + }) + ) + }) + + it('opens all changes with uncommitted and committed branch snapshots', () => { + const store = createEditorStore() + const localEntry: GitStatusEntry = { + path: 'src/local.ts', + status: 'modified', + area: 'unstaged' + } + const branchEntry: GitBranchChangeEntry = { + path: 'src/committed.ts', + status: 'modified' + } + const branchSummary: GitBranchCompareSummary = { + baseRef: 'origin/main', + baseOid: 'base-oid', + compareRef: 'HEAD', + headOid: 'head-oid', + mergeBase: 'merge-base-oid', + changedFiles: 1, + status: 'ready' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [localEntry] + }) + store.setState({ + gitBranchCompareSummaryByWorktree: { 'wt-1': branchSummary }, + gitBranchChangesByWorktree: { 'wt-1': [branchEntry] } + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + diffSource: 'combined-all', + uncommittedEntriesSnapshot: [localEntry], + branchEntriesSnapshot: [branchEntry], + branchCompare: expect.objectContaining({ + baseRef: 'origin/main', + baseOid: 'base-oid', + headOid: 'head-oid', + mergeBase: 'merge-base-oid' + }) + }) + ) + }) }) describe('createEditorSlice remote branch actions', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index fa2d9ea1d63..3780be8c6e3 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -94,6 +94,7 @@ export type DiffSource = | 'staged' | 'branch' | 'commit' + | 'combined-all' | 'combined-uncommitted' | 'combined-branch' | 'combined-commit' @@ -155,7 +156,7 @@ type CommitCompareLike = Pick< } type CombinedDiffAlternate = { - source: 'combined-uncommitted' | 'combined-branch' + source: 'combined-all' | 'combined-branch' branchCompare?: BranchCompareSnapshot } @@ -2657,13 +2658,22 @@ export const createEditorSlice: StateCreator = (s ] ?? 'All Changes') : 'All Changes' set((s) => { + const branchSummary = s.gitBranchCompareSummaryByWorktree[worktreeId] + const branchCompare = + !areaFilter && + branchSummary?.status === 'ready' && + branchSummary.baseOid && + branchSummary.headOid && + branchSummary.mergeBase + ? toBranchCompareSnapshot(branchSummary) + : undefined + const branchEntriesSnapshot = branchCompare + ? (s.gitBranchChangesByWorktree[worktreeId] ?? []) + : undefined const relevantEntries = entriesSnapshot ?? (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => { - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) const skippedConflicts = relevantEntries .filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind) @@ -2687,6 +2697,9 @@ export const createEditorSlice: StateCreator = (s f.id === id ? { ...f, + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, @@ -2710,7 +2723,9 @@ export const createEditorSlice: StateCreator = (s language: 'plaintext', isDirty: false, mode: 'diff', - diffSource: 'combined-uncommitted', + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, diff --git a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts new file mode 100644 index 00000000000..e2a0839f9ee --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createGitHubSlice } from './github' +import { createHostedReviewSlice } from './hosted-review' +import type { AppState } from '../types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' + +// Why: regression guard for the renderer OOM crash. Enqueuing the local +// `gh:enqueuePRRefresh` for a repo owned by a remote/SSH/runtime host rejects +// with "Access denied: unknown repository path"; a flood of those failures grew +// the renderer heap to the V8 ceiling and crashed it. enqueueGitHubPRRefresh +// must only hit the local handler for local-host repos. +const enqueuePRRefresh = vi.fn().mockResolvedValue(undefined) + +const mockApi = { + gh: { + prForBranch: vi.fn().mockResolvedValue(null), + enqueuePRRefresh, + issue: vi.fn().mockResolvedValue(null) + }, + hostedReview: { forBranch: vi.fn().mockResolvedValue(null) }, + runtimeEnvironments: { call: vi.fn() } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function createTestStore() { + return create()( + (...a) => + ({ + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + }) as AppState + ) +} + +function seed(store: ReturnType, repo: Record) { + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + repos: [repo], + worktreesByRepo: { + [repo.id as string]: [ + { + id: 'wt-1', + repoId: repo.id, + path: `${repo.path}/wt`, + branch: 'refs/heads/feature', + displayName: 'feature', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedPR: null, + linkedIssue: null + } + ] + }, + prCache: {}, + issueCache: {}, + hostedReviewCache: {}, + sshConnectionStates: new Map() + } as unknown as Partial) +} + +describe('enqueueGitHubPRRefresh host guard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('enqueues the local handler for a local-host repo', () => { + const store = createTestStore() + seed(store, { id: 'local-1', path: '/Users/me/code/local-1', name: 'local-1', kind: 'git' }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + }) + + it('enqueues the local handler for a repo with an explicit local executionHostId', () => { + const store = createTestStore() + seed(store, { + id: 'local-2', + path: '/Users/me/code/local-2', + name: 'local-2', + kind: 'git', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + }) + + it('does NOT enqueue the local handler for a runtime-host repo (the OOM loop)', () => { + const store = createTestStore() + seed(store, { + id: 'rt-1', + path: '/Users/lobster/orca/workspaces/openclaw/imessage-performance', + name: 'imessage-performance', + kind: 'git', + executionHostId: 'runtime:env-1' + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('does NOT enqueue the local handler for an SSH repo', () => { + const store = createTestStore() + seed(store, { + id: 'ssh-1', + path: '/home/me/code/ssh-1', + name: 'ssh-1', + kind: 'git', + connectionId: 'conn-1' + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts new file mode 100644 index 00000000000..d53beb70488 --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createGitHubSlice } from './github' +import { createHostedReviewSlice } from './hosted-review' +import type { AppState } from '../types' +import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const enqueuePRRefresh = vi.fn().mockResolvedValue(undefined) +const reportVisiblePRRefreshCandidates = vi.fn().mockResolvedValue(true) + +const mockApi = { + gh: { + prForBranch: vi.fn().mockResolvedValue(null), + refreshPRNow: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 }), + enqueuePRRefresh, + reportVisiblePRRefreshCandidates, + issue: vi.fn().mockResolvedValue(null) + }, + hostedReview: { forBranch: vi.fn().mockResolvedValue(null) }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + cache: { + getGitHub: vi.fn().mockResolvedValue(null), + setGitHub: vi.fn().mockResolvedValue(undefined) + } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function resetRuntimeMocks(): void { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) +} + +function createTestStore() { + return create()( + (...a) => + ({ + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + }) as AppState + ) +} + +function makePR(overrides: Partial = {}): PRInfo { + return { + number: 12, + title: 'Test PR', + state: 'open', + url: 'https://example.com/pr/12', + checksStatus: 'pending', + updatedAt: '2026-03-28T00:00:00Z', + mergeable: 'UNKNOWN', + headSha: 'head-oid', + ...overrides + } +} + +function makeRepo(overrides: Partial & Pick): Repo { + return { + displayName: overrides.id, + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeWorktree(repoId: string, branch: string, id = `${repoId}-wt`): Worktree { + return { + id, + repoId, + path: `/worktrees/${id}`, + head: 'head-oid', + branch, + displayName: branch, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + isMainWorktree: false, + isBare: false, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1 + } +} + +function seed( + store: ReturnType, + state: Pick & Partial +): void { + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + groupBy: 'pr-status', + worktreeCardProperties: ['status'], + prCache: {}, + issueCache: {}, + hostedReviewCache: {}, + commentsCache: {}, + sshConnectionStates: new Map(), + ...state + } as unknown as Partial) +} + +describe('GitHub PR refresh owner-host routing', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRuntimeMocks() + }) + + it('routes explicit PR refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 23 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/runtime-owner' + seed(store, { + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) + + store.getState().enqueueGitHubPRRefresh('wt-runtime', 'active', 80) + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) + + it('keeps connected SSH PR refresh on the local coordinator even when a runtime is focused', () => { + const store = createTestStore() + const repoPath = '/ssh/repo' + const branch = 'feature/ssh' + seed(store, { + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-ssh', + path: repoPath, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + ], + sshConnectionStates: new Map([ + ['ssh-1', { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 }] + ]), + worktreesByRepo: { + 'repo-ssh': [makeWorktree('repo-ssh', branch, 'wt-ssh')] + } + }) + + store.getState().refreshGitHubForWorktreeIfStale('wt-ssh') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(enqueuePRRefresh).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + repoId: 'repo-ssh', + repoPath, + branch, + connectionId: 'ssh-1', + connectionState: 'connected' + }), + reason: 'active', + priority: 80 + }) + }) + + it('routes post-push refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 24 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/post-push' + seed(store, { + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) + + store.getState().refreshGitHubForWorktree('wt-runtime') + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) + + it('splits visible candidates between local coordinator and runtime owner', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 25 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + seed(store, { + repos: [ + makeRepo({ id: 'repo-local', path: '/local/repo' }), + makeRepo({ + id: 'repo-runtime', + path: '/runtime/repo', + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-local': [makeWorktree('repo-local', 'feature/local', 'wt-local')], + 'repo-runtime': [makeWorktree('repo-runtime', 'feature/runtime', 'wt-runtime')] + } + }) + + store.getState().reportVisibleGitHubPRRefreshCandidates(['wt-local', 'wt-runtime'], 123) + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(reportVisiblePRRefreshCandidates).toHaveBeenCalledWith({ + candidates: [ + expect.objectContaining({ + repoId: 'repo-local', + repoPath: '/local/repo', + branch: 'feature/local' + }) + ], + generation: 123 + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) +}) diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 039550b7299..cfd032f969d 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -130,6 +130,46 @@ function getRuntimeRepoTarget( return repo ? { target, repo } : null } +function getPRRefreshOwnerRuntimeEnvironmentId( + candidate: Pick +): string | null { + const parsed = parseExecutionHostId(candidate.executionHostId) + if (parsed?.kind === 'runtime') { + return parsed.environmentId + } + const cacheScope = candidate.cacheKey.split('::', 1)[0] + const cacheScopeHost = parseExecutionHostId(cacheScope) + return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null +} + +function getPRRefreshRuntimeRepoTarget( + state: AppState, + candidate: GitHubPRRefreshCandidate +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate) + if (!ownerRuntimeEnvironmentId) { + return null + } + // Why: PR refreshes must follow the repo owner host, not the Active Server + // dropdown. A runtime-owned worktree can be visible while Local desktop is focused. + return getRuntimeRepoTarget( + state, + candidate.repoPath, + state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) + ) +} + +function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean { + // Why: the local PR coordinator owns local git and SSH bridge refreshes, but + // runtime-owned repos and disconnected SSH repos must not hit the IPC crash path. + if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) { + return false + } + return !candidate.connectionId || candidate.connectionState === 'connected' +} + type GitHubWorkItemRequestContext = { repoId: string repoPath: string @@ -3383,7 +3423,7 @@ export const createGitHubSlice: StateCreator = (s if (!candidate) { return } - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, @@ -3394,6 +3434,9 @@ export const createGitHubSlice: StateCreator = (s }) return } + if (!shouldEnqueueLocalPRRefresh(candidate)) { + return + } const enqueue = window.api.gh.enqueuePRRefresh if (enqueue) { void enqueue({ candidate, reason, priority }) @@ -3424,8 +3467,9 @@ export const createGitHubSlice: StateCreator = (s return worktree ? buildPRRefreshCandidate(state, worktree) : null }) .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) - if (getActiveRuntimeTarget(state.settings).kind === 'environment') { - for (const candidate of candidates) { + const localCandidates: GitHubPRRefreshCandidate[] = [] + for (const candidate of candidates) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, worktreeId: candidate.worktreeId, @@ -3433,12 +3477,15 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) + continue + } + if (shouldEnqueueLocalPRRefresh(candidate)) { + localCandidates.push(candidate) } - return } const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates if (reportVisible) { - void reportVisible({ candidates, generation }).catch((err) => { + void reportVisible({ candidates: localCandidates, generation }).catch((err) => { console.warn('Failed to report visible PR refresh candidates:', err) }) } @@ -3733,7 +3780,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 }) } } @@ -3797,7 +3844,7 @@ export const createGitHubSlice: StateCreator = (s if (!worktree.isBare && branch) { const candidate = buildPRRefreshCandidate(get(), worktree) if (candidate) { - if (getRuntimeRepoTarget(get(), candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, @@ -3806,7 +3853,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'post-push', priority: 100 }) } } @@ -3995,7 +4042,7 @@ export const createGitHubSlice: StateCreator = (s if (shouldRefreshPR && !worktree.isBare && branch) { const candidate = buildPRRefreshCandidate(state, worktree) if (candidate) { - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, @@ -4004,7 +4051,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 }) } } diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index ebe427f443b..f1dabcb24ca 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -391,14 +391,30 @@ describe('createJiraSlice credential errors', () => { }) }) - it('keeps Jira connected when an issue read hits endpoint-level forbidden access', async () => { + it('surfaces endpoint-level forbidden errors without disconnecting Jira', async () => { const store = createTestStore() store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) jiraListIssues.mockRejectedValueOnce(new Error('Forbidden')) - await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toEqual([]) + // A non-auth failure must reject so the Tasks panel can show a real error + // instead of a misleading empty list, while keeping the session connected. + await expect(store.getState().listJiraIssues('assigned', 30)).rejects.toThrow('Forbidden') + + expect(store.getState().jiraStatus.connected).toBe(true) + }) + + it('surfaces endpoint-level search errors without disconnecting Jira', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } + }) + jiraSearchIssues.mockRejectedValueOnce(new Error('Malformed JQL')) + + await expect(store.getState().searchJiraIssues('project =', 30)).rejects.toThrow( + 'Malformed JQL' + ) expect(store.getState().jiraStatus.connected).toBe(true) }) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 1498f312edd..fd084a3d985 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -496,7 +496,14 @@ export const createJiraSlice: StateCreator = (set, ) { set({ jiraStatus: { connected: false, viewer: null } }) } - return [] + // Credential/auth failures are surfaced through connection state, so they + // keep the empty-list contract. Other failures (forbidden, bad JQL, + // network, 5xx) reject so the Tasks panel can show a real error instead + // of a misleading "No issues found". + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + return [] + } + throw error }) .finally(() => { if (inflightSearchRequests.get(cacheKey) === entry) { @@ -583,7 +590,14 @@ export const createJiraSlice: StateCreator = (set, ) { set({ jiraStatus: { connected: false, viewer: null } }) } - return [] + // Credential/auth failures are surfaced through connection state, so they + // keep the empty-list contract. Other failures (forbidden, bad JQL, + // network, 5xx) reject so the Tasks panel can show a real error instead + // of a misleading "No issues found". + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + return [] + } + throw error }) .finally(() => { if (inflightListRequests.get(cacheKey) === entry) { diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts new file mode 100644 index 00000000000..6471776b327 --- /dev/null +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -0,0 +1,727 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { + FolderWorkspace, + Project, + ProjectHostSetup, + ProjectGroup, + Repo +} from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 1 +} + +const localProject: Project = { + id: 'local-project', + displayName: 'Local project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const localProjectHostSetup: ProjectHostSetup = { + id: 'local-setup', + projectId: 'local-project', + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local setup', + setupState: 'setting-up', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 +} + +const localProjectGroup: ProjectGroup = { + id: 'local-group', + name: 'Local group', + parentPath: '/local', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const remoteProjectGroup: ProjectGroup = { + id: 'remote-group', + name: 'Remote group', + parentPath: '/srv', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const localFolderWorkspace: FolderWorkspace = { + id: 'local-folder', + projectGroupId: 'local-group', + name: 'Local folder', + folderPath: '/local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const remoteFolderWorkspace: FolderWorkspace = { + id: 'remote-folder', + projectGroupId: 'remote-group', + name: 'Remote folder', + folderPath: '/srv', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const reposList = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const projectGroupsList = vi.fn() +const folderWorkspacesList = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const dispatchEventMock = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + projectGroupsList.mockReset() + folderWorkspacesList.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + dispatchEventMock.mockReset() + + reposList.mockResolvedValue([localRepo]) + projectsList.mockResolvedValue([localProject]) + listHostSetups.mockResolvedValue([localProjectHostSetup]) + projectGroupsList.mockResolvedValue([localProjectGroup]) + folderWorkspacesList.mockResolvedValue([localFolderWorkspace]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'lobster' }]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectGroup.list') { + return { + id: 'rpc-project-group-list', + ok: true, + result: { groups: [remoteProjectGroup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'folderWorkspace.list') { + return { + id: 'rpc-folder-workspace-list', + ok: true, + result: { folderWorkspaces: [remoteFolderWorkspace] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups: listHostSetups }, + projectGroups: { list: projectGroupsList }, + folderWorkspaces: { list: folderWorkspacesList }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: dispatchEventMock + }) +}) + +function configureSharedProjectCompatibilityMocks( + options: { + localRepoHasProviderIdentity?: boolean + remoteProjectRuntimePreference?: Project['localWindowsRuntimePreference'] + } = {} +): { + sharedProjectId: string + sharedRemoteProject: Project +} { + const sharedProjectId = 'github:stablyai/orca' + const localRepoForSharedProject: Repo = + options.localRepoHasProviderIdentity === false + ? localRepo + : { + ...localRepo, + upstream: { owner: 'stablyai', repo: 'orca' } + } + const remoteRepoWithIdentity: Repo = { + ...remoteRepo, + upstream: { owner: 'stablyai', repo: 'orca' } + } + const sharedLocalProject: Project = { + id: sharedProjectId, + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 1 + } + const sharedRemoteProject: Project = { + id: sharedProjectId, + displayName: 'Orca', + badgeColor: '#111', + sourceRepoIds: ['remote-repo'], + ...(options.remoteProjectRuntimePreference + ? { localWindowsRuntimePreference: options.remoteProjectRuntimePreference } + : {}), + createdAt: 2, + updatedAt: 2 + } + const sharedLocalSetup: ProjectHostSetup = { + ...localProjectHostSetup, + projectId: sharedProjectId + } + const sharedRemoteSetup: ProjectHostSetup = { + ...localProjectHostSetup, + id: 'remote-setup', + projectId: sharedProjectId, + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote setup' + } + reposList.mockResolvedValue([localRepoForSharedProject]) + projectsList.mockResolvedValue([sharedLocalProject]) + listHostSetups.mockResolvedValue([sharedLocalSetup]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepoWithIdentity] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list', + ok: true, + result: { projects: [sharedRemoteProject] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list', + ok: true, + result: { setups: [sharedRemoteSetup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + return { sharedProjectId, sharedRemoteProject } +} + +function expectSharedProjectMetadata(projects: readonly Project[], sharedProjectId: string): void { + const sharedProject = projects.find((project) => project.id === sharedProjectId) + expect([...(sharedProject?.sourceRepoIds ?? [])].sort()).toEqual(['local-repo', 'remote-repo']) + expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) +} + +describe('fetchReposForAllHosts', () => { + it('loads local + all configured runtime environments even when a remote env is active', async () => { + // Why: a cold start that restored a remote workspace leaves the remote + // environment active. The active-host-only fetchRepos would drop local + // repos entirely; fetchReposForAllHosts must surface both. + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + const ids = store + .getState() + .repos.map((repo) => repo.id) + .sort() + expect(ids).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects).toContainEqual(localProject) + expect(store.getState().projectHostSetups).toContainEqual(localProjectHostSetup) + }) + + it('preserves runtime-owned project metadata during a local-only repo refresh', async () => { + const runtimeProject: Project = { + id: 'repo:remote-repo', + displayName: 'Runtime API project', + badgeColor: '#abc', + sourceRepoIds: ['remote-repo'], + createdAt: 3, + updatedAt: 4 + } + const runtimeSetup: ProjectHostSetup = { + ...localProjectHostSetup, + id: 'runtime-setup', + projectId: runtimeProject.id, + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Runtime setup', + setupState: 'ready', + createdAt: 3, + updatedAt: 4 + } + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list', + ok: true, + result: { projects: [runtimeProject] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list', + ok: true, + result: { setups: [runtimeSetup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRepos() + + expect( + store + .getState() + .projects.map((project) => project.id) + .sort() + ).toEqual(['local-project', 'repo:remote-repo']) + expect(store.getState().projects.find((project) => project.id === runtimeProject.id)).toEqual( + expect.objectContaining({ + displayName: runtimeProject.displayName, + badgeColor: runtimeProject.badgeColor + }) + ) + }) + + it('does not backfill another host repo-derived project during runtime-only refresh', async () => { + const store = createTestStore() + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect( + store + .getState() + .projects.map((project) => project.id) + .sort() + ).toEqual(['local-project', 'repo:remote-repo']) + expect(store.getState().projects).toContainEqual(localProject) + }) + + it('preserves shared project metadata when the same project id is fetched from multiple hosts', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }), + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'runtime:env-1', + repoId: 'remote-repo' + }) + ]) + ) + }) + + it('keeps local Windows runtime preference when remote project metadata has its own preference', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks({ + remoteProjectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect( + store.getState().projects.find((project) => project.id === sharedProjectId) + ?.localWindowsRuntimePreference + ).toEqual({ kind: 'windows-host' }) + }) + + it('preserves shared project metadata after a runtime-only repo refresh', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + }) + + it('keeps the local side of a shared project when a runtime refresh removes its repos', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-empty', + ok: true, + result: { repos: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-empty', + ok: true, + result: { projects: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + const sharedProject = store + .getState() + .projects.find((project) => project.id === sharedProjectId) + expect(sharedProject?.sourceRepoIds).toEqual(['local-repo']) + expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }) + ]) + }) + + it('preserves API-owned local shared projects when repo identity cannot re-derive them', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks({ + localRepoHasProviderIdentity: false + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-empty', + ok: true, + result: { repos: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-empty', + ok: true, + result: { projects: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(store.getState().projects.map((project) => project.id)).toEqual([sharedProjectId]) + expect( + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + ).toEqual(['local-repo']) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }) + ]) + }) + + it('drops refreshed-host source ownership when that repo no longer matches a shared project', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-reassigned', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-empty', + ok: true, + result: { projects: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect( + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + ).toEqual(['local-repo']) + expect( + store + .getState() + .projects.map((project) => project.id) + .sort() + ).toEqual(['github:stablyai/orca', 'repo:remote-repo']) + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }), + expect.objectContaining({ + projectId: 'repo:remote-repo', + hostId: 'runtime:env-1', + repoId: 'remote-repo' + }) + ]) + ) + }) + + it('drops stale runtime repo ownership when project metadata lags behind repo removal', async () => { + const { sharedProjectId, sharedRemoteProject } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-empty', + ok: true, + result: { repos: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-stale', + ok: true, + result: { projects: [sharedRemoteProject] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect( + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + ).toEqual(['local-repo']) + }) + + it('fails soft when a runtime environment is unreachable, keeping local repos', async () => { + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + throw new Error('runtime_unreachable') + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + }) + + it('loads project groups and folder workspaces for every host', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' }, + { ...remoteProjectGroup, executionHostId: 'runtime:env-1' } + ]) + expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'local-folder', + 'remote-folder' + ]) + }) + + it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => { + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') { + throw new Error('runtime_unreachable') + } + return { + id: 'rpc-other', + ok: true, + result: { repos: [], projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' } + ]) + expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace]) + }) +}) diff --git a/src/renderer/src/store/slices/repos-project-runtime.test.ts b/src/renderer/src/store/slices/repos-project-runtime.test.ts index d3fefb1d8c4..f007158d31b 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.test.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.test.ts @@ -135,6 +135,47 @@ describe('repo slice project runtime updates', () => { expect(window.api.projects.listHostSetups).toHaveBeenCalled() }) + it('clears stale local runtime preferences when local project refresh omits them', async () => { + const staleProject: Project = { + id: 'local-project', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 1 + } + const refreshedProject: Project = { + id: staleProject.id, + displayName: staleProject.displayName, + badgeColor: staleProject.badgeColor, + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 2 + } + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: staleProject.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + projectsList.mockResolvedValue([refreshedProject]) + window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + store.setState({ projects: [staleProject], projectHostSetups: [setup] }) + + await store.getState().fetchRepos() + + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + }) + it('updates local project runtime preferences through the projects API', async () => { const project: Project = { id: 'project-1', @@ -224,4 +265,61 @@ describe('repo slice project runtime updates', () => { }) expect(projectsUpdate).not.toHaveBeenCalled() }) + + it('preserves shared project source repos when updating local runtime preferences', async () => { + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo', 'remote-repo'], + createdAt: 1, + updatedAt: 2 + } + projectsUpdate.mockResolvedValue({ + ...project, + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + updatedAt: 3 + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject(project.id, { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'windows-host' + }) + }) + + it('clears local runtime preferences without dropping shared project source repos', async () => { + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo', 'remote-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 2 + } + projectsUpdate.mockResolvedValue({ + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + sourceRepoIds: ['local-repo'], + createdAt: project.createdAt, + updatedAt: 3 + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject(project.id, { + localWindowsRuntimePreference: undefined + }) + + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6b99742955b..6465697299f 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -394,6 +394,139 @@ function projectCompatibilityFromRepos( } } +function mergeProjectCompatibilityProject(base: Project, overlay: Project): Project { + const localWindowsRuntimePreference = + 'localWindowsRuntimePreference' in overlay + ? overlay.localWindowsRuntimePreference + : base.localWindowsRuntimePreference + const project: Project = { + ...base, + ...overlay, + // Why: all-host startup fetches hosts separately; one host's project record + // must not erase repo ownership learned from another host with the same id. + sourceRepoIds: [...new Set([...base.sourceRepoIds, ...overlay.sourceRepoIds])], + createdAt: Math.min(base.createdAt, overlay.createdAt), + updatedAt: Math.max(base.updatedAt, overlay.updatedAt) + } + if (localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = localWindowsRuntimePreference + } + return project +} + +function mergeProjectCompatibilityProjects( + base: readonly Project[], + overlay: readonly Project[] +): Project[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = mergeProjectCompatibilityProject(merged[index]!, entry) + } + } + return merged +} + +function mergeUpdatedProjectCompatibilityProject( + base: Project, + updated: Project, + updates: ProjectUpdate +): Project { + const project = mergeProjectCompatibilityProject(base, updated) + if ('localWindowsRuntimePreference' in updates) { + const localWindowsRuntimePreference = + 'localWindowsRuntimePreference' in updated + ? updated.localWindowsRuntimePreference + : updates.localWindowsRuntimePreference + // Why: project.update returns one host's project record, but preference + // clears must still override the cross-host metadata preservation merge. + if (localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = localWindowsRuntimePreference + } + } + return project +} + +function getCurrentSourceRepoIds(project: Project, currentRepoIds: ReadonlySet): string[] { + return project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) +} + +function getSourceRepoIdsOutsideHost( + project: Project, + reposById: ReadonlyMap, + hostId: string +): string[] { + return project.sourceRepoIds.filter((repoId) => { + const repo = reposById.get(repoId) + return repo && getRepoExecutionHostId(repo) !== hostId + }) +} + +function getMergedSourceRepoIdsForHostRefresh( + previous: Project, + current: Project, + reposById: ReadonlyMap, + hostId: string +): string[] { + return [ + ...new Set([ + ...getSourceRepoIdsOutsideHost(previous, reposById, hostId), + ...getCurrentSourceRepoIds(current, new Set(reposById.keys())) + ]) + ] +} + +function projectWithCurrentSourceRepoIds( + project: Project, + currentRepoIds: ReadonlySet +): Project { + const sourceRepoIds = getCurrentSourceRepoIds(project, currentRepoIds) + return sourceRepoIds.length === project.sourceRepoIds.length + ? project + : { ...project, sourceRepoIds } +} + +function mergePreviousProjectMetadata( + previous: Project, + current: Project, + reposById: ReadonlyMap, + hostId: string +): Project { + const project = mergeProjectCompatibilityProject(previous, current) + if (hostId === LOCAL_EXECUTION_HOST_ID) { + // Why: `localWindowsRuntimePreference` belongs to the local host; a local + // refresh that omits it is authoritative and should clear stale renderer state. + if ('localWindowsRuntimePreference' in current) { + if (current.localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = current.localWindowsRuntimePreference + } + } else { + delete project.localWindowsRuntimePreference + } + } else if (previous.localWindowsRuntimePreference !== undefined) { + // Why: remote runtimes can have their own local Windows preference; they must + // not overwrite the client-local project runtime setting. + project.localWindowsRuntimePreference = previous.localWindowsRuntimePreference + } + return { + ...project, + // Why: fetched project metadata can lag behind repo.list; repo ownership + // must track the freshly reconciled repos so removed host repos do not linger. + sourceRepoIds: getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) + } +} + function mergeProjectHostSetupCompatibility( derived: Pick, fetched: ProjectHostSetupProjection @@ -406,7 +539,7 @@ function mergeProjectHostSetupCompatibility( const setupProjectIds = new Set(projectHostSetups.map((setup) => setup.projectId)) const fetchedProjectIds = new Set(fetched.projects.map((project) => project.id)) return { - projects: mergeById(derived.projects, fetched.projects).filter( + projects: mergeProjectCompatibilityProjects(derived.projects, fetched.projects).filter( (project) => fetchedProjectIds.has(project.id) || setupProjectIds.has(project.id) ), projectHostSetups @@ -417,6 +550,99 @@ function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { return `${setup.hostId}:${setup.repoId ?? setup.id}` } +function getProjectHostIds( + project: Project, + setups: readonly ProjectHostSetup[], + repos: readonly Repo[] +): Set { + const hostIds = getExplicitProjectHostIds(project, setups, repos) + if (hostIds.size === 0) { + hostIds.add(LOCAL_EXECUTION_HOST_ID) + } + return hostIds +} + +function getExplicitProjectHostIds( + project: Project, + setups: readonly ProjectHostSetup[], + repos: readonly Repo[] +): Set { + const hostIds = new Set() + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + for (const setup of setups) { + if (setup.projectId === project.id) { + hostIds.add(setup.hostId) + } + } + for (const repoId of project.sourceRepoIds) { + const repo = repoById.get(repoId) + if (repo) { + hostIds.add(getRepoExecutionHostId(repo)) + } + } + return hostIds +} + +function mergeFetchedProjectCompatibilityForHost({ + previous, + fetched, + repos, + hostId +}: { + previous: Pick + fetched: Pick + repos: readonly Repo[] + hostId: string +}): Pick { + const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId) + const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId) + const projectHostSetups = mergeById(preservedSetups, fetchedSetupsForHost) + const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) + const reposById = new Map(repos.map((repo) => [repo.id, repo])) + const currentRepoIds = new Set(repos.map((repo) => repo.id)) + const projectHasHost = (project: Project, setups: readonly ProjectHostSetup[]): boolean => + getProjectHostIds(project, setups, repos).has(hostId) + const projectHasCurrentOwnerOutsideHost = (project: Project): boolean => + [...getExplicitProjectHostIds(project, projectHostSetups, repos)].some( + (ownerHostId) => ownerHostId !== hostId + ) + const fetchedProjects = fetched.projects + .filter((project) => { + const previousProject = previousProjectById.get(project.id) + // Why: repo-derived compatibility projects include every known host. + // A one-host refresh should only reconcile that host or prune its stale ownership. + return ( + projectHasHost(project, fetched.projectHostSetups) || + (previousProject ? projectHasHost(previousProject, previous.projectHostSetups) : false) + ) + }) + .map((project) => { + const previousProject = previousProjectById.get(project.id) + return previousProject + ? mergePreviousProjectMetadata(previousProject, project, reposById, hostId) + : projectWithCurrentSourceRepoIds(project, currentRepoIds) + }) + const fetchedProjectIds = new Set(fetchedProjects.map((project) => project.id)) + const preservedProjects = previous.projects.filter( + (project) => + !fetchedProjectIds.has(project.id) && + (!getProjectHostIds(project, previous.projectHostSetups, repos).has(hostId) || + projectHasCurrentOwnerOutsideHost(project)) + ) + return { + projects: mergeProjectCompatibilityProjects( + preservedProjects.map((project) => { + const sourceRepoIds = getSourceRepoIdsOutsideHost(project, reposById, hostId) + return sourceRepoIds.length === project.sourceRepoIds.length + ? project + : { ...project, sourceRepoIds } + }), + fetchedProjects + ), + projectHostSetups + } +} + function mergeById(base: readonly T[], overlay: readonly T[]): T[] { const merged = [...base] const indexById = new Map(merged.map((entry, index) => [entry.id, index])) @@ -458,6 +684,48 @@ function mergeFetchedReposForHost( ) } +function getProjectGroupHostId(group: Pick) { + if (group.executionHostId) { + return group.executionHostId + } + return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID +} + +function mergeFetchedProjectGroupsForHost( + previous: readonly ProjectGroup[], + fetched: ProjectGroup[], + hostId: string +): ProjectGroup[] { + const fetchedIds = new Set(fetched.map((group) => group.id)) + const preserved = previous.filter((group) => { + const existingHostId = getProjectGroupHostId(group) + return existingHostId !== hostId || fetchedIds.has(group.id) + }) + return mergeById(preserved, fetched) +} + +function mergeFetchedFolderWorkspacesForHost({ + previous, + fetched, + projectGroups, + hostId +}: { + previous: readonly FolderWorkspace[] + fetched: FolderWorkspace[] + projectGroups: readonly ProjectGroup[] + hostId: string +}): FolderWorkspace[] { + const fetchedIds = new Set(fetched.map((workspace) => workspace.id)) + const projectGroupHostIds = new Map( + projectGroups.map((group) => [group.id, getProjectGroupHostId(group)]) + ) + const preserved = previous.filter((workspace) => { + const existingHostId = projectGroupHostIds.get(workspace.projectGroupId) + return existingHostId === undefined || existingHostId !== hostId || fetchedIds.has(workspace.id) + }) + return mergeById(preserved, fetched) +} + async function fetchReposForTarget( target: ReturnType, currentRepos: readonly Repo[] @@ -492,6 +760,66 @@ async function fetchReposForTarget( return { repos: reconciledRepos, projectCompatibility, hostId } } +async function fetchProjectGroupsForTarget( + target: ReturnType, + currentProjectGroups: readonly ProjectGroup[] +): Promise<{ projectGroups: ProjectGroup[]; hostId: ReturnType }> { + const fetchedGroups = + target.kind === 'local' + ? await window.api.projectGroups.list() + : ( + await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, { + timeoutMs: 15_000 + }) + ).groups + const hostId = getRuntimeTargetHostId(target) + const ownedGroups = fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)) + return { + projectGroups: mergeFetchedProjectGroupsForHost(currentProjectGroups, ownedGroups, hostId), + hostId + } +} + +async function fetchFolderWorkspacesForTarget( + target: ReturnType, + currentFolderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): Promise<{ + folderWorkspaces: FolderWorkspace[] + hostId: ReturnType +}> { + const fetchedFolderWorkspaces = + target.kind === 'local' + ? await window.api.folderWorkspaces.list() + : ( + await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( + target, + 'folderWorkspace.list', + undefined, + { timeoutMs: 15_000 } + ) + ).folderWorkspaces + const hostId = getRuntimeTargetHostId(target) + return { + folderWorkspaces: mergeFetchedFolderWorkspacesForHost({ + previous: currentFolderWorkspaces, + fetched: fetchedFolderWorkspaces, + projectGroups, + hostId + }), + hostId + } +} + +async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[]> { + try { + return (await window.api.runtimeEnvironments.list()) ?? [] + } catch (err) { + console.warn('Failed to list runtime environments for all-host load:', err) + return [] + } +} + function settingsForRepoOwner(state: Pick, repoId: string) { return getSettingsForRepoRuntimeOwner(state, repoId) as AppState['settings'] } @@ -625,9 +953,12 @@ export type RepoSlice = { folderWorkspacePathStatuses: Record activeRepoId: string | null fetchRepos: () => Promise + fetchReposForAllHosts: () => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise fetchProjectGroups: () => Promise + fetchProjectGroupsForAllHosts: () => Promise fetchFolderWorkspaces: () => Promise + fetchFolderWorkspacesForAllHosts: () => Promise addRepo: () => Promise addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise setupProjectExistingFolder: ( @@ -746,9 +1077,18 @@ export const createRepoSlice: StateCreator = (set, } = await fetchReposForTarget(target, get().repos) set((s) => { const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: s.projectHostSetups + }, + fetched: projectCompatibility, + repos: reconciledRepos, + hostId + }) return { repos: reconciledRepos, - ...projectCompatibility, + ...mergedProjectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), @@ -776,16 +1116,27 @@ export const createRepoSlice: StateCreator = (set, hostId } = await fetchReposForTarget(target, get().repos) const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) - set((s) => ({ - repos: reconciledRepos, - ...projectCompatibility, - activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( - s.setupScriptPromptDismissedRepoIds, - validRepoIds - ) - })) + set((s) => { + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: s.projectHostSetups + }, + fetched: projectCompatibility, + repos: reconciledRepos, + hostId + }) + return { + repos: reconciledRepos, + ...mergedProjectCompatibility, + activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoIds + ) + } + }) const fetchedHostRepos = reconciledRepos.filter( (repo) => getRepoExecutionHostId(repo) === hostId ) @@ -797,24 +1148,77 @@ export const createRepoSlice: StateCreator = (set, } }, + fetchReposForAllHosts: async () => { + // Why: a cold start that restores a remote workspace re-activates that + // remote runtime environment, and fetching only the active host hides every + // other host's repos (notably all local repos), which reads as "my projects + // vanished". Load local + every configured runtime environment so the + // sidebar "All hosts" scope shows them together regardless of which + // environment is active. Each host fails soft: an unreachable/disconnected + // host is skipped without blocking the others. + const applyResult = (result: Awaited>): void => { + const validRepoIds = new Set(result.repos.map((repo) => repo.id)) + set((s) => { + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: s.projectHostSetups + }, + fetched: result.projectCompatibility, + repos: result.repos, + hostId: result.hostId + }) + return { + repos: result.repos, + ...mergedProjectCompatibility, + folderWorkspacePathStatuses: {}, + activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoIds + ) + } + }) + // Why: preserve the safe-auto fork sync that fetchRepos / + // fetchRuntimeEnvironmentRepos schedule after merging each host, so + // cold-start (which now routes through here) keeps updating safe-auto forks. + scheduleSafeAutoForkSync( + get, + result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId) + ) + } + + // Local first so local repos are present even if a remote fetch stalls. + try { + applyResult(await fetchReposForTarget({ kind: 'local' }, get().repos)) + } catch (err) { + console.error('Failed to fetch local repos for all-host load:', err) + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + + // Sequential to avoid concurrent set() races on the merged repos array. + for (const environment of environments) { + try { + applyResult( + await fetchReposForTarget( + { kind: 'environment', environmentId: environment.id }, + get().repos + ) + ) + } catch (err) { + console.warn(`Skipped repos for runtime environment ${environment.id}:`, err) + } + } + }, + fetchProjectGroups: async () => { try { const target = getActiveRuntimeTarget(get().settings) - const projectGroups = - target.kind === 'local' - ? await window.api.projectGroups.list() - : ( - await callRuntimeRpc<{ groups: ProjectGroup[] }>( - target, - 'projectGroup.list', - undefined, - { - timeoutMs: 15_000 - } - ) - ).groups + const { projectGroups } = await fetchProjectGroupsForTarget(target, []) set({ - projectGroups: projectGroups.map((group) => projectGroupWithFetchedOwner(group, target)), + projectGroups, folderWorkspacePathStatuses: {} }) } catch (err) { @@ -822,26 +1226,91 @@ export const createRepoSlice: StateCreator = (set, } }, + fetchProjectGroupsForAllHosts: async () => { + // Why: startup renders an all-host sidebar; replacing groups with only the + // active host would leave repos from other hosts visible but ungrouped. + const applyResult = (result: Awaited>): void => { + set({ + projectGroups: result.projectGroups, + folderWorkspacePathStatuses: {} + }) + } + + try { + applyResult(await fetchProjectGroupsForTarget({ kind: 'local' }, get().projectGroups)) + } catch (err) { + console.error('Failed to fetch local project groups for all-host load:', err) + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + for (const environment of environments) { + try { + applyResult( + await fetchProjectGroupsForTarget( + { kind: 'environment', environmentId: environment.id }, + get().projectGroups + ) + ) + } catch (err) { + console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err) + } + } + }, + fetchFolderWorkspaces: async () => { try { const target = getActiveRuntimeTarget(get().settings) - const folderWorkspaces = - target.kind === 'local' - ? await window.api.folderWorkspaces.list() - : ( - await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( - target, - 'folderWorkspace.list', - undefined, - { timeoutMs: 15_000 } - ) - ).folderWorkspaces + const { folderWorkspaces } = await fetchFolderWorkspacesForTarget( + target, + [], + get().projectGroups + ) set({ folderWorkspaces, folderWorkspacePathStatuses: {} }) } catch (err) { console.error('Failed to fetch folder workspaces:', err) } }, + fetchFolderWorkspacesForAllHosts: async () => { + // Why: folder workspaces are owned through their project groups, so startup + // must fetch groups first and then merge each host's folder slice. + const applyResult = ( + result: Awaited> + ): void => { + set({ + folderWorkspaces: result.folderWorkspaces, + folderWorkspacePathStatuses: {} + }) + } + + try { + applyResult( + await fetchFolderWorkspacesForTarget( + { kind: 'local' }, + get().folderWorkspaces, + get().projectGroups + ) + ) + } catch (err) { + console.error('Failed to fetch local folder workspaces for all-host load:', err) + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + for (const environment of environments) { + try { + applyResult( + await fetchFolderWorkspacesForTarget( + { kind: 'environment', environmentId: environment.id }, + get().folderWorkspaces, + get().projectGroups + ) + ) + } catch (err) { + console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err) + } + } + }, + getFolderWorkspacePathStatusCacheKey: (request, options) => `${getRuntimeTargetCachePrefix( getFolderWorkspacePathStatusRouteSettings(options, get().settings) @@ -1794,7 +2263,9 @@ export const createRepoSlice: StateCreator = (set, const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ projects: state.projects.map((project) => - project.id === projectId ? updatedProject : project + project.id === projectId + ? mergeUpdatedProjectCompatibilityProject(project, updatedProject, updates) + : project ), folderWorkspacePathStatuses: {} })) diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 032fa0d81e5..4f2d06848e0 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -80,6 +80,10 @@ import { } from './store-test-helpers' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' +import { + loadSessionCommitDrafts, + saveSessionCommitDrafts +} from '@/lib/source-control-commit-draft-session' // ─── Tests ──────────────────────────────────────────────────────────── @@ -87,6 +91,7 @@ describe('removeWorktree cascade', () => { beforeEach(() => { vi.clearAllMocks() clearRuntimeCompatibilityCacheForTests() + saveSessionCommitDrafts({}) mockApi.worktrees.remove.mockResolvedValue(undefined) mockApi.worktrees.forceDeletePreservedBranch.mockResolvedValue({ deleted: true }) mockApi.runtimeEnvironments.call.mockReset() @@ -149,6 +154,10 @@ describe('removeWorktree cascade', () => { activeTabTypeByWorktree: { [worktreeId]: 'editor' }, rightSidebarExplorerViewByWorktree: { [worktreeId]: 'search' } }) + saveSessionCommitDrafts({ + [worktreeId]: 'feat: stale draft', + 'repo1::/path/wt2': 'fix: keep draft' + }) const result = await store.getState().removeWorktree(worktreeId) const s = store.getState() @@ -170,6 +179,7 @@ describe('removeWorktree cascade', () => { expect(s.activeFileIdByWorktree[worktreeId]).toBeUndefined() expect(s.activeTabTypeByWorktree[worktreeId]).toBeUndefined() expect(s.rightSidebarExplorerViewByWorktree[worktreeId]).toBeUndefined() + expect(loadSessionCommitDrafts()).toEqual({ 'repo1::/path/wt2': 'fix: keep draft' }) }) it('warns when workspace removal keeps the local branch', async () => { diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index da3524afdcf..d2f93e73ab4 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -101,6 +101,8 @@ const mockApi = { globalThis.window = { api: mockApi } import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test-helpers' +import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' // ─── Helpers ────────────────────────────────────────────────────────── @@ -1490,13 +1492,13 @@ describe('reconnectPersistedTerminals', () => { [wt2]: [makeTab({ id: 'tab2', worktreeId: wt2, ptyId: 'old-pty-2' })] }, terminalLayoutsByTabId: { tab1: makeLayout(), tab2: makeLayout() }, - activeWorktreeIdsOnShutdown: [wt1, wt2] + activeWorktreeIdsOnShutdown: [wt1] }) expect(store.getState().workspaceSessionReady).toBe(false) expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBeNull() expect(store.getState().tabsByWorktree[wt2][0].ptyId).toBeNull() - expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1, wt2]) + expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) await store.getState().reconnectPersistedTerminals() @@ -1506,11 +1508,24 @@ describe('reconnectPersistedTerminals', () => { // records daemon session IDs as tab-level ptyIds so connectPanePty // can pass them as sessionId to the daemon's createOrAttach. expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') - expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2') + expect(s.tabsByWorktree[wt2][0].ptyId).toBeNull() expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1']) - // Why: inactive worktrees keep a wake hint but must not advertise live PTYs - // until the user opens them and connectPanePty performs the actual reattach. expect(s.ptyIdsByTabId.tab2).toEqual([]) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1, wt2], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([wt1]) expect(s.pendingReconnectWorktreeIds).toEqual([]) // No eager spawn — PTY creation deferred to pane mount expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() @@ -1801,6 +1816,65 @@ describe('reconnectPersistedTerminals', () => { expect(Object.values(bindings).sort()).toEqual(['daemon-session-A', 'daemon-session-B']) expect(s.workspaceSessionReady).toBe(true) }) + + it('does not advertise split-pane-only wake hints as hide-sleeping activity', async () => { + const store = createDaemonEnabledStore() + const wt1 = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' })] + } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt1, + activeTabId: 'tab1', + tabsByWorktree: { + [wt1]: [makeTab({ id: 'tab1', worktreeId: wt1, ptyId: null })] + }, + terminalLayoutsByTabId: { + tab1: { + ...makeLayout(), + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:3' } + }, + ptyIdsByLeafId: { 'pane:1': 'daemon-session-A', 'pane:3': 'daemon-session-B' } + } + }, + activeWorktreeIdsOnShutdown: [] + }) + + expect(store.getState().pendingReconnectWorktreeIds).toEqual([]) + + await store.getState().reconnectPersistedTerminals() + + const s = store.getState() + expect(s.terminalLayoutsByTabId.tab1?.ptyIdsByLeafId).toBeDefined() + expect(s.ptyIdsByTabId.tab1).toEqual([]) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([]) + }) }) describe('hydrateEditorSession', () => { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e235f65673f..b99b248a503 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -2595,10 +2595,9 @@ export const createTerminalSlice: StateCreator // reconnectPersistedTerminals() after all eager PTY spawns complete. // This prevents TerminalPane from mounting and spawning duplicate PTYs // before the reconnect phase has set ptyId on each tab. - // Why: fall back to deriving the list from tabsByWorktree ptyIds when - // activeWorktreeIdsOnShutdown is absent (upgrade from older build). - // The raw tabs still carry ptyId values before clearTransientTerminalState - // nulls them, so we can infer which worktrees had active terminals. + // Why: match the pre-idle-runtime-optimization startup contract. + // activeWorktreeIdsOnShutdown is authoritative when present; persisted + // tab/layout PTY IDs are wake hints, not a broader active-workspace list. const shutdownIds = session.activeWorktreeIdsOnShutdown ?? Object.entries(session.tabsByWorktree) @@ -2794,8 +2793,7 @@ export const createTerminalSlice: StateCreator pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, terminalLayoutsByTabId, - tabsByWorktree, - activeWorktreeId + tabsByWorktree } = get() const ids = pendingReconnectWorktreeIds ?? [] @@ -2869,15 +2867,11 @@ export const createTerminalSlice: StateCreator `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) if (tabLevelPtyId) { - const shouldAdvertiseLivePtys = worktreeId === activeWorktreeId set((s) => { const next = { ...s.tabsByWorktree } if (!next[worktreeId]) { return {} } - next[worktreeId] = next[worktreeId].map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) // Why: populate ptyIdsByTabId so the sessions status segment // can map daemon session IDs back to tabs (for bound/orphan @@ -2885,20 +2879,19 @@ export const createTerminalSlice: StateCreator // appear as orphans until the terminal pane mounts. const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) - : [tabLevelPtyId] + : [tabLevelPtyId!] + next[worktreeId] = next[worktreeId].map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) return { tabsByWorktree: next, - ...(shouldAdvertiseLivePtys - ? { - // Why: inactive worktrees only need wake hints. Publishing - // their PTYs as live at startup starts global session/status - // machinery before the user opens that workspace. - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [tabId]: allPtyIds - } - } - : {}) + // Why: hide-sleeping uses ptyIdsByTabId as the liveness source. + // Restored daemon sessions are still running even before their + // pane remounts, so background workspaces must advertise them. + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [tabId]: allPtyIds + } } }) } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 02f60abbaff..565b66e0557 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -42,6 +42,7 @@ import { toast } from 'sonner' import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' +import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { translate } from '@/i18n/i18n' @@ -2466,6 +2467,9 @@ export const createWorktreeSlice: StateCreator // assemblies (some unit tests) omit the generation slices. get().prunePullRequestGenerationRecords?.(liveWorktreeKeys) get().pruneCommitMessageGenerationRecords?.(liveWorktreeKeys) + // Why: Source Control may be unmounted during deletion, so its local + // prune effect cannot be the only stale-draft cleanup path. + clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch if (preservedBranch) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 2df68373451..b977489b4bc 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -19,9 +19,9 @@ export const AI_VAULT_AGENTS = [ ] as const satisfies readonly TuiAgent[] export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number] -export type AiVaultScope = 'workspace' | 'all' +export type AiVaultScope = 'workspace' | 'project' | 'all' export type AiVaultSort = 'updated' | 'created' -export type AiVaultGroup = 'folder' | 'agent' +export type AiVaultGroup = 'project' | 'folder' | 'agent' export const AI_VAULT_AGENT_LABELS = { claude: 'Claude', diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e61ad0bb8f4..7336cbc15f7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -210,6 +210,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalFontFamily: defaultTerminalFontFamily(), terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 3, // Why: "auto" should use WebGL when supported while keeping DOM fallback // for renderer failures and Linux software/unknown GPU renderers. terminalGpuAcceleration: 'auto', diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 1228d7bbdd6..83db7e37efd 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -21,6 +21,7 @@ import { normalizeKeybindingListForAction, normalizeKeybindingList } from './keybindings' +import type { KeybindingActionId, KeybindingPlatform } from './keybindings' import { ALL_TUI_AGENTS } from './tui-agent-display-names' describe('keybindings', () => { @@ -250,6 +251,22 @@ describe('keybindings', () => { 'linux' ) ).toBe(false) + expect( + keybindingMatchesAction( + 'tab.rename', + { + key: 'r', + code: 'KeyR', + meta: true, + control: false, + alt: false, + shift: false + }, + 'darwin', + undefined, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + ).toBe(false) // Why: tab.rename (Mod+R) intentionally shares its binding with // browser.reload, but the two live in different scopes (tabs vs browser), @@ -439,6 +456,65 @@ describe('keybindings', () => { ) }) + it('defines floating workspace panel action metadata', () => { + const actionIds = [ + 'floatingWorkspace.maximize' as KeybindingActionId, + 'floatingWorkspace.minimize' as KeybindingActionId + ] as const + + for (const actionId of actionIds) { + expect(getKeybindingDefinition(actionId), actionId).toMatchObject({ id: actionId }) + } + }) + + it('assigns the floating workspace maximize default only on macOS', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + expect(getEffectiveKeybindingsForAction(maximizeAction, 'darwin')).toEqual(['Mod+Alt+Shift+A']) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'linux')).toEqual([]) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'win32')).toEqual([]) + }) + + it('captures and round-trips the macOS Option-composed maximize chord', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + // Why: macOS Option+A composes to a glyph (å), so capture must resolve the + // chord through the physical-code fallback rather than the composed key, + // matching the matcher so a user override round-trips to the same binding. + const macComposedMaximize = { + key: 'å', + code: 'KeyA', + meta: true, + control: false, + alt: true, + shift: true + } + expect(keybindingFromInput(macComposedMaximize, 'darwin')).toEqual({ + ok: true, + value: 'Mod+Alt+Shift+A' + }) + expect(keybindingMatchesAction(maximizeAction, macComposedMaximize, 'darwin')).toBe(true) + // The captured override formats back to the same effective shortcut. + expect( + getEffectiveKeybindingsForAction(maximizeAction, 'darwin', { + [maximizeAction]: ['Mod+Alt+Shift+A'] + }) + ).toEqual(['Mod+Alt+Shift+A']) + expect(formatKeybindingList(['Mod+Alt+Shift+A'], 'darwin')).toBe('⌘⌥⇧A') + }) + + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { + const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId + + for (const platform of platforms) { + expect(getEffectiveKeybindingsForAction(minimizeAction, platform)).toEqual([]) + } + expect(getEffectiveKeybindingsForAction('floatingTerminal.toggle', 'darwin')).toEqual([ + 'Mod+Alt+A' + ]) + }) + it('defines a macOS-only default for the new agent tab shortcut', () => { expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T']) expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([]) @@ -533,6 +609,32 @@ describe('keybindings', () => { ).toBe(true) }) + it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => { + const panelFocus = { + context: 'app', + terminalShortcutPolicy: 'terminal-first' + } as const + + expect( + keybindingMatchesAction( + 'tab.rename', + { key: 'r', code: 'KeyR', meta: true, control: false, alt: false, shift: false }, + 'darwin', + undefined, + panelFocus + ) + ).toBe(true) + expect( + matchKeybindingDigitIndex( + 'tab.selectByIndex', + { key: '4', code: 'Digit4', meta: false, control: false, alt: true, shift: false }, + 'linux', + undefined, + panelFocus + ) + ).toBe(3) + }) + it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => { const deleteBinding = { key: 'Backspace', diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4f514c25626..57a8b775267 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -50,6 +50,8 @@ export type KeybindingActionId = | 'sidebar.ports.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' | 'zoom.in' | 'zoom.out' | 'zoom.reset' @@ -408,6 +410,57 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['Mod+Alt+A']), allowInTerminal: true }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so + // maximize/restore lives on the same key anchor and stays one-handed, + // instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default; + // Linux/Windows stay unbound for users to assign. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: intentionally unbound on every platform. floatingTerminal.toggle + // already owns the default show/hide chord; this action exists only so + // users can bind an explicit "hide the focused panel" shortcut in Settings. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, { id: 'zoom.in', title: 'Zoom In', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 274869eee69..a5e691543a0 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -25,6 +25,7 @@ export type AgentStartupPlan = { expectedProcess: string followupPrompt: string | null launchConfig: SleepingAgentLaunchConfig + launchToken?: string draftPrompt?: string | null env?: Record startupCommandDelivery?: StartupCommandDelivery diff --git a/src/shared/types.ts b/src/shared/types.ts index ad5ebcf9ede..2ce4ad3c4dc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2391,6 +2391,9 @@ export type GlobalSettings = { terminalFontFamily: string terminalFontWeight: number terminalLineHeight: number + terminalScrollSensitivity: number + terminalFastScrollSensitivity: number + terminalTuiScrollSensitivity: number /** Terminal renderer policy. * - 'auto': try xterm WebGL and fall back to DOM when unsupported or risky. * - 'on': always try xterm WebGL. diff --git a/tests/e2e/floating-mobile-emulator-tab.spec.ts b/tests/e2e/floating-mobile-emulator-tab.spec.ts new file mode 100644 index 00000000000..cf68477df9d --- /dev/null +++ b/tests/e2e/floating-mobile-emulator-tab.spec.ts @@ -0,0 +1,125 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' + +// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts. +// E2E specs avoid importing renderer/shared modules into the Playwright runner. +const FLOATING_WORKTREE_ID = 'global-floating-terminal' +const PANEL_SELECTOR = '[data-floating-terminal-panel]' +const OPEN_PANEL_SELECTOR = `${PANEL_SELECTOR}[aria-hidden="false"]` +const TOGGLE_EVENT = 'orca-toggle-floating-terminal' + +type SeededSimulatorTab = { + id: string +} +type E2ESimulatorTab = { + id: string + contentType: string +} + +type E2EStoreState = { + settings: Record + unifiedTabsByWorktree: Record + createUnifiedTab: ( + worktreeId: string, + contentType: 'simulator', + init: { label: string; recordInteraction: false } + ) => E2ESimulatorTab + activateTab: (tabId: string) => void +} + +type E2EStore = { + getState: () => E2EStoreState + setState: (partial: Partial) => void +} + +type E2EWindow = typeof window & { + __store?: E2EStore +} + +async function seedFloatingSimulatorTab(page: Page): Promise { + const tab = await page.evaluate((worktreeId) => { + const store = (window as E2EWindow).__store + if (!store) { + throw new Error('Store unavailable') + } + + const state = store.getState() + store.setState({ + settings: { + ...state.settings, + floatingTerminalEnabled: true, + mobileEmulatorEnabled: true + } + }) + + const refreshedState = store.getState() + const existingTab = (refreshedState.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'simulator' + ) + const tab = + existingTab ?? + refreshedState.createUnifiedTab(worktreeId, 'simulator', { + label: 'Mobile Emulator', + recordInteraction: false + }) + refreshedState.activateTab(tab.id) + return { id: tab.id } + }, FLOATING_WORKTREE_ID) + // Why: the toggle listener closes over floatingTerminalEnabled; wait for + // React to commit the enabled floating panel before dispatching the event. + await page.waitForFunction( + (panelSelector) => Boolean(document.querySelector(panelSelector)), + PANEL_SELECTOR, + { timeout: 30_000 } + ) + return tab +} + +async function openFloatingPanelIfNeeded(page: Page): Promise { + if ((await page.locator(OPEN_PANEL_SELECTOR).count()) === 0) { + await page.evaluate((eventName) => { + window.dispatchEvent(new Event(eventName)) + }, TOGGLE_EVENT) + } + await expect(page.locator(OPEN_PANEL_SELECTOR)).toBeVisible() +} + +async function attachPanelScreenshot(page: Page, testInfo: TestInfo): Promise { + const panel = page.locator(OPEN_PANEL_SELECTOR).first() + const body = await panel.screenshot() + await testInfo.attach('floating-mobile-emulator-open', { body, contentType: 'image/png' }) +} + +test('floating Mobile Emulator tab renders content and closes from the tab strip', async ({ + orcaPage +}, testInfo) => { + const tab = await seedFloatingSimulatorTab(orcaPage) + await openFloatingPanelIfNeeded(orcaPage) + + const openPanel = orcaPage.locator(OPEN_PANEL_SELECTOR).first() + const tabLocator = openPanel.locator(`[data-tab-id="${tab.id}"]`) + await expect(tabLocator).toBeVisible() + await expect(openPanel.locator('[data-emulator-pane]')).toBeVisible() + await attachPanelScreenshot(orcaPage, testInfo) + + await tabLocator.hover() + await tabLocator.locator('[data-tab-close-button="true"]').click() + + await expect + .poll( + async () => + orcaPage.evaluate( + ({ worktreeId, tabId }) => { + const tabs = + (window as E2EWindow).__store?.getState().unifiedTabsByWorktree[worktreeId] ?? [] + return tabs.some((candidate) => candidate.id === tabId) + }, + { worktreeId: FLOATING_WORKTREE_ID, tabId: tab.id } + ), + { timeout: 10_000 } + ) + .toBe(false) + + await expect(tabLocator).toHaveCount(0) + await expect(openPanel.locator('[data-emulator-pane]')).toHaveCount(0) +}) diff --git a/tests/e2e/source-control-commit-draft-persistence.spec.ts b/tests/e2e/source-control-commit-draft-persistence.spec.ts new file mode 100644 index 00000000000..5e8fbe770c3 --- /dev/null +++ b/tests/e2e/source-control-commit-draft-persistence.spec.ts @@ -0,0 +1,172 @@ +import { execFileSync } from 'child_process' +import { rmSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +type E2eWorktree = { + branchName: string + worktreePath: string +} + +function createWorktreeWithStagedChange(repoPath: string): E2eWorktree { + const branchName = `e2e-commit-draft-${Date.now()}-${Math.random().toString(16).slice(2)}` + const worktreePath = path.join(os.tmpdir(), branchName) + execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: repoPath, + stdio: 'pipe' + }) + writeFileSync( + path.join(worktreePath, 'README.md'), + '# Commit Draft Persistence E2E\n\nPreserve this draft across remounts.\n' + ) + execFileSync('git', ['add', 'README.md'], { cwd: worktreePath, stdio: 'pipe' }) + return { branchName, worktreePath } +} + +function cleanupWorktree(repoPath: string, worktreePath: string, branchName: string): void { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoPath, + stdio: 'pipe' + }) + } catch { + try { + rmSync(worktreePath, { recursive: true, force: true }) + execFileSync('git', ['worktree', 'prune'], { cwd: repoPath, stdio: 'pipe' }) + } catch { + // Best effort: the branch delete below is still worth attempting. + } + } + try { + execFileSync('git', ['branch', '-D', branchName], { cwd: repoPath, stdio: 'pipe' }) + } catch { + // The branch may already be gone when git prunes it with the worktree. + } +} + +async function openSourceControlForWorktree( + page: Parameters[0], + repoPath: string, + targetWorktreePath: string +): Promise { + await page.evaluate( + async ({ repoPath, targetWorktreePath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + await state.fetchRepos() + const repo = store.getState().repos.find((entry) => entry.path === repoPath) + if (!repo) { + throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) + } + + const listedWorktrees = await window.api.worktrees.list({ repoId: repo.id }) + store.setState((current) => ({ + worktreesByRepo: { + ...current.worktreesByRepo, + [repo.id]: listedWorktrees + } + })) + + const normalizeMacTmpPath = (value: string): string => + value.startsWith('/private/var/') ? value.slice('/private'.length) : value + const worktree = listedWorktrees.find( + (entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath) + ) + if (!worktree) { + throw new Error( + `E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees + .map((entry) => entry.path) + .join(', ')}` + ) + } + + store.getState().setActiveWorktree(worktree.id) + const status = await window.api.git.status({ worktreePath: worktree.path }) + store.getState().setGitStatus(worktree.id, status) + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('source-control') + }, + { repoPath, targetWorktreePath } + ) + + await expect + .poll( + async () => + page.evaluate(() => { + const state = window.__store?.getState() + return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') + }), + { timeout: 5_000 } + ) + .toBe(true) +} + +test.describe('Source Control commit draft persistence', () => { + test('preserves a typed draft when the sidebar tab remounts', async ({ + orcaPage, + testRepoPath + }) => { + let firstWorktree: E2eWorktree | null = null + let secondWorktree: E2eWorktree | null = null + + try { + firstWorktree = createWorktreeWithStagedChange(testRepoPath) + secondWorktree = createWorktreeWithStagedChange(testRepoPath) + await waitForSessionReady(orcaPage) + await openSourceControlForWorktree(orcaPage, testRepoPath, firstWorktree.worktreePath) + + const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' }) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + + const draft = 'fix: keep draft after leaving Source Control' + await textarea.fill(draft) + await expect(textarea).toHaveValue(draft) + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('explorer') + }) + await expect + .poll( + async () => orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab ?? null), + { timeout: 5_000 } + ) + .toBe('explorer') + await expect(textarea).toBeHidden() + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('source-control') + }) + await expect + .poll( + async () => orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab ?? null), + { timeout: 5_000 } + ) + .toBe('source-control') + + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue(draft) + + await openSourceControlForWorktree(orcaPage, testRepoPath, secondWorktree.worktreePath) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue('') + + await openSourceControlForWorktree(orcaPage, testRepoPath, firstWorktree.worktreePath) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue(draft) + } finally { + for (const worktree of [firstWorktree, secondWorktree]) { + if (worktree) { + cleanupWorktree(testRepoPath, worktree.worktreePath, worktree.branchName) + } + } + } + }) +}) diff --git a/tests/e2e/ssh-codex-display-artifacts-repro.spec.ts b/tests/e2e/ssh-codex-display-artifacts-repro.spec.ts new file mode 100644 index 00000000000..e19a090df4d --- /dev/null +++ b/tests/e2e/ssh-codex-display-artifacts-repro.spec.ts @@ -0,0 +1,199 @@ +import type { TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT, + REMOTE_TUI_DONE, + installRemoteCodexArtifactTui, + installRemoteCodexFixture, + shellQuote +} from './ssh-codex-repro-remote-fixtures' +import { + connectDockerRemote, + dropDockerSshClientSessions, + enableRiskyTerminalRendererPath, + installPtyReplayProbe, + readDuplicateStatusRows, + readReplayProbeSnapshot, + switchToNonRemoteWorktree, + waitForDockerRemoteReconnected +} from './ssh-codex-reconnect-replay-driver' +import { installRemoteRealCodex, realRemoteCodexCommand } from './ssh-codex-real-remote' +import { + clearRemoteTerminalAfterCodex, + scrollActiveTerminalToArtifactHistory, + stressRestoreRemoteTerminalDuringCodex, + waitForRealRemoteCodexCompletion, + waitForRemoteFixtureCleanFinalInHiddenPane +} from './ssh-codex-terminal-observers' +import { MAX_FINAL_GRAY_SLABS, captureGraySlabAnalysis } from './terminal-raster-artifact-analysis' +import { persistReproEvidence } from './terminal-repro-evidence' +import { resetWebglAndCaptureGraySlabAnalysis } from './terminal-webgl-reset-capture' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const RUN_REAL_REMOTE_CODEX = process.env.ORCA_E2E_REAL_REMOTE_CODEX === '1' +const EXPECT_NO_ARTIFACTS = process.env.ORCA_E2E_EXPECT_NO_CODEX_ARTIFACTS === '1' +const CAPTURE_WHILE_REMOTE_TUI_RUNNING = + process.env.ORCA_E2E_CAPTURE_WHILE_REMOTE_TUI_RUNNING === '1' +const HIDE_UNTIL_REMOTE_TUI_DONE = process.env.ORCA_E2E_HIDE_UNTIL_REMOTE_TUI_DONE === '1' +const CAPTURE_SCROLLBACK_ARTIFACT_REGION = + process.env.ORCA_E2E_CAPTURE_SCROLLBACK_ARTIFACT_REGION === '1' +const FORCE_SSH_RECONNECT_DURING_TUI = process.env.ORCA_E2E_FORCE_SSH_RECONNECT_DURING_TUI === '1' +const KEEP_SSH_REPRO_TARGET = process.env.ORCA_E2E_KEEP_SSH_REPRO_TARGET === '1' + +test.describe('Remote SSH Codex display artifacts repro', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH repro.') + test.skip(process.platform === 'win32', 'Docker SSH repro uses POSIX ssh tooling.') + + test('does not leave duplicated Codex status output after SSH replay', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + installRemoteCodexArtifactTui(target) + if (RUN_REAL_REMOTE_CODEX) { + installRemoteRealCodex(target) + } else { + installRemoteCodexFixture(target) + } + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + expect(remote.targetId).toBeTruthy() + expect(remote.worktreeId).toBeTruthy() + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + await enableRiskyTerminalRendererPath(orcaPage) + await installPtyReplayProbe(orcaPage) + + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + const doneMarker = RUN_REAL_REMOTE_CODEX + ? `ORCA_REAL_REMOTE_CODEX_DONE_${Date.now()}` + : REMOTE_TUI_DONE + const cleanMarker = RUN_REAL_REMOTE_CODEX + ? `ORCA_REAL_REMOTE_CODEX_CLEAN_${Date.now()}` + : doneMarker + await execInTerminal( + orcaPage, + ptyId, + RUN_REAL_REMOTE_CODEX + ? realRemoteCodexCommand(doneMarker) + : `codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox ${shellQuote( + doneMarker + )}` + ) + await orcaPage.waitForTimeout(1_200) + if (FORCE_SSH_RECONNECT_DURING_TUI) { + dropDockerSshClientSessions(target) + await waitForDockerRemoteReconnected(orcaPage, remote.targetId) + await orcaPage.waitForTimeout(2_000) + } + await (RUN_REAL_REMOTE_CODEX + ? (async () => { + await stressRestoreRemoteTerminalDuringCodex(orcaPage, remote.worktreeId) + await waitForRealRemoteCodexCompletion(orcaPage, doneMarker) + })() + : (async () => { + if (CAPTURE_WHILE_REMOTE_TUI_RUNNING) { + await orcaPage.waitForTimeout(10_000) + } else { + await switchToNonRemoteWorktree(orcaPage, remote.worktreeId) + await (HIDE_UNTIL_REMOTE_TUI_DONE + ? waitForRemoteFixtureCleanFinalInHiddenPane(orcaPage, remote.worktreeId) + : orcaPage.waitForTimeout(10_000)) + } + if (CAPTURE_WHILE_REMOTE_TUI_RUNNING) { + await orcaPage.waitForTimeout(900) + return + } + await switchToWorktree(orcaPage, remote.worktreeId) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + await waitForTerminalOutput( + orcaPage, + REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT, + 60_000, + 120_000 + ) + })()) + await orcaPage.waitForTimeout(600) + if (CAPTURE_SCROLLBACK_ARTIFACT_REGION) { + await scrollActiveTerminalToArtifactHistory(orcaPage) + } + + const { analysis, screenshot } = await captureGraySlabAnalysis(orcaPage) + analysis.replayDebug = await readReplayProbeSnapshot(orcaPage) + analysis.duplicateStatusRows = await readDuplicateStatusRows(orcaPage) + const evidenceLabel = RUN_REAL_REMOTE_CODEX + ? 'real-remote-codex-reconnect-replay' + : 'fixture-codex-reconnect-replay' + persistReproEvidence(evidenceLabel, analysis, screenshot) + const resetEvidence = await resetWebglAndCaptureGraySlabAnalysis(orcaPage) + resetEvidence.analysis.replayDebug = await readReplayProbeSnapshot(orcaPage) + resetEvidence.analysis.duplicateStatusRows = await readDuplicateStatusRows(orcaPage) + persistReproEvidence( + `${evidenceLabel}-after-webgl-reset`, + resetEvidence.analysis, + resetEvidence.screenshot + ) + await testInfo.attach('remote-codex-artifact-final-screen', { + body: screenshot, + contentType: 'image/png' + }) + await testInfo.attach('remote-codex-artifact-after-webgl-reset', { + body: resetEvidence.screenshot, + contentType: 'image/png' + }) + testInfo.annotations.push({ + type: 'remote-codex-artifact-analysis', + description: JSON.stringify(analysis) + }) + testInfo.annotations.push({ + type: 'remote-codex-artifact-after-webgl-reset-analysis', + description: JSON.stringify(resetEvidence.analysis) + }) + + // Why: this spec supports both repro mode and strict regression mode so + // the same harness can prove a failure and lock the fixed behavior. + if (EXPECT_NO_ARTIFACTS) { + expect(analysis.slabCount).toBeLessThanOrEqual(MAX_FINAL_GRAY_SLABS) + expect(analysis.staleStatusGlyphRowCount).toBe(0) + expect(analysis.duplicateStatusRows ?? []).toEqual([]) + } else { + expect(analysis.rawSlabCount + analysis.staleStatusGlyphRowCount).toBeGreaterThan(0) + } + if (FORCE_SSH_RECONNECT_DURING_TUI) { + expect(Number(analysis.replayDebug?.replayCount ?? 0)).toBeGreaterThan(0) + } + if (RUN_REAL_REMOTE_CODEX) { + await clearRemoteTerminalAfterCodex(orcaPage, ptyId, cleanMarker) + } + } finally { + if (KEEP_SSH_REPRO_TARGET && target) { + console.log( + `[ssh-codex-repro] keeping Docker SSH target ${target.containerName} on port ${target.port}` + ) + } else { + cleanupDockerSshRelayTarget(target) + } + } + }) +}) diff --git a/tests/e2e/ssh-codex-real-remote.ts b/tests/e2e/ssh-codex-real-remote.ts new file mode 100644 index 00000000000..60c96eb0d1e --- /dev/null +++ b/tests/e2e/ssh-codex-real-remote.ts @@ -0,0 +1,59 @@ +import { existsSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { dockerExec, dockerWriteFile, shellQuote } from './ssh-codex-repro-remote-fixtures' + +const REMOTE_CODEX_VERSION = '0.141.0' + +function tomlString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') +} + +export function installRemoteRealCodex(target: DockerSshRelayTarget): void { + const codexAuthPath = path.join(os.homedir(), '.codex', 'auth.json') + if (!existsSync(codexAuthPath)) { + throw new Error(`Real remote Codex repro needs local auth at ${codexAuthPath}`) + } + dockerExec(target, 'mkdir -p /root/.codex') + dockerWriteFile(target, '/root/.codex/auth.json', readFileSync(codexAuthPath), '600') + const trustedRemotePath = tomlString(DOCKER_SSH_RELAY_REMOTE_REPO_PATH) + dockerWriteFile( + target, + '/root/.codex/config.toml', + [ + 'approval_policy = "never"', + '', + `[projects."${trustedRemotePath}"]`, + 'trust_level = "trusted"', + '' + ].join('\n'), + '600' + ) + dockerExec(target, `npm install -g @openai/codex@${REMOTE_CODEX_VERSION}`, 180_000) +} + +export function realRemoteCodexCommand(doneMarker: string): string { + const prompt = [ + 'This is an automated terminal rendering reproduction.', + 'Run these three shell commands one at a time, waiting for each one to finish before starting the next:', + 'node -e "let i = 0; const timer = setInterval(() => { console.log(\'REMOTE_CODEX_PHASE_0_\' + i); i += 1; if (i >= 100) clearInterval(timer) }, 250)"', + 'node -e "let i = 0; const timer = setInterval(() => { console.log(\'REMOTE_CODEX_PHASE_1_\' + i); i += 1; if (i >= 100) clearInterval(timer) }, 250)"', + 'node -e "let i = 0; const timer = setInterval(() => { console.log(\'REMOTE_CODEX_PHASE_2_\' + i); i += 1; if (i >= 100) clearInterval(timer) }, 250)"', + 'The commands are intentionally slow. Keep waiting until all three complete.', + 'Then briefly summarize that all three commands ran.', + `End your final response with this exact marker: ${doneMarker}` + ].join(' ') + return [ + 'codex', + '--no-alt-screen', + '--dangerously-bypass-approvals-and-sandbox', + '--dangerously-bypass-hook-trust', + '-C', + shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH), + shellQuote(prompt) + ].join(' ') +} diff --git a/tests/e2e/ssh-codex-reconnect-replay-driver.ts b/tests/e2e/ssh-codex-reconnect-replay-driver.ts new file mode 100644 index 00000000000..75fc21a47e5 --- /dev/null +++ b/tests/e2e/ssh-codex-reconnect-replay-driver.ts @@ -0,0 +1,234 @@ +import { execFileSync } from 'node:child_process' +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' +import { + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' + +export type ConnectedDockerRemote = { + targetId: string + worktreeId: string +} + +export function dropDockerSshClientSessions(target: DockerSshRelayTarget): void { + execFileSync( + 'docker', + [ + 'exec', + target.containerName, + 'bash', + '-lc', + `ps -eo pid=,comm=,args= | awk '$2 == "sshd" && index($0, "sshd: root") { print $1 }' | xargs -r kill -9` + ], + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 } + ) +} + +export async function connectDockerRemote( + page: Page, + target: DockerSshRelayTarget +): Promise { + return await page.evaluate( + async ({ target, remotePath }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { + void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) + }) + try { + const createdTarget = await window.api.ssh.addTarget({ + target: { + label: `Docker SSH Codex Artifact Repro ${Date.now()}`, + host: '127.0.0.1', + port: target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + } + }) + const state = await window.api.ssh.connect({ targetId: createdTarget.id }) + if (!state || state.status !== 'connected') { + throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) + } + store.getState().setSshConnectionState(createdTarget.id, state) + const labels = new Map(store.getState().sshTargetLabels) + labels.set(createdTarget.id, createdTarget.label) + store.getState().setSshTargetLabels(labels) + + const result = await window.api.repos.addRemote({ + connectionId: createdTarget.id, + remotePath, + displayName: 'Docker SSH Codex Artifact Repro' + }) + if ('error' in result) { + throw new Error(result.error) + } + await store.getState().fetchRepos() + await store.getState().fetchWorktrees(result.repo.id) + const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0] + if (!worktree) { + throw new Error(`No remote worktree found for ${result.repo.path}`) + } + store.getState().setActiveWorktree(worktree.id) + if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { + store.getState().createTab(worktree.id) + } + store.getState().setActiveTabType('terminal') + return { targetId: createdTarget.id, worktreeId: worktree.id } + } finally { + credentialUnsub() + } + }, + { target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH } + ) +} + +export async function switchToNonRemoteWorktree( + page: Page, + remoteWorktreeId: string +): Promise { + const otherWorktreeId = await page.evaluate((remoteWorktreeId) => { + const store = window.__store + if (!store) { + return null + } + const state = store.getState() + const other = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id !== remoteWorktreeId) + if (!other) { + return null + } + state.setActiveWorktree(other.id) + return other.id + }, remoteWorktreeId) + if (!otherWorktreeId) { + throw new Error('No non-remote worktree available to hide the SSH terminal') + } + return otherWorktreeId +} + +export async function installPtyReplayProbe(page: Page): Promise { + await page.evaluate(() => { + const api = window.api?.pty + if (!api || typeof api.onReplay !== 'function') { + throw new Error('PTY replay API unavailable') + } + const holder = window as unknown as { + __orcaSshCodexReplayProbe?: { + payloads: { id: string; length: number; preview: string }[] + dispose: () => void + } + } + holder.__orcaSshCodexReplayProbe?.dispose() + const payloads: { id: string; length: number; preview: string }[] = [] + const dispose = api.onReplay(({ id, data }) => { + payloads.push({ + id, + length: data.length, + preview: data.slice(-400) + }) + }) + holder.__orcaSshCodexReplayProbe = { payloads, dispose } + }) +} + +export async function waitForDockerRemoteReconnected(page: Page, targetId: string): Promise { + let observedNonConnected = false + await expect + .poll( + async () => { + const status = await page.evaluate((targetId) => { + const state = window.__store?.getState().sshConnectionStates.get(targetId) + return state?.status ?? null + }, targetId) + if (status !== 'connected') { + observedNonConnected = true + } + return observedNonConnected && status === 'connected' + }, + { + timeout: 90_000, + message: 'Docker SSH target did not auto-reconnect after transport drop' + } + ) + .toBe(true) +} + +export async function readReplayProbeSnapshot(page: Page): Promise> { + return page.evaluate(() => { + const probe = ( + window as unknown as { + __orcaSshCodexReplayProbe?: { + payloads: { id: string; length: number; preview: string }[] + } + } + ).__orcaSshCodexReplayProbe + return { + replayCount: probe?.payloads.length ?? 0, + replayPayloads: probe?.payloads.slice(-8) ?? [] + } + }) +} + +export async function readDuplicateStatusRows(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const text = pane?.serializeAddon?.serialize?.() ?? '' + const counts = new Map() + const escapeSequencePattern = new RegExp( + `${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, + 'g' + ) + for (const line of text.split(/\r?\n/)) { + const normalized = line.replace(escapeSequencePattern, '').trim() + if (!/gpt-5\.5|background terminal|\/ps to view|\/stop to close/i.test(normalized)) { + continue + } + counts.set(normalized, (counts.get(normalized) ?? 0) + 1) + } + return Array.from(counts) + .filter(([, count]) => count > 1) + .map(([line, count]) => `${count}x ${line}`) + .slice(0, 12) + }) +} + +export async function enableRiskyTerminalRendererPath(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store unavailable') + } + const state = store.getState() + store.setState({ + settings: { + ...state.settings!, + terminalGpuAcceleration: 'on', + theme: 'dark' + } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + manager?.setTerminalGpuAcceleration('on') + }) +} diff --git a/tests/e2e/ssh-codex-repro-remote-fixtures.ts b/tests/e2e/ssh-codex-repro-remote-fixtures.ts new file mode 100644 index 00000000000..3ee48187e7c --- /dev/null +++ b/tests/e2e/ssh-codex-repro-remote-fixtures.ts @@ -0,0 +1,233 @@ +import { execFileSync } from 'node:child_process' +import type { DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' + +const REMOTE_TUI_PATH = '/tmp/orca-codex-display-artifacts-repro.mjs' +export const REMOTE_TUI_DONE = 'ORCA_REMOTE_CODEX_ARTIFACT_TUI_DONE' +export const REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT = + 'Any gray slab visible now is stale renderer state.' +const REMOTE_TUI_FRAMES = 900 +const REMOTE_CODEX_FIXTURE_FRAMES = parseEnvNumber(process.env.ORCA_E2E_CODEX_FIXTURE_FRAMES, 34) +const REMOTE_CODEX_FIXTURE_FRAME_DELAY_MS = parseEnvNumber( + process.env.ORCA_E2E_CODEX_FIXTURE_FRAME_DELAY_MS, + 45 +) + +function parseEnvNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function remoteCodexArtifactTuiScript(): string { + return ` +const cols = Number(process.env.COLUMNS || 120) +const statusWidth = Math.max(48, Math.min(cols - 4, 104)) +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +function write(chunk) { + return new Promise((resolve) => process.stdout.write(chunk, resolve)) +} + +function pad(text, width) { + const raw = text.length > width ? text.slice(0, width) : text + return raw + ' '.repeat(Math.max(0, width - raw.length)) +} + +await write('\\x1b]0;codex\\x07') +await write('\\x1b[2J\\x1b[H\\x1b[?25l') + +for (let frame = 0; frame < ${REMOTE_TUI_FRAMES}; frame += 1) { + const statusRow = 8 + (frame % 13) + const priorRow = 8 + ((frame + 12) % 13) + const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'][frame % 8] + const bands = [ + { row: statusRow, width: statusWidth, text: \`\${spinner} Working for background terminal · frame \${String(frame).padStart(3, '0')}\` }, + { row: 27 + (frame % 9), width: Math.max(32, statusWidth - 18), text: \`gpt-5.5 high · ~/remote/repro/pr-5969 · /ps to view · /stop to close \${frame}\` }, + { row: 40 + (frame % 11), width: Math.max(28, statusWidth - 8), text: \`• Working for background terminal · rtk bun run e2e:ui --filter=@dalp/app \${frame}\` } + ] + await write('\\x1b[?2026h') + await write(\`\\x1b[\${priorRow};1H\\x1b[2K\`) + await write(\`\\x1b[\${27 + ((frame + 8) % 9)};1H\\x1b[2K\`) + await write(\`\\x1b[\${40 + ((frame + 10) % 11)};1H\\x1b[2K\`) + await write('\\x1b[1;1H\\x1b[38;2;142;196;255mgpt-5.5 high\\x1b[0m ') + await write('\\x1b[38;2;106;176;76m~/remote/repro/pr-5969\\x1b[0m ') + await write('/ps to view · /stop to close') + await write('\\x1b[3;1H• Reproducing remote Codex SSH display artifacts with fast status movement') + await write('\\x1b[4;1H• The moving status band intentionally uses gray background during frames') + await write('\\x1b[5;1H• Final screen is clean; any remaining gray slab is stale renderer state') + for (const band of bands) { + await write(\`\\x1b[\${band.row};1H\\x1b[48;2;72;72;72m\${pad('', band.width)}\\x1b[0m\`) + await write(\`\\x1b[\${band.row};3H\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad(band.text, band.width - 4)}\\x1b[0m\`) + } + await write(\`\\x1b[22;1H\\x1b[38;2;106;176;76m+\${' added code '.repeat(8)}\${frame}\\x1b[0m\`) + await write(\`\\x1b[23;1H\\x1b[38;2;230;90;75m-\${' removed code '.repeat(8)}\${frame}\\x1b[0m\`) + await write(\`\\x1b[25;1H\\x1b[38;2;153;199;255m› \${'Run focused and full validation gates '.repeat(3)}\${frame}\\x1b[0m\`) + if (frame % 18 === 0) { + await write(\`\\x1b[52;1H\\x1b[0m• Waited for background terminal · rtk bun run e2e:ui --filter=@dalp/app \${frame}\\r\\n\`) + } + await write('\\x1b[?2026l') + await sleep(8) +} + +await write('\\x1b[?2026h\\x1b[2J\\x1b[H') +await write('\\x1b[38;2;142;196;255mgpt-5.5 high\\x1b[0m ') +await write('\\x1b[38;2;106;176;76m~/remote/repro/pr-5969\\x1b[0m clean final frame\\r\\n\\r\\n') +await write('Final screen intentionally has no gray background bands.\\r\\n') +await write('If Orca leaves wide gray rectangles here, they are stale remote-PTY render artifacts.\\r\\n') +await write('${REMOTE_TUI_DONE}\\r\\n') +await write('\\x1b[?25h\\x1b[?2026l') +setTimeout(() => process.exit(0), 50) +` +} + +export function installRemoteCodexArtifactTui(target: DockerSshRelayTarget): void { + const body = remoteCodexArtifactTuiScript() + dockerWriteFile(target, REMOTE_TUI_PATH, body, '755') +} + +function remoteCodexFixtureScript(): string { + return `#!/usr/bin/env node +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const write = (chunk) => new Promise((resolve) => process.stdout.write(chunk, resolve)) +const cols = Number(process.env.COLUMNS || 120) +const rows = Number(process.env.LINES || 48) +const width = Math.max(42, cols - 4) +const marker = process.argv.join(' ').match(/ORCA_REMOTE_CODEX_ARTIFACT_TUI_DONE|ORCA_REAL_REMOTE_CODEX_DONE_[0-9]+/)?.[0] || '${REMOTE_TUI_DONE}' +const scrollTop = 1 +const viewportTop = Math.max(14, rows - 17) +const viewportBottom = Math.max(viewportTop + 8, rows - 3) + +function pad(text, size) { + const raw = text.length > size ? text.slice(0, size) : text + return raw + ' '.repeat(Math.max(0, size - raw.length)) +} + +async function grayLine(row, text) { + await write(\`\\x1b[\${row};1H\\x1b[48;2;72;72;72m\${pad('', width)}\\x1b[0m\`) + await write(\`\\x1b[\${row};3H\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad(text, width - 4)}\\x1b[0m\`) +} + +async function grayScrollLine(text) { + await write(\`\\x1b[48;2;72;72;72m\${pad(text, width)}\\x1b[0m\\r\\n\`) +} + +async function codexViewportFrame(frame) { + await write('\\x1b[?2026h') + await write('\\x1b[?25l') + await write(\`\\x1b[\${viewportTop};1H\\x1b[2K\\x1b[38;2;142;196;255m>_ OpenAI Codex\\x1b[0m \`) + await write('\\x1b[38;2;106;176;76mgpt-5.5 high\\x1b[0m ') + await write('\\x1b[38;2;180;180;180m/model to change · /ps to view · /stop to close\\x1b[0m') + await grayLine(viewportTop + 2 + (frame % 5), \`• Working for background terminal · rtk bun run e2e:ui --filter=@dalp/app \${frame}\`) + await write(\`\\x1b[\${viewportTop + 8};1H\\x1b[2K\\x1b[38;2;153;199;255m› \${pad('Reviewing terminal renderer state after remote SSH output burst ' + frame, width - 4)}\\x1b[0m\`) + await write('\\x1b[?2026l') +} + +async function insertCodexHistory(frame) { + const historyTop = scrollTop + const historyBottom = Math.max(historyTop + 3, viewportTop - 1) + const cursorTop = Math.max(historyTop, historyBottom - 1) + await write(\`\\x1b[\${historyTop};\${historyBottom}r\`) + await write(\`\\x1b[\${cursorTop};1H\`) + for (let index = 0; index < 3; index += 1) { + const phase = String(frame).padStart(4, '0') + '.' + index + await write('\\r\\n') + await write(\`\\x1b[48;2;72;72;72m\\x1b[K\`) + await write(\`\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad('gpt-5.5 high · ~/code/pr-12250-migration-compare-move-baseprice-claim · /ps to view · /stop to close ' + phase, width)}\\x1b[0m\`) + } + await write('\\x1b[r') + await write(\`\\x1b[\${viewportBottom};1H\`) +} + +async function reverseIndexCodexHistory(frame) { + const historyTop = scrollTop + const historyBottom = Math.max(historyTop + 4, viewportTop - 1) + const scrollAmount = 2 + (frame % 3) + await write(\`\\x1b[\${historyTop};\${rows}r\`) + await write(\`\\x1b[\${viewportTop};1H\`) + for (let index = 0; index < scrollAmount; index += 1) { + await write('\\x1bM') + } + await write('\\x1b[r') + await write(\`\\x1b[\${historyTop};\${historyBottom}r\`) + await write(\`\\x1b[\${historyBottom};1H\`) + for (let index = 0; index < scrollAmount; index += 1) { + await write('\\r\\n') + await write(\`\\x1b[48;2;72;72;72m\\x1b[K\\x1b[38;2;220;220;220;48;2;72;72;72m\${pad('• Waited for background terminal · rtk bun run e2e:ui --filter=@dalp/app ' + frame + ':' + index, width)}\\x1b[0m\`) + } + await write('\\x1b[r') + await write(\`\\x1b[\${viewportBottom};1H\`) +} + +await write('\\x1b]0;codex\\x07') +await write('\\x1b[?25l') +await write('>_ OpenAI Codex (fixture)\\r\\n') +await write('model: gpt-5.5 /model to change\\r\\n') +await write('directory: ' + process.cwd() + '\\r\\n') +await write('permissions: YOLO mode\\r\\n\\r\\n') +await write('Tip: deterministic fixture for Orca SSH Codex display artifacts.\\r\\n\\r\\n') + +for (let frame = 0; frame < ${REMOTE_CODEX_FIXTURE_FRAMES}; frame += 1) { + await codexViewportFrame(frame) + await insertCodexHistory(frame) + if (frame % 4 === 0) { + await reverseIndexCodexHistory(frame) + } + if (frame % 9 === 0) { + await grayScrollLine(\`gpt-5.5 high · ~/code/pr-12250-migration-compare-move-baseprice-claim · /ps to view · /stop to close \${frame}\`) + } + await sleep(${REMOTE_CODEX_FIXTURE_FRAME_DELAY_MS}) +} + +await write('Updated Plan\\r\\n') +await write(' ✓ Reproduce remote Codex SSH display artifact\\r\\n') +await write(' ✓ Capture repeated gray status bands in scrollback\\r\\n') +await write('\\x1b[r\\x1b[?2026h\\x1b[2J\\x1b[H') +await write('Clean final frame after Codex-style gray status redraws.\\r\\n') +await write('There should be no gray background bands on this screen.\\r\\n') +await write('${REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT}\\r\\n') +await write(marker + '\\r\\n') +await write('\\x1b[?25h\\x1b[?2026l') +` +} + +export function installRemoteCodexFixture(target: DockerSshRelayTarget): void { + dockerWriteFile(target, '/usr/local/bin/codex', remoteCodexFixtureScript(), '755') +} + +export function dockerExec( + target: DockerSshRelayTarget, + command: string, + timeoutMs = 60_000 +): void { + execFileSync('docker', ['exec', target.containerName, 'bash', '-lc', command], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: timeoutMs + }) +} + +export function dockerWriteFile( + target: DockerSshRelayTarget, + remotePath: string, + body: string | Uint8Array, + mode: string +): void { + execFileSync( + 'docker', + [ + 'exec', + '-i', + target.containerName, + 'bash', + '-lc', + `cat > ${shellQuote(remotePath)} && chmod ${shellQuote(mode)} ${shellQuote(remotePath)}` + ], + { + input: body, + stdio: ['pipe', 'ignore', 'pipe'], + timeout: 60_000 + } + ) +} diff --git a/tests/e2e/ssh-codex-terminal-observers.ts b/tests/e2e/ssh-codex-terminal-observers.ts new file mode 100644 index 00000000000..276441779e8 --- /dev/null +++ b/tests/e2e/ssh-codex-terminal-observers.ts @@ -0,0 +1,147 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' +import { ensureTerminalVisible, switchToWorktree } from './helpers/store' +import { + execInTerminal, + sendToTerminal, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT } from './ssh-codex-repro-remote-fixtures' +import { switchToNonRemoteWorktree } from './ssh-codex-reconnect-replay-driver' + +export async function waitForRemoteFixtureCleanFinalInHiddenPane( + page: Page, + remoteWorktreeId: string +): Promise { + await expect + .poll( + async () => + page.evaluate( + ({ remoteWorktreeId, cleanFinalText }) => { + const state = window.__store?.getState() + const tabId = state?.activeTabIdByWorktree?.[remoteWorktreeId] ?? null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.().includes(cleanFinalText) === true + }, + { remoteWorktreeId, cleanFinalText: REMOTE_CODEX_FIXTURE_CLEAN_FINAL_TEXT } + ), + { + timeout: 180_000, + message: 'Remote fixture did not reach its clean final frame while hidden' + } + ) + .toBe(true) +} + +export async function waitForRealRemoteCodexCompletion( + page: Page, + doneMarker: string +): Promise { + await expect + .poll( + async () => { + const content = await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }) + return content.split(doneMarker).length - 1 + }, + { + timeout: 300_000, + message: 'Real remote Codex did not emit its final marker' + } + ) + .toBeGreaterThanOrEqual(2) +} + +export async function clearRemoteTerminalAfterCodex( + page: Page, + ptyId: string, + cleanMarker: string +): Promise { + await sendToTerminal(page, ptyId, '/quit\r') + await waitForTerminalOutput(page, 'root@', 20_000, 120_000) + await execInTerminal( + page, + ptyId, + `printf '\\033[2J\\033[H${cleanMarker}\\nREAL_CODEX_CLEAN_SCREEN\\n'` + ) + await waitForTerminalOutput(page, cleanMarker, 20_000, 120_000) +} + +export async function waitForRealRemoteCodexBackgroundStatus(page: Page): Promise { + await expect + .poll( + async () => { + const content = await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }) + return /background terminal|Working for background terminal|REMOTE_CODEX_PHASE/i.test( + content + ) + }, + { + timeout: 180_000, + message: 'Real remote Codex did not enter the long-running background-command state' + } + ) + .toBe(true) +} + +export async function scrollActiveTerminalToArtifactHistory(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('No active terminal pane to scroll') + } + const historyDepth = pane.terminal.buffer.active.baseY + pane.terminal.scrollToLine(Math.max(0, historyDepth - pane.terminal.rows * 3)) + pane.terminal.refresh(0, pane.terminal.rows - 1) + }) + await page.waitForTimeout(800) +} + +export async function stressRestoreRemoteTerminalDuringCodex( + page: Page, + remoteWorktreeId: string +): Promise { + for (let cycle = 0; cycle < 5; cycle += 1) { + await switchToNonRemoteWorktree(page, remoteWorktreeId) + await page.waitForTimeout(8_000) + await switchToWorktree(page, remoteWorktreeId) + await ensureTerminalVisible(page, 45_000) + await waitForActiveTerminalManager(page, 60_000) + await waitForRealRemoteCodexBackgroundStatus(page) + await page.waitForTimeout(900) + } +} diff --git a/tests/e2e/terminal-raster-artifact-analysis.ts b/tests/e2e/terminal-raster-artifact-analysis.ts new file mode 100644 index 00000000000..7658054229a --- /dev/null +++ b/tests/e2e/terminal-raster-artifact-analysis.ts @@ -0,0 +1,283 @@ +import { Buffer } from 'node:buffer' +import { PNG } from 'pngjs' +import type { Page } from '@stablyai/playwright-test' + +export type TerminalRasterTarget = { + clip: { x: number; y: number; width: number; height: number } + cellWidth: number + cellHeight: number + rows: number + cols: number + renderer: 'webgl' | 'dom' + modelGrayRows: number[] + modelStatusRows: number[] +} + +export type GraySlab = { + x: number + y: number + width: number + height: number +} +export type GraySlabAnalysis = { + slabCount: number + slabs: GraySlab[] + rawSlabCount: number + rawSlabs: GraySlab[] + staleStatusGlyphRowCount: number + staleStatusGlyphRows: number[] + target: TerminalRasterTarget + schedulerDebug: Record | null + replayDebug?: Record + duplicateStatusRows?: string[] +} + +export const MAX_FINAL_GRAY_SLABS = 0 + +async function readActiveTerminalRasterTarget(page: Page): Promise { + return page.evaluate(() => { + const isGrayRgb = (red: number, green: number, blue: number): boolean => { + const max = Math.max(red, green, blue) + const min = Math.min(red, green, blue) + return max - min <= 9 && max >= 48 && max <= 112 + } + + const isBufferGrayBackground = (cell: unknown): boolean => { + const record = cell as { + isBgRGB?: () => boolean + isBgPalette?: () => boolean + getBgColor?: () => number + } + if (record?.isBgRGB?.()) { + const color = record.getBgColor?.() ?? 0 + return isGrayRgb((color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff) + } + if (record?.isBgPalette?.()) { + const index = record.getBgColor?.() ?? -1 + const rgba = + pane.terminal._core?._themeService?.colors?.ansi?.[index]?.rgba ?? + pane.terminal._core?._themeService?.colors?.background?.rgba + if (typeof rgba !== 'number') { + return false + } + return isGrayRgb((rgba >> 24) & 0xff, (rgba >> 16) & 0xff, (rgba >> 8) & 0xff) + } + return false + } + + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('No active terminal pane') + } + const screen = pane.container.querySelector('.xterm-screen') + const dimensions = pane.terminal._core?._renderService?.dimensions?.css?.cell + if (!screen || !dimensions) { + throw new Error('Active terminal has no measurable xterm screen') + } + const diagnostics = manager + ?.getRenderingDiagnostics() + .find((diagnostic) => diagnostic.paneId === pane.id) + const rect = screen.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + throw new Error('Active terminal screen is not visible for raster capture') + } + const activeBuffer = pane.terminal.buffer.active + const modelGrayRows: number[] = [] + const modelStatusRows: number[] = [] + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = activeBuffer.getLine(activeBuffer.viewportY + row) + const rowText = line?.translateToString(true) ?? '' + if (/gpt-5\.5|background terminal|\/ps to view|\/stop to close/i.test(rowText)) { + modelStatusRows.push(row) + } + let grayCellCount = 0 + for (let col = 0; col < pane.terminal.cols; col += 1) { + if (isBufferGrayBackground(line?.getCell(col))) { + grayCellCount += 1 + } + } + if (grayCellCount >= Math.min(8, Math.ceil(pane.terminal.cols * 0.08))) { + modelGrayRows.push(row) + } + } + return { + clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + cellWidth: dimensions.width, + cellHeight: dimensions.height, + rows: pane.terminal.rows, + cols: pane.terminal.cols, + renderer: diagnostics?.hasWebgl ? 'webgl' : 'dom', + modelGrayRows, + modelStatusRows + } + }) +} + +function isGraySlabPixel(red: number, green: number, blue: number, alpha: number): boolean { + if (alpha < 245) { + return false + } + const max = Math.max(red, green, blue) + const min = Math.min(red, green, blue) + return max - min <= 9 && max >= 48 && max <= 112 +} + +function isCodexStatusCyanPixel(red: number, green: number, blue: number, alpha: number): boolean { + if (alpha < 160) { + return false + } + return blue >= 160 && green >= 130 && red >= 80 && red <= 180 +} + +function isCodexStatusGreenPixel(red: number, green: number, blue: number, alpha: number): boolean { + if (alpha < 160) { + return false + } + return green >= 120 && red >= 60 && red <= 145 && blue >= 45 && blue <= 130 +} + +function analyzeGraySlabs( + buffer: Buffer, + target: TerminalRasterTarget, + viewport: { width: number; height: number } +): GraySlabAnalysis { + const image = PNG.sync.read(buffer) + const scaleX = image.width / viewport.width + const scaleY = image.height / viewport.height + const originX = Math.round(target.clip.x * scaleX) + const originY = Math.round(target.clip.y * scaleY) + const maxX = Math.min(image.width, originX + Math.round(target.clip.width * scaleX)) + const maxY = Math.min(image.height, originY + Math.round(target.clip.height * scaleY)) + // Tuned to ignore short gray fragments while still catching replay slab bands. + const minRunWidth = Math.max(32, Math.round(target.cellWidth * scaleX * 14)) + // Require multi-pixel vertical continuity to avoid one-line anti-alias noise. + const minRunHeight = Math.max(4, Math.round(target.cellHeight * scaleY * 0.35)) + const runs: GraySlab[] = [] + + for (let y = originY; y < maxY; y += 1) { + let runStart: number | null = null + for (let x = originX; x <= maxX; x += 1) { + const inside = x < maxX + const offset = (y * image.width + x) * 4 + const gray = + inside && + isGraySlabPixel( + image.data[offset] ?? 0, + image.data[offset + 1] ?? 0, + image.data[offset + 2] ?? 0, + image.data[offset + 3] ?? 0 + ) + if (gray && runStart === null) { + runStart = x + } else if (!gray && runStart !== null) { + const width = x - runStart + if (width >= minRunWidth) { + runs.push({ x: runStart - originX, y: y - originY, width, height: 1 }) + } + runStart = null + } + } + } + + const slabs: GraySlab[] = [] + for (const run of runs) { + const previous = slabs.at(-1) + if ( + previous && + Math.abs(previous.x - run.x) <= 3 && + Math.abs(previous.width - run.width) <= 8 && + previous.y + previous.height === run.y + ) { + previous.height += 1 + continue + } + slabs.push({ ...run }) + } + + const meaningfulSlabs = slabs.filter((slab) => slab.height >= minRunHeight) + const artifactSlabs = meaningfulSlabs.filter((slab) => { + const slabCenterCssY = (slab.y + slab.height / 2) / scaleY + const row = Math.floor(slabCenterCssY / target.cellHeight) + return !target.modelGrayRows.some((grayRow) => Math.abs(grayRow - row) <= 1) + }) + const statusGlyphRows: number[] = [] + for (let row = 0; row < target.rows; row += 1) { + let cyanPixelCount = 0 + let greenPixelCount = 0 + const yStart = originY + Math.round(row * target.cellHeight * scaleY) + const yEnd = Math.min(maxY, yStart + Math.round(target.cellHeight * scaleY)) + for (let y = yStart; y < yEnd; y += 1) { + for (let x = originX; x < maxX; x += 1) { + const offset = (y * image.width + x) * 4 + if ( + isCodexStatusCyanPixel( + image.data[offset] ?? 0, + image.data[offset + 1] ?? 0, + image.data[offset + 2] ?? 0, + image.data[offset + 3] ?? 0 + ) + ) { + cyanPixelCount += 1 + } else if ( + isCodexStatusGreenPixel( + image.data[offset] ?? 0, + image.data[offset + 1] ?? 0, + image.data[offset + 2] ?? 0, + image.data[offset + 3] ?? 0 + ) + ) { + greenPixelCount += 1 + } + } + } + if ( + row >= 8 && + // Thresholds tuned to classify stale Codex status glyph rows in screenshots. + cyanPixelCount >= Math.max(12, Math.round(target.cellWidth * scaleX * 3)) && + greenPixelCount >= Math.max(24, Math.round(target.cellWidth * scaleX * 8)) && + !target.modelStatusRows.some((statusRow) => Math.abs(statusRow - row) <= 1) + ) { + statusGlyphRows.push(row) + } + } + return { + slabCount: artifactSlabs.length, + slabs: artifactSlabs.slice(0, 12), + rawSlabCount: meaningfulSlabs.length, + rawSlabs: meaningfulSlabs.slice(0, 12), + staleStatusGlyphRowCount: statusGlyphRows.length, + staleStatusGlyphRows: statusGlyphRows.slice(0, 12), + target, + schedulerDebug: null + } +} + +export async function captureGraySlabAnalysis(page: Page): Promise<{ + analysis: GraySlabAnalysis + screenshot: Buffer +}> { + const target = await readActiveTerminalRasterTarget(page) + const schedulerDebug = await page.evaluate( + () => window.__terminalOutputSchedulerDebug?.snapshot?.() ?? null + ) + const viewport = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight + })) + const screenshot = Buffer.from(await page.screenshot()) + const analysis = analyzeGraySlabs(screenshot, target, viewport) + analysis.schedulerDebug = schedulerDebug + return { + analysis, + screenshot + } +} diff --git a/tests/e2e/terminal-repro-evidence.ts b/tests/e2e/terminal-repro-evidence.ts new file mode 100644 index 00000000000..1ae6bd0efa9 --- /dev/null +++ b/tests/e2e/terminal-repro-evidence.ts @@ -0,0 +1,19 @@ +import type { Buffer } from 'node:buffer' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { GraySlabAnalysis } from './terminal-raster-artifact-analysis' + +const REPRO_ARTIFACT_DIR = path.join(process.cwd(), '.tmp', 'issue-5969-repro') + +export function persistReproEvidence( + label: string, + analysis: GraySlabAnalysis, + screenshot: Buffer +): void { + mkdirSync(REPRO_ARTIFACT_DIR, { recursive: true }) + writeFileSync(path.join(REPRO_ARTIFACT_DIR, `${label}.png`), screenshot) + writeFileSync( + path.join(REPRO_ARTIFACT_DIR, `${label}.json`), + `${JSON.stringify(analysis, null, 2)}\n` + ) +} diff --git a/tests/e2e/terminal-screenshot-diff.ts b/tests/e2e/terminal-screenshot-diff.ts new file mode 100644 index 00000000000..1a3819a2691 --- /dev/null +++ b/tests/e2e/terminal-screenshot-diff.ts @@ -0,0 +1,47 @@ +import { PNG } from 'pngjs' + +export type ScreenshotDiffSummary = { + matches: boolean + diffPixels: number + diffRatio: number + width: number + height: number +} + +export function compareTerminalScreenshots( + baselineBuffer: Buffer, + candidateBuffer: Buffer +): ScreenshotDiffSummary { + const baseline = PNG.sync.read(baselineBuffer) + const candidate = PNG.sync.read(candidateBuffer) + if (baseline.width !== candidate.width || baseline.height !== candidate.height) { + return { + matches: false, + diffPixels: Number.POSITIVE_INFINITY, + diffRatio: Number.POSITIVE_INFINITY, + width: candidate.width, + height: candidate.height + } + } + + let diffPixels = 0 + for (let offset = 0; offset < baseline.data.length; offset += 4) { + const redDiff = Math.abs((baseline.data[offset] ?? 0) - (candidate.data[offset] ?? 0)) + const greenDiff = Math.abs((baseline.data[offset + 1] ?? 0) - (candidate.data[offset + 1] ?? 0)) + const blueDiff = Math.abs((baseline.data[offset + 2] ?? 0) - (candidate.data[offset + 2] ?? 0)) + const alphaDiff = Math.abs((baseline.data[offset + 3] ?? 0) - (candidate.data[offset + 3] ?? 0)) + if (redDiff + greenDiff + blueDiff + alphaDiff > 48) { + diffPixels += 1 + } + } + + const pixelCount = baseline.width * baseline.height + const diffRatio = pixelCount > 0 ? diffPixels / pixelCount : Number.POSITIVE_INFINITY + return { + matches: diffRatio <= 0.015, + diffPixels, + diffRatio, + width: baseline.width, + height: baseline.height + } +} diff --git a/tests/e2e/terminal-tab-screenshot.ts b/tests/e2e/terminal-tab-screenshot.ts new file mode 100644 index 00000000000..506ed1a200f --- /dev/null +++ b/tests/e2e/terminal-tab-screenshot.ts @@ -0,0 +1,21 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' + +function tabScreenLocator(page: Page, tabId: string): ReturnType { + return page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() +} + +export async function captureStableTabScreenshot(page: Page, tabId: string): Promise { + const screen = tabScreenLocator(page, tabId) + await expect(screen).toBeVisible() + let previous = await screen.screenshot({ animations: 'disabled' }) + for (let attempt = 0; attempt < 10; attempt += 1) { + await page.waitForTimeout(250) + const next = await screen.screenshot({ animations: 'disabled' }) + if (next.equals(previous)) { + return next + } + previous = next + } + throw new Error(`Terminal surface for tab ${tabId} did not stabilize for screenshot`) +} diff --git a/tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts b/tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts new file mode 100644 index 00000000000..75d65bea2d9 --- /dev/null +++ b/tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts @@ -0,0 +1,294 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager +} from './helpers/terminal' + +const SIGWINCH_PROBE_PAGE = 100 +const SIGWINCH_PROBE_ROWS = 12 + +type HiddenOutputDebugSnapshot = { + hiddenRendererSkipCount: number +} + +type HiddenOutputRecoveryWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } + __terminalPtyOutputDebug?: { + reset: () => void + snapshot: () => HiddenOutputDebugSnapshot + } + __terminalHiddenSnapshotOverride?: { + setPending: ( + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } + ) => void + resolve: (ptyId: string) => void + } +} + +function buildSigwinchProbeRows(page: number): string { + return Array.from( + { length: SIGWINCH_PROBE_ROWS }, + (_, index) => `row ${String(page + index).padStart(3, '0')}` + ).join('\r\n') +} + +function buildSigwinchResetProbeCommand(): string { + const script = [ + 'let armed=false', + `let page=${SIGWINCH_PROBE_PAGE}`, + "const topLabel=['TOP','AFTER','SIGWINCH'].join('_')", + `function paint(label){process.stdout.write('\\x1b[?1049h\\x1b[2J\\x1b[H'+label+' page='+page+'\\r\\n'+Array.from({length:${SIGWINCH_PROBE_ROWS}},(_,i)=>'row '+String(page+i).padStart(3,'0')).join('\\r\\n'))}`, + "paint('VISIBLE_BEFORE_SWITCH')", + "process.stdin.setEncoding('utf8')", + "process.stdin.on('data',data=>{if(data.includes('ARM_SIGWINCH_PROBE')){armed=true;paint('ARMED_BEFORE_SWITCH')}})", + "process.on('SIGWINCH',()=>{if(armed===false)return;page=0;paint(topLabel)})", + 'setInterval(()=>{},1000)' + ].join(';') + return `node -e ${JSON.stringify(script)}` +} + +function buildSigwinchResetProbeSnapshot(label: string): string { + return `\x1b[?1049h\x1b[2J\x1b[H${label} page=${SIGWINCH_PROBE_PAGE}\r\n${buildSigwinchProbeRows(SIGWINCH_PROBE_PAGE)}` +} + +async function createAgentMarkedTerminalTab( + page: Page, + agent: 'codex', + command: string +): Promise { + const worktreeId = (await getActiveWorktreeId(page))! + return page.evaluate( + ({ worktreeId, agent, command }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + launchAgent: agent + }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: agent, + telemetry: { + agent_kind: agent, + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, agent, command } + ) +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTab(id) + store.getState().setActiveTabType('terminal') + }, tabId) + await expect + .poll( + () => + page + .locator(`[data-testid="sortable-tab"][data-active="true"]`) + .getAttribute('data-tab-id'), + { + timeout: 3_000 + } + ) + .toBe(tabId) +} + +async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId), + { timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` } + ) + .not.toBeNull() +} + +async function readPaneIdentityOnTab( + page: Page, + tabId: string +): Promise<{ leafId: string; ptyId: string; cols: number; rows: number }> { + const identity = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + return { + leafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null, + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + }, tabId) + if (!identity?.leafId || !identity.ptyId) { + throw new Error(`Pane identity for tab ${tabId} is incomplete`) + } + return { + leafId: identity.leafId, + ptyId: identity.ptyId, + cols: identity.cols, + rows: identity.rows + } +} + +async function resetHiddenOutputDebug(page: Page): Promise { + await page.evaluate(() => { + ;(window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.reset() + }) +} + +async function readHiddenOutputDebug(page: Page): Promise { + return page.evaluate(() => { + return (window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.snapshot() ?? null + }) +} + +async function injectPaneData( + page: Page, + paneKey: string, + data: string, + meta?: { seq?: number; rawLength?: number } +): Promise { + const injected = await page.evaluate( + ({ paneKey, data, meta }) => + (window as HiddenOutputRecoveryWindow).__terminalPtyDataInjection?.inject( + paneKey, + data, + meta + ) ?? false, + { paneKey, data, meta } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function setHiddenSnapshotOverride( + page: Page, + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } +): Promise { + await page.evaluate( + ({ ptyId, snapshot }) => { + const api = (window as HiddenOutputRecoveryWindow).__terminalHiddenSnapshotOverride + if (!api) { + throw new Error('Hidden snapshot override API unavailable') + } + api.setPending(ptyId, snapshot) + api.resolve(ptyId) + }, + { ptyId, snapshot } + ) +} + +test.describe('Terminal tab switch SIGWINCH restore', () => { + test('keeps an alternate-screen Codex viewport after hidden snapshot replay', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const agentTabId = await createAgentMarkedTerminalTab( + orcaPage, + 'codex', + buildSigwinchResetProbeCommand() + ) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, agentTabId) + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'SIGWINCH probe TUI did not paint its initial scrolled page' + }) + .toContain(`VISIBLE_BEFORE_SWITCH page=${SIGWINCH_PROBE_PAGE}`) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, agentTabId) + await sendToTerminal(orcaPage, paneIdentity.ptyId, 'ARM_SIGWINCH_PROBE\n') + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'SIGWINCH probe TUI did not arm after startup settled' + }) + .toContain(`ARMED_BEFORE_SWITCH page=${SIGWINCH_PROBE_PAGE}`) + await orcaPage.waitForTimeout(1_200) + const armedContentAfterSettle = await getTerminalContent(orcaPage, 8_000) + expect(armedContentAfterSettle).not.toContain('TOP_AFTER_SIGWINCH page=0') + const paneKey = `${agentTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const hiddenFrame = ['\x1b[?2026h', 'hidden probe frame', '\x1b[?2026l'].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Codex probe hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: buildSigwinchResetProbeSnapshot('RESTORED_SNAPSHOT'), + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, agentTabId) + + await expect + .poll( + async () => { + const content = await getTerminalContent(orcaPage, 8_000) + if (content.includes('TOP_AFTER_SIGWINCH page=0')) { + return 'top' + } + return content.includes(`RESTORED_SNAPSHOT page=${SIGWINCH_PROBE_PAGE}`) + ? 'snapshot' + : 'pending' + }, + { + timeout: 10_000, + message: 'hidden snapshot replay did not restore the probe TUI page' + } + ) + .toBe('snapshot') + await orcaPage.waitForTimeout(1_200) + const contentAfterSettle = await getTerminalContent(orcaPage, 8_000) + expect(contentAfterSettle).toContain(`RESTORED_SNAPSHOT page=${SIGWINCH_PROBE_PAGE}`) + expect(contentAfterSettle).not.toContain('TOP_AFTER_SIGWINCH page=0') + }) +}) diff --git a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts new file mode 100644 index 00000000000..d154882ec23 --- /dev/null +++ b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts @@ -0,0 +1,867 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager +} from './helpers/terminal' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' +import { captureStableTabScreenshot } from './terminal-tab-screenshot' + +const SILENT_FOREGROUND_COMMAND = 'node -e "setInterval(() => {}, 1000)"\r' +const TAB_A_GLYPH_ROW = 'abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~' +const TAB_B_GLYPH_ROW = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-' + +type TabTerminalGeometry = { + tabId: string + overlayWidth: number + overlayHeight: number + overlayDisplay: string + cols: number + rows: number + cellWidth: number + screenWidth: number + screenRight: number + rowRight: number + contentWidthRatio: number + markerPresent: boolean + hasWebgl: boolean +} + +const TAB_SWITCH_MARKER_PREFIX = 'TAB_SWITCH_VISUAL_RESTORE' + +type TerminalOutputSchedulerSnapshot = { + backgroundEnqueueCount: number + scheduledDrainCount: number + queuedChars: number +} + +type SchedulerDebugWindow = Window & { + __terminalOutputSchedulerDebug?: { + reset: () => void + snapshot: () => TerminalOutputSchedulerSnapshot + } +} + +type HiddenOutputDebugSnapshot = { + hiddenRendererSkipCount: number + hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number +} + +type HiddenOutputRecoveryWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } + __terminalPtyOutputDebug?: { + reset: () => void + snapshot: () => HiddenOutputDebugSnapshot + } + __terminalHiddenSnapshotOverride?: { + setPending: ( + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } + ) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void + } +} + +async function forceWebglOnActiveTab(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { + ...state.settings, + terminalGpuAcceleration: 'on' + } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) +} + +async function ensureTwoTerminalTabs( + page: Page +): Promise<{ firstTabId: string; secondTabId: string }> { + const worktreeId = (await getActiveWorktreeId(page))! + if ((await page.locator('[data-testid="sortable-tab"]').count()) < 2) { + await page.getByRole('button', { name: 'New tab' }).click({ force: true }) + await page + .getByRole('menuitem', { name: /New Terminal/i }) + .first() + .click({ force: true }) + await expect + .poll(() => page.locator('[data-testid="sortable-tab"]').count(), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(2) + } + const firstTabId = (await getActiveTabId(page))! + const secondTabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const other = tabs.find((tab) => tab.id !== state.activeTabId) + return other?.id ?? null + }, worktreeId) + if (!secondTabId) { + throw new Error('Expected a second terminal tab') + } + return { firstTabId, secondTabId } +} + +async function createAgentMarkedTerminalTab( + page: Page, + agent: 'codex' | 'grok', + command: string +): Promise { + const worktreeId = (await getActiveWorktreeId(page))! + return page.evaluate( + ({ worktreeId, agent, command }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + launchAgent: agent + }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: agent, + telemetry: { + agent_kind: agent, + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, agent, command } + ) +} + +async function createCodexMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'codex', 'node -e "setInterval(() => {}, 1000)"') +} + +async function createGrokMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'grok', 'node -e "setInterval(() => {}, 1000)"') +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTab(id) + store.getState().setActiveTabType('terminal') + }, tabId) + await expect + .poll( + () => + page + .locator(`[data-testid="sortable-tab"][data-active="true"]`) + .getAttribute('data-tab-id'), + { + timeout: 3_000 + } + ) + .toBe(tabId) +} + +async function waitForWebglOnTab(page: Page, tabId: string): Promise { + return page + .waitForFunction( + (id) => { + const diagnostics = window.__paneManagers?.get(id)?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((entry) => entry.hasWebgl) + }, + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId), + { timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` } + ) + .not.toBeNull() + const ptyId = await page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId) + if (!ptyId) { + throw new Error(`Pane for tab ${tabId} has no PTY binding`) + } + return ptyId +} + +async function readPaneIdentityOnTab( + page: Page, + tabId: string +): Promise<{ leafId: string; ptyId: string; cols: number; rows: number }> { + const identity = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + return { + leafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null, + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + }, tabId) + if (!identity?.leafId || !identity.ptyId) { + throw new Error(`Pane identity for tab ${tabId} is incomplete`) + } + return { + leafId: identity.leafId, + ptyId: identity.ptyId, + cols: identity.cols, + rows: identity.rows + } +} + +async function resetHiddenOutputDebug(page: Page): Promise { + await page.evaluate(() => { + ;(window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.reset() + }) +} + +async function readHiddenOutputDebug(page: Page): Promise { + return page.evaluate(() => { + return (window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.snapshot() ?? null + }) +} + +async function injectPaneData( + page: Page, + paneKey: string, + data: string, + meta?: { seq?: number; rawLength?: number } +): Promise { + const injected = await page.evaluate( + ({ paneKey, data, meta }) => + (window as HiddenOutputRecoveryWindow).__terminalPtyDataInjection?.inject( + paneKey, + data, + meta + ) ?? false, + { paneKey, data, meta } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function setHiddenSnapshotOverride( + page: Page, + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } +): Promise { + await page.evaluate( + ({ ptyId, snapshot }) => { + const api = (window as HiddenOutputRecoveryWindow).__terminalHiddenSnapshotOverride + if (!api) { + throw new Error('Hidden snapshot override API unavailable') + } + api.setPending(ptyId, snapshot) + api.resolve(ptyId) + }, + { ptyId, snapshot } + ) +} + +async function resetTerminalOutputSchedulerDebug(page: Page): Promise { + await page.evaluate(() => { + const debug = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('Terminal output scheduler debug API unavailable') + } + debug.reset() + }) +} + +async function waitForHiddenOutputSchedulerActivity( + page: Page +): Promise { + await expect + .poll( + () => + page.evaluate(() => { + const snapshot = ( + window as SchedulerDebugWindow + ).__terminalOutputSchedulerDebug?.snapshot() + return snapshot?.backgroundEnqueueCount ?? 0 + }), + { + timeout: 5_000, + message: 'hidden PTY output did not reach the background output scheduler' + } + ) + .toBeGreaterThan(0) + return page.evaluate(() => { + const snapshot = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug?.snapshot() + if (!snapshot) { + throw new Error('Terminal output scheduler debug API unavailable') + } + return { + backgroundEnqueueCount: snapshot.backgroundEnqueueCount, + scheduledDrainCount: snapshot.scheduledDrainCount, + queuedChars: snapshot.queuedChars + } + }) +} + +async function startHiddenPtyOutputBurst(page: Page, ptyId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_PTY_${runId}` + const script = [ + `const marker=${JSON.stringify(marker)};`, + 'setTimeout(()=>{', + 'let frame=0;', + 'const timer=setInterval(()=>{', + 'console.log(`${marker} frame=${String(frame).padStart(3,"0")} abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\\\#@%&*+=~`);', + 'frame+=1;', + 'if(frame>=180) clearInterval(timer);', + '},1);', + '},30);' + ].join('') + await sendToTerminal(page, ptyId, `node -e ${JSON.stringify(script)}\r`) +} + +async function writeStaticTabContent( + page: Page, + tabId: string, + marker: string, + glyphRow: string +): Promise { + await page.evaluate( + async ({ id, marker, glyphRow }) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`Pane unavailable for tab ${id}`) + } + const rows = Array.from( + { length: 14 }, + (_, row) => `${marker} row ${row} | ${glyphRow} |\r\n` + ).join('') + await new Promise((resolve) => + pane.terminal.write(`\x1b[2J\x1b[3J\x1b[H\x1b[?25l${rows}`, resolve) + ) + pane.terminal.refresh(0, pane.terminal.rows - 1) + }, + { id: tabId, marker, glyphRow } + ) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +async function resetAtlasOnTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + window.__paneManagers?.get(id)?.resetWebglTextureAtlases?.() + }, tabId) +} + +async function injectHiddenStreamingBurst(page: Page, tabId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + await page.evaluate( + ({ tabId, marker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + // Why: Grok sessions can emit large formatted bursts while the tab is + // hidden; stress the visibility-resume flush path beyond a few lines. + const burst = Array.from({ length: 400 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 16) + 1)}${'░'.repeat(16 - ((frame % 16) + 1))}` + return [ + `hidden_stream frame=${String(frame).padStart(3, '0')} ${marker}`, + `Dimension │ Rating │`, + `status ${frame % 2 === 0 ? 'thinking' : 'streaming'} ${progress}`, + `abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~` + ].join('\r\n') + }).join('\r\n') + return new Promise((resolve) => { + pane.terminal.write(`${burst}\r\n`, resolve) + }) + }, + { tabId, marker } + ) +} + +async function readTabTerminalGeometry( + page: Page, + tabId: string, + runId: string +): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + return page.evaluate( + ({ tabId, marker }) => { + const overlay = document.querySelector( + `[data-terminal-overlay-tab-id="${tabId}"]` + ) + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const overlayRect = overlay?.getBoundingClientRect() + const screen = pane.container.querySelector('.xterm-screen') + if (!screen) { + throw new Error(`No xterm screen for tab ${tabId}`) + } + const screenRect = screen.getBoundingClientRect() + const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0 + const renderedContentWidth = pane.terminal.cols * cellWidth + const rowRight = screenRect.left + renderedContentWidth + const contentWidthRatio = screenRect.width > 0 ? renderedContentWidth / screenRect.width : 0 + const buffer = pane.terminal.buffer.active + let markerPresent = false + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + if (line.includes(marker)) { + markerPresent = true + break + } + } + const diagnostics = manager?.getRenderingDiagnostics?.() ?? [] + const hasWebgl = diagnostics.some((entry) => entry.hasWebgl) + return { + tabId, + overlayWidth: overlayRect?.width ?? 0, + overlayHeight: overlayRect?.height ?? 0, + overlayDisplay: overlay ? window.getComputedStyle(overlay).display : 'missing', + cols: pane.terminal.cols, + rows: pane.terminal.rows, + cellWidth, + screenWidth: screenRect.width, + screenRight: screenRect.right, + rowRight, + contentWidthRatio, + markerPresent, + hasWebgl + } + }, + { tabId, marker } + ) +} + +function geometryLooksCorrupted(geometry: TabTerminalGeometry): string | null { + if (geometry.overlayDisplay === 'none') { + return 'overlay still display:none after activation' + } + if (geometry.overlayWidth < 200 || geometry.overlayHeight <= 0) { + return `overlay dimensions invalid (${geometry.overlayWidth}x${geometry.overlayHeight}px)` + } + if (geometry.cols < 40 || geometry.rows <= 0) { + return `terminal grid invalid (${geometry.cols}x${geometry.rows})` + } + // Why: half-width bug paints content in only ~50% of the screen; rowRight + // lags far behind screenRight when cols are stale. + if (geometry.contentWidthRatio > 0 && geometry.contentWidthRatio < 0.82) { + return `content width ratio ${geometry.contentWidthRatio.toFixed(3)} < 0.82` + } + if ( + geometry.screenWidth > 0 && + geometry.rowRight < geometry.screenRight - geometry.cellWidth * 4 + ) { + return `rowRight ${geometry.rowRight.toFixed(1)} lags screenRight ${geometry.screenRight.toFixed(1)}` + } + return null +} + +async function captureTabScreenshot( + page: Page, + tabId: string, + testInfo: TestInfo, + label: string +): Promise { + const overlay = page.locator(`[data-terminal-overlay-tab-id="${tabId}"]`) + const path = testInfo.outputPath(`${label}-${tabId}.png`) + await overlay.screenshot({ path }) + await testInfo.attach(`${label}.png`, { path, contentType: 'image/png' }) +} + +test.describe('Terminal tab switch visual restore', () => { + test.describe.configure({ mode: 'serial' }) + + test('keeps full-width geometry after switching away and back', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + await writeStaticTabContent(orcaPage, firstTabId, marker, TAB_A_GLYPH_ROW) + + const baseline = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + expect(baseline.markerPresent).toBe(true) + expect(baseline.overlayWidth).toBeGreaterThan(300) + expect(geometryLooksCorrupted(baseline)).toBeNull() + + const corruptionReports: string[] = [] + await resetTerminalOutputSchedulerDebug(orcaPage) + await startHiddenPtyOutputBurst(orcaPage, firstPtyId, runId) + + for (let cycle = 0; cycle < 12; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await injectHiddenStreamingBurst(orcaPage, firstTabId, runId) + // Why: rapid back-to-back switches mirror the user's leave/return pattern + // and race the overlay's rAF/50ms refit retries. + await activateTerminalTab(orcaPage, firstTabId) + if (cycle % 3 === 0) { + await activateTerminalTab(orcaPage, secondTabId) + await activateTerminalTab(orcaPage, firstTabId) + } + + // Sample immediately — bug often shows before the 50ms overlay refit retry. + const immediate = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const immediateIssue = geometryLooksCorrupted(immediate) + if (immediateIssue) { + corruptionReports.push(`cycle ${cycle} immediate: ${immediateIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-immediate-cycle-${cycle}` + ) + } + + await orcaPage.waitForTimeout(60) + const settled = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const settledIssue = geometryLooksCorrupted(settled) + if (settledIssue) { + corruptionReports.push(`cycle ${cycle} settled: ${settledIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-settled-cycle-${cycle}` + ) + } + } + const schedulerActivity = await waitForHiddenOutputSchedulerActivity(orcaPage) + expect(schedulerActivity.scheduledDrainCount).toBeGreaterThan(0) + + if (corruptionReports.length > 0) { + console.log('[tab-switch-repro] corruption reports:', corruptionReports) + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `tab switch left stale terminal geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('keeps geometry after hidden alt-screen TUI redraws during tab switches', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const finalMarker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}_ALT_24` + + await orcaPage.evaluate( + ({ tabId, finalMarker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frames = Array.from({ length: 25 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + }).join('') + return new Promise((resolve) => pane.terminal.write(frames, resolve)) + }, + { tabId: firstTabId, finalMarker } + ) + + const corruptionReports: string[] = [] + for (let cycle = 0; cycle < 6; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate( + ({ tabId, finalMarker, cycle }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frame = cycle * 4 + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const redraw = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + return new Promise((resolve) => pane.terminal.write(redraw, resolve)) + }, + { tabId: firstTabId, finalMarker, cycle } + ) + await activateTerminalTab(orcaPage, firstTabId) + + const geometry = await readTabTerminalGeometry(orcaPage, firstTabId, `${runId}_ALT`) + const issue = geometryLooksCorrupted(geometry) + if (issue || !geometry.markerPresent) { + corruptionReports.push( + `cycle ${cycle}: ${issue ?? 'marker missing after alt-screen redraw'}` + ) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `alt-screen-corrupt-cycle-${cycle}` + ) + } + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `alt-screen hidden redraw left corrupted geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('restores skipped hidden agent output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const agentTabId = await createCodexMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, agentTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, agentTabId) + const paneKey = `${agentTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_AGENT_${runId}` + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Codex-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, agentTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped hidden-output recovery' + }) + .toContain(marker) + }) + + test('restores skipped hidden Grok output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const grokTabId = await createGrokMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, grokTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, grokTabId) + const paneKey = `${grokTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_GROK_${runId}` + // Why: synchronized-output mode exercises the hidden renderer skip path + // used by agent TUIs before light tab resume requests recovery. + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Grok-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, grokTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped Grok hidden-output recovery' + }) + .toContain(marker) + }) + + test('keeps returned tab glyphs intact across tab switches', async ({ orcaPage }, testInfo) => { + // Why: screenshot equality catches WebGL atlas corruption on the tab being + // resumed, not just stale cols/rows geometry checks. + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + await activateTerminalTab(orcaPage, firstTabId) + const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate((id) => { + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, secondTabId) + const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) + if (!firstWebgl || !secondWebgl) { + test.skip(true, 'WebGL never attached on both tabs') + return + } + + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + const secondPtyId = await waitForPanePtyIdOnTab(orcaPage, secondTabId) + await sendToTerminal(orcaPage, firstPtyId, SILENT_FOREGROUND_COMMAND) + await sendToTerminal(orcaPage, secondPtyId, SILENT_FOREGROUND_COMMAND) + await orcaPage.waitForTimeout(1_000) + + const runId = `${Date.now()}` + const markerA = `${TAB_SWITCH_MARKER_PREFIX}_A_${runId}` + const markerB = `${TAB_SWITCH_MARKER_PREFIX}_B_${runId}` + await writeStaticTabContent(orcaPage, firstTabId, markerA, TAB_A_GLYPH_ROW) + await activateTerminalTab(orcaPage, secondTabId) + await writeStaticTabContent(orcaPage, secondTabId, markerB, TAB_B_GLYPH_ROW) + + await activateTerminalTab(orcaPage, firstTabId) + await resetAtlasOnTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(800) + const baseline = await captureStableTabScreenshot(orcaPage, firstTabId) + + const screenshotMismatches: string[] = [] + for (let cycle = 0; cycle < 8; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + // Why: do not write into the hidden tab here — new bytes would change the + // screenshot even when rendering is healthy. This cycle only exercises the + // suspend/resume + atlas reset path on unchanged content. + await activateTerminalTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(100) + const afterReturn = await captureStableTabScreenshot(orcaPage, firstTabId) + const diff = compareTerminalScreenshots(baseline, afterReturn) + if (!diff.matches) { + screenshotMismatches.push( + `cycle ${cycle}: ${diff.diffPixels} px (${(diff.diffRatio * 100).toFixed(2)}%)` + ) + await testInfo.attach(`after-return-cycle-${cycle}`, { + body: afterReturn, + contentType: 'image/png' + }) + } + } + + await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' }) + expect( + screenshotMismatches, + screenshotMismatches.length > 0 + ? `returned tab glyphs changed after switch cycles: ${screenshotMismatches.join(', ')}` + : undefined + ).toEqual([]) + }) +}) diff --git a/tests/e2e/terminal-webgl-reset-capture.ts b/tests/e2e/terminal-webgl-reset-capture.ts new file mode 100644 index 00000000000..93da7610ca1 --- /dev/null +++ b/tests/e2e/terminal-webgl-reset-capture.ts @@ -0,0 +1,30 @@ +import type { Buffer } from 'node:buffer' +import type { Page } from '@stablyai/playwright-test' +import { captureGraySlabAnalysis, type GraySlabAnalysis } from './terminal-raster-artifact-analysis' + +export async function resetWebglAndCaptureGraySlabAnalysis(page: Page): Promise<{ + analysis: GraySlabAnalysis + screenshot: Buffer +}> { + await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + manager?.resetWebglTextureAtlases?.() + pane?.terminal?.refresh?.(0, Math.max(0, (pane.terminal.rows ?? 1) - 1)) + }) + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + return captureGraySlabAnalysis(page) +}