From 78519d8dce2788b2c1463871261f90e4871d5780 Mon Sep 17 00:00:00 2001
From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com>
Date: Fri, 26 Jun 2026 23:57:17 -0700
Subject: [PATCH 01/54] fix: pr-bug-scan validated finding from #6315 (#6471)
* fix: address pr-bug-scan validated finding from #6315
Protect fenced/inline code spans from the HTML stripTags pass in normalizeMobileMarkdownPreviewHtml; blocks stripTags(next) at mobile-markdown-preview-html.ts:95 from deleting <...> inside code.
* Preserve non-tag angle brackets in markdown preview HTML parser
Only strip standard/known HTML tags instead of matching any `<...>`
pattern. This prevents false-positive stripping of generic types
(e.g., `Array`) or math comparisons (e.g., `1 < 2`) in prose.
Additionally, robustify markdown code block preservation by using a
Private Use Area prefix (`\uE000`) and dynamically padding it to avoid
collisions with literal input content.
* Protect code spans when stripping HTML tags in mobile preview
Ensure HTML-like content within code spans is not erroneously removed
by the tag stripping logic. We now protect markdown code blocks and
restore them after stripping tags and decoding HTML entities.
---------
Co-authored-by: orca-bug-scan-bot
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
---
mobile/src/components/MobileMarkdown.test.ts | 42 ++++++++
.../mobile-markdown-preview-html.ts | 100 +++++++++++++++++-
2 files changed, 139 insertions(+), 3 deletions(-)
diff --git a/mobile/src/components/MobileMarkdown.test.ts b/mobile/src/components/MobileMarkdown.test.ts
index cfc7a0df410..52eb0e5ad2b 100644
--- a/mobile/src/components/MobileMarkdown.test.ts
+++ b/mobile/src/components/MobileMarkdown.test.ts
@@ -49,4 +49,46 @@ describe('parseMobileMarkdown', () => {
normalizeMobileMarkdownPreviewHtml('Use <button>
')
).toBe('Use `<button>`')
})
+
+ it('preserves angle brackets and generics inside fenced and inline code', () => {
+ expect(normalizeMobileMarkdownPreviewHtml('```html\nx
\n```')).toBe(
+ '```html\nx
\n```'
+ )
+ expect(normalizeMobileMarkdownPreviewHtml('```ts\nconst x: Array = []\n```')).toBe(
+ '```ts\nconst x: Array = []\n```'
+ )
+ expect(normalizeMobileMarkdownPreviewHtml('```ts\nconst x: Array = []')).toBe(
+ '```ts\nconst x: Array = []'
+ )
+ expect(normalizeMobileMarkdownPreviewHtml('Use `Array` here')).toBe(
+ 'Use `Array` here'
+ )
+ })
+
+ it('preserves non-tag angle bracket prose while stripping known HTML tags', () => {
+ expect(normalizeMobileMarkdownPreviewHtml('1 < 2 and 3 > 1')).toBe('1 < 2 and 3 > 1')
+ expect(normalizeMobileMarkdownPreviewHtml('Array in prose')).toBe(
+ 'Array in prose'
+ )
+ expect(normalizeMobileMarkdownPreviewHtml('')).toBe(
+ ''
+ )
+ expect(normalizeMobileMarkdownPreviewHtml('Readable text
')).toBe('Readable text')
+ })
+
+ it('preserves angle brackets inside Markdown code produced from HTML code tags', () => {
+ expect(normalizeMobileMarkdownPreviewHtml('Use Array<string>
')).toBe(
+ 'Use `Array`'
+ )
+ expect(
+ normalizeMobileMarkdownPreviewHtml('Use <div>x</div>
')
+ ).toBe('Use `x
`')
+ })
+
+ it('does not replace literal code placeholder text in markdown prose', () => {
+ const literalPlaceholder = '\uE000ORCA_MD_CODE_0\uE000'
+ expect(normalizeMobileMarkdownPreviewHtml(`${literalPlaceholder} and \`Array\``)).toBe(
+ `${literalPlaceholder} and \`Array\``
+ )
+ })
})
diff --git a/mobile/src/components/mobile-markdown-preview-html.ts b/mobile/src/components/mobile-markdown-preview-html.ts
index 14965a4ff82..4c72e2cb4c3 100644
--- a/mobile/src/components/mobile-markdown-preview-html.ts
+++ b/mobile/src/components/mobile-markdown-preview-html.ts
@@ -8,6 +8,21 @@ const escapedHtmlEntityTokens = [
{ pattern: /'/gi, token: '\uE000ORCA_MD_ENTITY_APOS\uE000', value: ''' }
] as const
+const strippableHtmlTagNames = new Set(
+ [
+ 'a abbr address area article aside audio b base bdi bdo blockquote body br button',
+ 'canvas caption cite code col colgroup data datalist dd del details dfn dialog div',
+ 'dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head',
+ 'header hgroup hr html i iframe img input ins kbd label legend li link main map',
+ 'mark menu meta meter nav noscript object ol optgroup option output p picture pre',
+ 'progress q rp rt ruby s samp script search section select slot small source span',
+ 'strong style sub summary sup table tbody td template textarea tfoot th thead time',
+ 'title tr track u ul var video wbr'
+ ]
+ .join(' ')
+ .split(' ')
+)
+
function protectEscapedHtmlEntities(value: string): string {
return escapedHtmlEntityTokens.reduce(
(next, entity) => next.replace(entity.pattern, entity.token),
@@ -35,10 +50,20 @@ function decodeHtmlEntities(value: string, preserveEscapedEntities = false): str
}
function stripTags(value: string): string {
- return decodeHtmlEntities(value.replace(/<[^>]+>/g, ''), true)
+ const { protectedText, codeSpans, placeholderPrefix } = protectMarkdownCode(value)
+ const stripped = decodeHtmlEntities(
+ protectedText
+ .replace(//g, '')
+ .replace(/<\/?([A-Za-z][A-Za-z0-9:-]*)(?:\s[^<>]*)?\/?>/g, (tag, name: string) =>
+ strippableHtmlTagNames.has(name.toLowerCase()) ? '' : tag
+ ),
+ true
+ )
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
+
+ return restoreMarkdownCode(stripped, codeSpans, placeholderPrefix)
}
function attrValue(tag: string, name: string): string {
@@ -74,8 +99,77 @@ function normalizeInlineHtml(value: string): string {
})
}
+// Why: Markdown code is literal source, so it must bypass the HTML strip pass.
+const CODE_PLACEHOLDER_PREFIX_BASE = '\uE000ORCA_MD_CODE_'
+const CODE_PLACEHOLDER_SUFFIX = '\uE000'
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+function codePlaceholderPrefix(content: string): string {
+ let prefix = CODE_PLACEHOLDER_PREFIX_BASE
+ while (content.includes(prefix)) {
+ prefix = `${prefix}_`
+ }
+ return prefix
+}
+
+function protectMarkdownCode(content: string): {
+ protectedText: string
+ codeSpans: string[]
+ placeholderPrefix: string
+} {
+ const placeholderPrefix = codePlaceholderPrefix(content)
+ const codeSpans: string[] = []
+ const store = (match: string): string => {
+ const token = `${placeholderPrefix}${codeSpans.length}${CODE_PLACEHOLDER_SUFFIX}`
+ codeSpans.push(match)
+ return token
+ }
+
+ const lines = content.split('\n')
+ const protectedLines: string[] = []
+ let index = 0
+ while (index < lines.length) {
+ const line = lines[index] ?? ''
+ if (/^```[A-Za-z0-9_-]*\s*$/.test(line)) {
+ const start = index
+ index += 1
+ while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) {
+ index += 1
+ }
+ if (index < lines.length) {
+ index += 1
+ }
+ protectedLines.push(store(lines.slice(start, index).join('\n')))
+ continue
+ }
+
+ protectedLines.push(line.replace(/`[^`\n]+`/g, store))
+ index += 1
+ }
+
+ return { protectedText: protectedLines.join('\n'), codeSpans, placeholderPrefix }
+}
+
+function restoreMarkdownCode(
+ value: string,
+ codeSpans: string[],
+ placeholderPrefix: string
+): string {
+ const placeholderPattern = new RegExp(
+ `${escapeRegExp(placeholderPrefix)}(\\d+)${escapeRegExp(CODE_PLACEHOLDER_SUFFIX)}`,
+ 'g'
+ )
+ return value.replace(placeholderPattern, (_token, index) => codeSpans[Number(index)] ?? _token)
+}
+
export function normalizeMobileMarkdownPreviewHtml(content: string): string {
- let next = content.replace(/\r\n?/g, '\n')
+ const { protectedText, codeSpans, placeholderPrefix } = protectMarkdownCode(
+ content.replace(/\r\n?/g, '\n')
+ )
+ let next = protectedText
// Why: repository Markdown often uses small HTML islands for centered README
// headers and badges. Preview mode should read like Markdown, while Source
@@ -94,5 +188,5 @@ export function normalizeMobileMarkdownPreviewHtml(content: string): string {
next = normalizeInlineHtml(next)
next = stripTags(next)
- return restoreEscapedHtmlEntities(next)
+ return restoreMarkdownCode(restoreEscapedHtmlEntities(next), codeSpans, placeholderPrefix)
}
From 0cd46ad21ea50ade73632ecf09a21f9cd2b9c090 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 00:39:44 -0700
Subject: [PATCH 02/54] Dim and transition the Create More label on hover
(#6479)
Change the "Create more" switch label to use muted foreground by default
and transition to foreground on group hover.
---
src/renderer/src/components/NewWorkspaceComposerCard.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
index 6c1f8c1d9bf..6f68d168bb2 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
@@ -1066,7 +1066,7 @@ export default function NewWorkspaceComposerCard({
role="switch"
aria-checked={createMultiple}
onClick={() => onCreateMultipleChange?.(!createMultiple)}
- className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
-
+
{translate('auto.components.NewWorkspaceComposerCard.createMultiple', 'Create more')}
From e32a62b913473495de3b579f4fba1805fb7ea7be Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 02:04:29 -0700
Subject: [PATCH 03/54] Force viewport refresh for Windows CJK input (#6488)
When committing East Asian text (e.g., via Microsoft Pinyin) on Windows,
the local terminal renderer can leave newly printed glyphs blank until
a repaint is triggered.
This detects CJK, Korean, and fullwidth Unicode ranges and forces a
viewport refresh when such output is received immediately following
active terminal input on Windows clients.
---
.../terminal-pane/pty-connection.test.ts | 225 +++++++++++++++++-
.../terminal-pane/pty-connection.ts | 16 ++
.../terminal-complex-script.test.ts | 17 ++
.../pane-manager/terminal-complex-script.ts | 31 +++
4 files changed, 288 insertions(+), 1 deletion(-)
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 a488a9aa224..ac1c52a4174 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -470,6 +470,35 @@ function createDeferred(): { promise: Promise; resolve: (value: T) => void
return { promise, resolve: resolveDeferred }
}
+function temporarilySetNavigatorUserAgent(userAgent: string): () => void {
+ const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
+ const platform = userAgent.includes('Windows')
+ ? 'Win32'
+ : userAgent.includes('Macintosh')
+ ? 'MacIntel'
+ : 'Linux x86_64'
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ value: { platform, userAgent }
+ })
+ return () => {
+ if (originalDescriptor) {
+ Object.defineProperty(globalThis, 'navigator', originalDescriptor)
+ } else {
+ delete (globalThis as { navigator?: Navigator }).navigator
+ }
+ }
+}
+
+function sendTerminalInputThroughPane(pane: ReturnType, data: string): void {
+ const onDataMock = pane.terminal.onData as unknown as {
+ mock: { calls: [[(data: string) => void] | []] }
+ }
+ const terminalInputHandler = onDataMock.mock.calls[0]?.[0]
+ expect(terminalInputHandler).toBeTypeOf('function')
+ terminalInputHandler?.(data)
+}
+
describe('connectPanePty', () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
@@ -6223,7 +6252,9 @@ describe('connectPanePty', () => {
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4)
expect(pane.terminal.write).toHaveBeenCalledWith(
- expect.stringContaining('Orca skipped hidden terminal output because main recovery was unavailable.'),
+ expect.stringContaining(
+ 'Orca skipped hidden terminal output because main recovery was unavailable.'
+ ),
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
@@ -7084,6 +7115,198 @@ describe('connectPanePty', () => {
expect(refresh).not.toHaveBeenCalled()
})
+ it('forces a viewport refresh for native Windows CJK foreground output after terminal input', async () => {
+ const restoreNavigator = temporarilySetNavigatorUserAgent(
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
+ )
+ try {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ 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 refresh = vi.fn()
+ const terminal = pane.terminal as typeof pane.terminal & {
+ _core?: { refresh: typeof refresh }
+ }
+ terminal._core = { refresh }
+ terminal.write = vi.fn((_data: string, callback?: () => void) => {
+ callback?.()
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
+ await flushAsyncTicks(6)
+ sendTerminalInputThroughPane(pane, '已经安装完成,软件已更新后重启。')
+
+ capturedDataCallback.current?.('已经安装完成,软件已更新后重启。')
+
+ expect(refresh).toHaveBeenCalledWith(0, 39, true)
+ } finally {
+ restoreNavigator()
+ }
+ })
+
+ it('does not force the Windows CJK repaint path without recent terminal input', async () => {
+ const restoreNavigator = temporarilySetNavigatorUserAgent(
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
+ )
+ try {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ 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 refresh = vi.fn()
+ const terminal = pane.terminal as typeof pane.terminal & {
+ _core?: { refresh: typeof refresh }
+ }
+ terminal._core = { refresh }
+ terminal.write = vi.fn((_data: string, callback?: () => void) => {
+ callback?.()
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
+ await flushAsyncTicks(6)
+
+ capturedDataCallback.current?.('已经安装完成,软件已更新后重启。')
+
+ expect(refresh).not.toHaveBeenCalled()
+ } finally {
+ restoreNavigator()
+ }
+ })
+
+ it('does not force the Windows CJK repaint path for non-East-Asian Unicode input', async () => {
+ const restoreNavigator = temporarilySetNavigatorUserAgent(
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
+ )
+ try {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ 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 refresh = vi.fn()
+ const terminal = pane.terminal as typeof pane.terminal & {
+ _core?: { refresh: typeof refresh }
+ }
+ terminal._core = { refresh }
+ terminal.write = vi.fn((_data: string, callback?: () => void) => {
+ callback?.()
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
+ await flushAsyncTicks(6)
+ sendTerminalInputThroughPane(pane, 'status 🚀')
+
+ capturedDataCallback.current?.('status 🚀')
+
+ expect(refresh).not.toHaveBeenCalled()
+ } finally {
+ restoreNavigator()
+ }
+ })
+
+ it('applies the Windows CJK repaint path to SSH panes on Windows clients after terminal input', async () => {
+ const restoreNavigator = temporarilySetNavigatorUserAgent(
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
+ )
+ try {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ 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)
+ // Why: the missing-glyph workaround is renderer-scoped, not PTY-scoped.
+ // SSH changes where bytes originate, but Windows still paints them locally.
+ mockStoreState = {
+ ...mockStoreState,
+ repos: [{ id: 'repo1', connectionId: 'conn-1', displayName: 'orca' }]
+ }
+
+ const pane = createPane(1)
+ const refresh = vi.fn()
+ const terminal = pane.terminal as typeof pane.terminal & {
+ _core?: { refresh: typeof refresh }
+ }
+ terminal._core = { refresh }
+ terminal.write = vi.fn((_data: string, callback?: () => void) => {
+ callback?.()
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
+ await flushAsyncTicks(6)
+ sendTerminalInputThroughPane(pane, '已经安装完成,软件已更新后重启。')
+
+ capturedDataCallback.current?.('已经安装完成,软件已更新后重启。')
+
+ expect(refresh).toHaveBeenCalledWith(0, 39, true)
+ } finally {
+ restoreNavigator()
+ }
+ })
+
+ it('does not apply the Windows CJK repaint path on non-Windows clients', async () => {
+ const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
+ try {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ 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 refresh = vi.fn()
+ const terminal = pane.terminal as typeof pane.terminal & {
+ _core?: { refresh: typeof refresh }
+ }
+ terminal._core = { refresh }
+ terminal.write = vi.fn((_data: string, callback?: () => void) => {
+ callback?.()
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
+ await flushAsyncTicks(6)
+
+ capturedDataCallback.current?.('已经安装完成,软件已更新后重启。')
+
+ expect(refresh).not.toHaveBeenCalled()
+ } finally {
+ restoreNavigator()
+ }
+ })
+
it('keeps terminal UI drawing glyphs on the active renderer', 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 bc9b2e5b806..e6930cfbd9d 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -26,6 +26,7 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard'
import {
+ terminalOutputContainsEastAsianRendererRisk,
terminalOutputPrefersRenderRefresh,
terminalRewriteOutputRenderRefreshDecision,
terminalRewriteOutputPrefersRenderRefresh
@@ -1769,6 +1770,7 @@ export function connectPanePty(
executionHostId
})
const shouldApplyNativeWindowsRewriteRefresh = isNativeWindowsConpty
+ const shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32'
const shouldProtectNativeWindowsSynchronizedOutput = isNativeWindowsConpty
const restoredPtyIdForTransport =
@@ -2731,6 +2733,8 @@ export function connectPanePty(
function shouldForceForegroundRenderRefresh(data: string): boolean {
const rewriteOutputPrefersRenderRefresh = foregroundRewriteOutputPrefersRenderRefresh(data)
+ const recentInput =
+ performance.now() - lastTerminalInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS
if (foregroundAnsiOutputPrefersRenderRefresh(data)) {
// Why: Codex-style background SGR panels can paint cell fills while
// glyphs lag behind; refresh only renderer-risk ANSI chunks, not all output.
@@ -2741,6 +2745,18 @@ export function connectPanePty(
// in-place redraw cells can remain stale in the renderer until repaint.
return true
}
+ if (
+ shouldApplyWindowsRendererUnicodeRefresh &&
+ recentInput &&
+ data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS &&
+ terminalOutputContainsEastAsianRendererRisk(data)
+ ) {
+ // Why: Microsoft Pinyin commits can surface as plain CJK foreground
+ // bytes; the prompt model is correct, but the local Windows renderer
+ // can leave individual glyph cells blank until repaint. Keep this
+ // scoped to recent East Asian text input, not all Unicode output.
+ return true
+ }
return (
shouldApplyNativeWindowsRewriteRefresh &&
containsNonAsciiOutput(data) &&
diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts
index 4480fee3a15..c5da3cf4289 100644
--- a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts
+++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
+ terminalOutputContainsEastAsianRendererRisk,
terminalOutputPrefersRenderRefresh,
terminalRewriteOutputRenderRefreshDecision,
terminalRewriteOutputPrefersRenderRefresh
@@ -74,6 +75,22 @@ describe('terminalOutputPrefersRenderRefresh', () => {
})
})
+describe('terminalOutputContainsEastAsianRendererRisk', () => {
+ it('detects CJK, fullwidth, and Korean output', () => {
+ expect(terminalOutputContainsEastAsianRendererRisk('已经安装完成,软件已更新后重启。')).toBe(
+ true
+ )
+ expect(terminalOutputContainsEastAsianRendererRisk('Fullwidth: ABC123')).toBe(true)
+ expect(terminalOutputContainsEastAsianRendererRisk('Korean: 터미널')).toBe(true)
+ })
+
+ it('does not match non-East-Asian renderer-risk Unicode', () => {
+ expect(terminalOutputContainsEastAsianRendererRisk('Arabic: السلام عليكم')).toBe(false)
+ expect(terminalOutputContainsEastAsianRendererRisk('status 🚀')).toBe(false)
+ expect(terminalOutputContainsEastAsianRendererRisk('developer 👩💻')).toBe(false)
+ })
+})
+
describe('terminalRewriteOutputPrefersRenderRefresh', () => {
it('detects in-place carriage-return redraws', () => {
expect(terminalRewriteOutputPrefersRenderRefresh('\r• Working')).toBe(true)
diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts
index d7b0617a099..e5044f5ea3c 100644
--- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts
+++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts
@@ -51,6 +51,21 @@ function isRendererRiskCodePoint(value: number): boolean {
)
}
+function isEastAsianRendererRiskCodePoint(value: number): boolean {
+ return (
+ isInRange(value, 0x1100, 0x11ff) ||
+ isInRange(value, 0x2e80, 0x9fff) ||
+ isInRange(value, 0xa960, 0xa97f) ||
+ isInRange(value, 0xac00, 0xd7ff) ||
+ isInRange(value, 0xf900, 0xfaff) ||
+ isInRange(value, 0xfe10, 0xfe1f) ||
+ isInRange(value, 0xfe30, 0xfe4f) ||
+ isInRange(value, 0xff00, 0xffef) ||
+ isInRange(value, 0x20000, 0x2fa1f) ||
+ isInRange(value, 0x30000, 0x3134f)
+ )
+}
+
function sgrParamCode(param: string | undefined): number | null {
if (!param) {
return null
@@ -229,3 +244,19 @@ export function terminalOutputPrefersRenderRefresh(data: string): boolean {
}
return false
}
+
+export function terminalOutputContainsEastAsianRendererRisk(data: string): boolean {
+ for (let i = 0; i < data.length; i += 1) {
+ const codePoint = data.codePointAt(i)
+ if (codePoint === undefined) {
+ continue
+ }
+ if (isEastAsianRendererRiskCodePoint(codePoint)) {
+ return true
+ }
+ if (codePoint > 0xffff) {
+ i += 1
+ }
+ }
+ return false
+}
From 6729f683f6be3a0d488005727e22ae3b69d69237 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 02:05:23 -0700
Subject: [PATCH 04/54] Fix Orca CLI path resolution and improve registration
UI (#6484)
* fix Orca CLI path resolution and improve registration UI
- Hydrate the shell PATH before running installer actions on macOS to
match the user's terminal instead of launchd.
- Resolve the active macOS command path dynamically against shell PATH
candidates, ignoring broken symlinks and non-executable files.
- Detect and report conflicts with unmanaged executables shadowing the
configured command path.
- Add "Preparing..." and "Registering..." loading states to the settings
and setup panels during preflight checks.
* Fix Orca CLI detection and installation path conflict checks
- Allow detecting a managed Orca command located later in PATH when the
default path entry is missing or non-executable.
- Avoid raising installation conflicts for unmanaged commands located
after the default install path, as they will be safely shadowed.
- Translate terminal setup and Orca CLI registration strings for
Spanish, Japanese, Korean, and Chinese locales.
* Only open terminal when pre-install checks succeed
Prevent the terminal from opening and setting the terminal command if
onBeforeOpenTerminal or refreshPreInstallNotice throws an error.
---
src/main/cli/cli-installer.test.ts | 366 ++++++++++++++++++
src/main/cli/cli-installer.ts | 112 +++++-
src/main/ipc/cli.ts | 16 +
.../MobileEmulatorAgentSetupGuideSteps.tsx | 25 +-
.../settings/AgentSkillSetupPanel.test.tsx | 26 ++
.../settings/AgentSkillSetupPanel.tsx | 43 +-
.../MobileEmulatorAgentControlRow.tsx | 25 +-
src/renderer/src/i18n/locales/en.json | 14 +-
src/renderer/src/i18n/locales/es.json | 14 +-
src/renderer/src/i18n/locales/ja.json | 14 +-
src/renderer/src/i18n/locales/ko.json | 14 +-
src/renderer/src/i18n/locales/zh.json | 14 +-
12 files changed, 653 insertions(+), 30 deletions(-)
diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts
index f3c0cb8a8ed..4ce919cef6b 100644
--- a/src/main/cli/cli-installer.test.ts
+++ b/src/main/cli/cli-installer.test.ts
@@ -624,6 +624,372 @@ describe('CliInstaller', () => {
}
)
+ // Why: users can have a managed Orca command in ~/.local/bin even when
+ // /usr/local/bin exists; Settings must follow the shell-visible command.
+ it.skipIf(process.platform === 'win32')(
+ 'uses an existing managed macOS orca command from the shell PATH before /usr/local/bin',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await symlink(launcherPath, userInstallPath)
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${userLocalBin}:${usrLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status.state).toBe('installed')
+ expect(status.commandPath).toBe(userInstallPath)
+ expect(status.pathConfigured).toBe(true)
+
+ const installed = await installer.install()
+ expect(installed.commandPath).toBe(userInstallPath)
+ await expect(readlink(userInstallPath)).resolves.toBe(launcherPath)
+ await expect(lstat(defaultInstallPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ }
+ )
+
+ // Why: POSIX command lookup skips broken symlinks and keeps searching PATH,
+ // so a stale earlier artifact must not steal status from the install path.
+ it.skipIf(process.platform === 'win32')(
+ 'skips a broken managed macOS orca symlink before /usr/local/bin',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ const oldLauncherPath = join(fixture.root, 'Old.app', 'Contents', 'Resources', 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await symlink(oldLauncherPath, userInstallPath)
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${userLocalBin}:${usrLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status).toMatchObject({
+ commandPath: defaultInstallPath,
+ state: 'not_installed',
+ currentTarget: null
+ })
+
+ const installed = await installer.install()
+ expect(installed.commandPath).toBe(defaultInstallPath)
+ expect(installed.state).toBe('installed')
+ await expect(readlink(defaultInstallPath)).resolves.toBe(launcherPath)
+ await expect(readlink(userInstallPath)).resolves.toBe(oldLauncherPath)
+ }
+ )
+
+ // Why: PATH lookup stops at the first existing command; a later managed
+ // ~/.local/bin/orca must not steal status from /usr/local/bin/orca.
+ it.skipIf(process.platform === 'win32')(
+ 'keeps the default macOS command when a managed orca appears later on PATH',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await symlink(launcherPath, defaultInstallPath)
+ await symlink(launcherPath, userInstallPath)
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${usrLocalBin}:${userLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status.commandPath).toBe(defaultInstallPath)
+ expect(status.state).toBe('installed')
+ }
+ )
+
+ // Why: shells skip missing PATH entries, so a managed command later in PATH
+ // is still the shell-visible Orca command until the default path is installed.
+ it.skipIf(process.platform === 'win32')(
+ 'uses a later managed macOS orca command when the default command is missing',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await symlink(launcherPath, userInstallPath)
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${usrLocalBin}:${userLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status.commandPath).toBe(userInstallPath)
+ expect(status.state).toBe('installed')
+
+ const installed = await installer.install()
+ expect(installed.commandPath).toBe(userInstallPath)
+ await expect(lstat(defaultInstallPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ }
+ )
+
+ // Why: bash/zsh skip non-executable PATH entries even at Orca's configured
+ // install slot, then keep looking for a runnable command later in PATH.
+ it.skipIf(process.platform === 'win32')(
+ 'uses a later managed macOS orca command when the default command is not executable',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await writeFile(defaultInstallPath, '#!/usr/bin/env bash\necho other-orca\n', 'utf8')
+ await symlink(launcherPath, userInstallPath)
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${usrLocalBin}:${userLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status.commandPath).toBe(userInstallPath)
+ expect(status.state).toBe('installed')
+
+ const installed = await installer.install()
+ expect(installed.commandPath).toBe(userInstallPath)
+ await expect(readFile(defaultInstallPath, 'utf8')).resolves.toContain('other-orca')
+ }
+ )
+
+ // Why: a non-Orca command after an empty default install slot can be shadowed
+ // by installing the default path without replacing the user's command.
+ it.skipIf(process.platform === 'win32')(
+ 'installs the default macOS command instead of replacing an unmanaged later command',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await writeFile(userInstallPath, '#!/usr/bin/env bash\necho other-orca\n', {
+ encoding: 'utf8',
+ mode: 0o755
+ })
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${usrLocalBin}:${userLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status.commandPath).toBe(defaultInstallPath)
+ expect(status.state).toBe('not_installed')
+
+ const installed = await installer.install()
+ expect(installed.commandPath).toBe(defaultInstallPath)
+ expect(installed.state).toBe('installed')
+ await expect(readlink(defaultInstallPath)).resolves.toBe(launcherPath)
+ await expect(readFile(userInstallPath, 'utf8')).resolves.toContain('other-orca')
+ }
+ )
+
+ // Why: an off-PATH ~/.local/bin/orca must not hijack CLI registration and
+ // leave the shell-visible /usr/local/bin command missing.
+ it.skipIf(process.platform === 'win32')(
+ 'ignores managed macOS orca commands that are not visible on the shell PATH',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await symlink(launcherPath, userInstallPath)
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: usrLocalBin
+ })
+
+ const status = await installer.getStatus()
+ expect(status.commandPath).toBe(defaultInstallPath)
+ expect(status.pathConfigured).toBe(true)
+ expect(status.state).toBe('not_installed')
+
+ const installed = await installer.install()
+ expect(installed.commandPath).toBe(defaultInstallPath)
+ expect(installed.state).toBe('installed')
+ await expect(readlink(defaultInstallPath)).resolves.toBe(launcherPath)
+ await expect(readlink(userInstallPath)).resolves.toBe(launcherPath)
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'reports a conflict for an unmanaged macOS orca that shadows the install path',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await writeFile(userInstallPath, '#!/usr/bin/env bash\necho other-orca\n', {
+ encoding: 'utf8',
+ mode: 0o755
+ })
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${userLocalBin}:${usrLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status.commandPath).toBe(userInstallPath)
+ expect(status.state).toBe('conflict')
+ await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command')
+ await expect(lstat(defaultInstallPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ await expect(readFile(userInstallPath, 'utf8')).resolves.toContain('other-orca')
+ }
+ )
+
+ // Why: bash/zsh skip non-executable PATH entries, so reporting them as a
+ // conflict would block a valid later install path the shell would use.
+ it.skipIf(process.platform === 'win32')(
+ 'skips a non-executable unmanaged macOS orca before the install path',
+ async () => {
+ const fixture = await makeFixture()
+ const homePath = join(fixture.root, 'home')
+ const resourcesPath = await createPackagedMacLauncher(fixture.root)
+ const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin')
+ const userLocalBin = join(homePath, '.local', 'bin')
+ const defaultInstallPath = join(usrLocalBin, 'orca')
+ const userInstallPath = join(userLocalBin, 'orca')
+ const launcherPath = join(resourcesPath, 'bin', 'orca')
+ await mkdir(usrLocalBin, { recursive: true })
+ await mkdir(userLocalBin, { recursive: true })
+ await writeFile(userInstallPath, '#!/usr/bin/env bash\necho other-orca\n', 'utf8')
+
+ const installer = new CliInstaller({
+ platform: 'darwin',
+ isPackaged: true,
+ resourcesPath,
+ userDataPath: fixture.userDataPath,
+ execPath: '/Applications/Orca.app/Contents/MacOS/Orca',
+ appPath: fixture.appPath,
+ homePath,
+ defaultMacCommandPath: defaultInstallPath,
+ processPathEnv: `${userLocalBin}:${usrLocalBin}`
+ })
+
+ const status = await installer.getStatus()
+ expect(status.commandPath).toBe(defaultInstallPath)
+ expect(status.state).toBe('not_installed')
+
+ const installed = await installer.install()
+ expect(installed.commandPath).toBe(defaultInstallPath)
+ expect(installed.state).toBe('installed')
+ await expect(readlink(defaultInstallPath)).resolves.toBe(launcherPath)
+ await expect(readFile(userInstallPath, 'utf8')).resolves.toContain('other-orca')
+ }
+ )
+
// Why: when macCommandPath falls back to ~/.local/bin/orca on arm64, commandName
// must still be 'orca' (not 'orca-ide' which is Linux-only).
it.skipIf(process.platform === 'win32')(
diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts
index 7155f6e61a4..6765d8249b1 100644
--- a/src/main/cli/cli-installer.ts
+++ b/src/main/cli/cli-installer.ts
@@ -1,8 +1,18 @@
/* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */
import { app } from 'electron'
import { execFile } from 'node:child_process'
-import { existsSync } from 'node:fs'
-import { lstat, mkdir, readFile, readlink, symlink, unlink, writeFile } from 'node:fs/promises'
+import { constants, existsSync } from 'node:fs'
+import {
+ access,
+ lstat,
+ mkdir,
+ readFile,
+ readlink,
+ stat,
+ symlink,
+ unlink,
+ writeFile
+} from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { promisify } from 'node:util'
@@ -104,8 +114,8 @@ export class CliInstaller {
}
async getStatus(): Promise {
- const spec = this.resolveInstallSpec()
- if (!spec) {
+ const defaultSpec = this.resolveInstallSpec()
+ if (!defaultSpec) {
return {
platform: this.platform,
commandName: this.commandName,
@@ -133,11 +143,11 @@ export class CliInstaller {
return {
platform: this.platform,
commandName: this.commandName,
- commandPath: spec.commandPath,
- pathDirectory: dirname(spec.commandPath),
+ commandPath: defaultSpec.commandPath,
+ pathDirectory: dirname(defaultSpec.commandPath),
pathConfigured: false,
launcherPath: null,
- installMethod: spec.installMethod,
+ installMethod: defaultSpec.installMethod,
supported: false,
state: 'unsupported',
currentTarget: null,
@@ -146,6 +156,7 @@ export class CliInstaller {
}
}
+ const spec = await this.resolveActiveInstallSpec(defaultSpec, launcherPath)
const baseStatus =
spec.installMethod === 'symlink'
? await this.inspectSymlink(spec.commandPath, launcherPath)
@@ -251,6 +262,66 @@ export class CliInstaller {
return null
}
+ private async resolveActiveInstallSpec(
+ defaultSpec: InstallSpec,
+ launcherPath: string
+ ): Promise {
+ if (
+ this.commandPathOverride ||
+ this.platform !== 'darwin' ||
+ defaultSpec.installMethod !== 'symlink'
+ ) {
+ return defaultSpec
+ }
+
+ const activeCommandPath = await this.findActivePathCommand(
+ launcherPath,
+ defaultSpec.commandPath
+ )
+ return activeCommandPath
+ ? {
+ commandPath: activeCommandPath,
+ installMethod: defaultSpec.installMethod
+ }
+ : defaultSpec
+ }
+
+ private async findActivePathCommand(
+ launcherPath: string,
+ defaultCommandPath: string
+ ): Promise {
+ let reachedDefaultCommandPath = false
+ for (const commandPath of this.getPathCommandCandidates(defaultCommandPath)) {
+ const isDefaultCommandPath = samePathEntry(this.platform, commandPath, defaultCommandPath)
+ reachedDefaultCommandPath ||= isDefaultCommandPath
+
+ if (!(await isExecutableFile(commandPath))) {
+ continue
+ }
+
+ const status = await this.inspectSymlink(commandPath, launcherPath)
+ if (status.state !== 'not_installed') {
+ if (reachedDefaultCommandPath && !isDefaultCommandPath && status.state === 'conflict') {
+ // Why: a non-Orca command after an empty/default install slot can be
+ // shadowed safely by installing there; no user file needs replacing.
+ continue
+ }
+ // Why: PATH lookup is first-match-wins; use the executable command the
+ // shell will actually run, while preserving conflicts that shadow Orca.
+ return commandPath
+ }
+ }
+ return null
+ }
+
+ private getPathCommandCandidates(defaultCommandPath: string): string[] {
+ const commandName = basename(defaultCommandPath)
+ const pathCandidates = splitPathEntries(this.platform, this.processPathEnv ?? '').map((entry) =>
+ join(entry, commandName)
+ )
+ return uniquePathEntries(this.platform, pathCandidates)
+ }
+
private resolveCommandPath(): string | null {
if (this.commandPathOverride) {
return this.commandPathOverride
@@ -906,6 +977,20 @@ function splitPathEntries(platform: NodeJS.Platform, value: string | null): stri
.filter(Boolean)
}
+function uniquePathEntries(platform: NodeJS.Platform, entries: string[]): string[] {
+ const seen = new Set()
+ const result: string[] = []
+ for (const entry of entries) {
+ const key = platform === 'win32' ? normalizeWindowsPath(entry) : entry
+ if (seen.has(key)) {
+ continue
+ }
+ seen.add(key)
+ result.push(entry)
+ }
+ return result
+}
+
function samePathEntry(platform: NodeJS.Platform, left: string, right: string): boolean {
return platform === 'win32'
? normalizeWindowsPath(left) === normalizeWindowsPath(right)
@@ -917,6 +1002,19 @@ function isPathInsideOrEqual(parentPath: string, childPath: string): boolean {
return childRelative === '' || (!childRelative.startsWith('..') && !isAbsolute(childRelative))
}
+async function isExecutableFile(commandPath: string): Promise {
+ try {
+ const stats = await stat(commandPath)
+ if (!stats.isFile()) {
+ return false
+ }
+ await access(commandPath, constants.X_OK)
+ return true
+ } catch {
+ return false
+ }
+}
+
function normalizeWindowsPath(value: string): string {
return value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase()
}
diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts
index 7b33db5fec3..b0b4f4c6311 100644
--- a/src/main/ipc/cli.ts
+++ b/src/main/ipc/cli.ts
@@ -2,21 +2,37 @@ import { ipcMain } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { CliInstaller } from '../cli/cli-installer'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
+import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined {
return args?.distro?.trim() || undefined
}
+async function hydrateLocalShellPathForCli(force = false): Promise {
+ if (process.platform === 'win32') {
+ return
+ }
+ // Why: CLI registration must match `which orca` in the user's terminal, not
+ // the sparse PATH a GUI-launched Electron process inherited from launchd.
+ const hydration = await hydrateShellPath(force ? { force: true } : undefined)
+ if (hydration.ok) {
+ mergePathSegments(hydration.segments)
+ }
+}
+
export function registerCliHandlers(): void {
ipcMain.handle('cli:getInstallStatus', async (): Promise => {
+ await hydrateLocalShellPathForCli()
return new CliInstaller().getStatus()
})
ipcMain.handle('cli:install', async (): Promise => {
+ await hydrateLocalShellPathForCli(true)
return new CliInstaller().install()
})
ipcMain.handle('cli:remove', async (): Promise => {
+ await hydrateLocalShellPathForCli()
return new CliInstaller().remove()
})
diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx
index c3771692f87..f169591d5e1 100644
--- a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx
+++ b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx
@@ -73,6 +73,23 @@ export function MobileEmulatorAgentSetupGuideSteps({
{setup.cliInstallStatus.detail}
) : null}
+ {setup.cliBusy ? (
+
+ {translate(
+ 'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.3d34423e88',
+ 'Registering the Orca CLI'
+ )}{' '}
+ {setup.cliInstallStatus?.commandPath ? (
+
+ {setup.cliInstallStatus.commandPath}
+
+ ) : null}{' '}
+ {translate(
+ 'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.3be27641c9',
+ 'so emulator commands can run from agent shells.'
+ )}
+
+ ) : null}
{!setup.cliEnabled && !setup.cliPathNeedsAttention && setup.cliInstallStatus?.detail ? (
{setup.cliInstallStatus.detail}
) : null}
@@ -93,7 +110,9 @@ export function MobileEmulatorAgentSetupGuideSteps({
void setup.handleEnableCli()
}}
>
- {setup.cliLoading ? : null}
+ {setup.cliLoading || setup.cliBusy ? (
+
+ ) : null}
{setup.cliActionLabel}
@@ -149,6 +168,10 @@ export function MobileEmulatorAgentSetupGuideSteps({
preInstallNotice={
showSkillPreInstallNotice ? AGENT_SKILL_CLI_PREREQUISITE_NOTICE : undefined
}
+ openingHint={translate(
+ 'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.3941719a56',
+ 'Checking Orca CLI before opening skill setup.'
+ )}
onBeforeOpenTerminal={async () => {
recordFeatureInteraction('mobile-emulator-agent-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal()
diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx
index 2bb4bac748f..69d2360b7e7 100644
--- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx
+++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx
@@ -238,6 +238,32 @@ describe('AgentSkillSetupPanel', () => {
expect(mocks.toastSuccess).toHaveBeenCalledWith('Copied command.')
})
+ it('shows a visible pending state while CLI setup preflight is running', async () => {
+ let resolvePreflight: (() => void) | null = null
+ const preflight = new Promise((resolve) => {
+ resolvePreflight = resolve
+ })
+
+ await renderInteractivePanel({
+ onBeforeOpenTerminal: () => preflight
+ })
+
+ await clickButton('Install')
+
+ expect(findButton('Preparing...').disabled).toBe(true)
+ expect(container?.textContent).toContain('Preparing setup terminal.')
+ expect(container?.textContent).not.toContain(INSTALL_COMMAND)
+
+ await act(async () => {
+ resolvePreflight?.()
+ await preflight
+ })
+ await act(async () => {})
+
+ expect(container?.textContent).toContain(INSTALL_COMMAND)
+ expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND })
+ })
+
it('opens installed setup with the installed command for preview, copy, and terminal', async () => {
await renderInteractivePanel({ installed: true, installedCommand: UPDATE_COMMAND })
diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx
index 1453eaf02e4..d7f4da5b619 100644
--- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx
+++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
-import { Copy, RefreshCw, Terminal } from 'lucide-react'
+import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react'
import { toast } from 'sonner'
import { IntegrationStatusPill } from '../integration-status-pill'
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
@@ -44,6 +44,7 @@ type AgentSkillSetupPanelProps = {
installLabel?: string
installedInstallLabel?: string
actionHint?: ReactNode
+ openingHint?: ReactNode
footer?: ReactNode
onRecheck: () => void | Promise
}
@@ -76,11 +77,13 @@ export function AgentSkillSetupPanel({
installLabel = 'Install',
installedInstallLabel = 'Update',
actionHint,
+ openingHint,
footer,
onRecheck
}: AgentSkillSetupPanelProps): React.JSX.Element {
const [terminalOpen, setTerminalOpen] = useState(false)
const [terminalCommand, setTerminalCommand] = useState(null)
+ const [terminalOpening, setTerminalOpening] = useState(false)
const [preInstallNoticeVisible, setPreInstallNoticeVisible] = useState(
Boolean(preInstallNotice && !installed)
)
@@ -164,23 +167,42 @@ export function AgentSkillSetupPanel({
variant="outline"
size="sm"
onClick={() => {
+ if (terminalOpening) {
+ return
+ }
const nextCommand = activeCommand
+ setTerminalOpening(true)
void (async () => {
+ let shouldOpenTerminal = false
try {
await onBeforeOpenTerminal?.()
await refreshPreInstallNotice()
+ shouldOpenTerminal = true
+ } catch {
+ shouldOpenTerminal = false
} finally {
if (mountedRef.current) {
- setTerminalCommand(nextCommand)
- setTerminalOpen(true)
+ setTerminalOpening(false)
+ if (shouldOpenTerminal) {
+ setTerminalCommand(nextCommand)
+ setTerminalOpen(true)
+ }
}
}
})()
}}
- disabled={terminalOpen || installDisabled}
+ disabled={terminalOpen || installDisabled || terminalOpening}
>
-
- {installed ? installedInstallLabel : installLabel}
+ {terminalOpening ? (
+
+ ) : (
+
+ )}
+ {terminalOpening
+ ? translate('auto.components.settings.AgentSkillSetupPanel.5f818f12ab', 'Preparing...')
+ : installed
+ ? installedInstallLabel
+ : installLabel}
) : null}
{!installed || showRecheckWhenInstalled ? (
@@ -196,6 +218,15 @@ export function AgentSkillSetupPanel({
{translate('auto.components.settings.AgentSkillSetupPanel.c689392435', 'Re-check')}
) : null}
+ {terminalOpening ? (
+
+ {openingHint ??
+ translate(
+ 'auto.components.settings.AgentSkillSetupPanel.4c05b9d7cb',
+ 'Preparing setup terminal.'
+ )}
+
+ ) : null}
)
diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx
index e6c87095210..17946134bc1 100644
--- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx
+++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx
@@ -94,6 +94,23 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
{!setup.cliEnabled && setup.cliInstallStatus?.detail ? (
{setup.cliInstallStatus.detail}
) : null}
+ {setup.cliBusy ? (
+
+ {translate(
+ 'auto.components.settings.MobileEmulatorAgentControlRow.3d34423e88',
+ 'Registering the Orca CLI'
+ )}{' '}
+ {setup.cliInstallStatus?.commandPath ? (
+
+ {setup.cliInstallStatus.commandPath}
+
+ ) : null}{' '}
+ {translate(
+ 'auto.components.settings.MobileEmulatorAgentControlRow.3be27641c9',
+ 'so emulator commands can run from agent shells.'
+ )}
+
+ ) : null}
@@ -108,7 +125,9 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
}
onClick={() => void handleEnableCli()}
>
- {setup.cliLoading ? : null}
+ {setup.cliLoading || setup.cliBusy ? (
+
+ ) : null}
{setup.cliActionLabel}
@@ -144,6 +163,10 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
installDisabled={setup.step2Blocked}
leading={ }
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
+ openingHint={translate(
+ 'auto.components.settings.MobileEmulatorAgentControlRow.3941719a56',
+ 'Checking Orca CLI before opening skill setup.'
+ )}
onBeforeOpenTerminal={async () => {
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c3e90f43827..86fadeaa74a 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -4495,7 +4495,9 @@
"copiedCommand": "Copied command.",
"failedToCopyCommand": "Failed to copy command.",
"copyCommandAria": "Copy command",
- "runCommandDescription": "Press Enter to run the command."
+ "runCommandDescription": "Press Enter to run the command.",
+ "5f818f12ab": "Preparing...",
+ "4c05b9d7cb": "Preparing setup terminal."
},
"AgentsPane": {
"d83834f5e6": "Detecting installed agents…",
@@ -5315,7 +5317,10 @@
"4f2205f3b6": "Enable Orca CLI",
"ff4b7e65d6": "Let coding agents control the active mobile emulator with Orca CLI commands.",
"2a674aa810": "Agent Mobile Emulator Control",
- "cdeaed9e37": "Registered the Orca CLI in PATH."
+ "cdeaed9e37": "Registered the Orca CLI in PATH.",
+ "3d34423e88": "Registering the Orca CLI",
+ "3be27641c9": "so emulator commands can run from agent shells.",
+ "3941719a56": "Checking Orca CLI before opening skill setup."
},
"MobileEmulatorExamples": {
"edf13dd03b": "Copy",
@@ -10459,7 +10464,10 @@
"21f5687c07": "Orca CLI skill",
"64fb057667": "Teaches agents the orca emulator commands for this worktree.",
"5c59ea96ca": "Mobile emulator Orca CLI skill setup",
- "bff5341ac3": "Mobile emulator Orca CLI skill install terminal"
+ "bff5341ac3": "Mobile emulator Orca CLI skill install terminal",
+ "3d34423e88": "Registering the Orca CLI",
+ "3be27641c9": "so emulator commands can run from agent shells.",
+ "3941719a56": "Checking Orca CLI before opening skill setup."
},
"MobileEmulatorTabIntroCallout": {
"1924982130": "Dismiss",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 614cd053091..b3e36b78356 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -4495,7 +4495,9 @@
"copiedCommand": "Comando copiado.",
"failedToCopyCommand": "No se pudo copiar el comando.",
"copyCommandAria": "Copiar comando",
- "runCommandDescription": "Presiona Enter para ejecutar el comando."
+ "runCommandDescription": "Presiona Enter para ejecutar el comando.",
+ "5f818f12ab": "Preparando...",
+ "4c05b9d7cb": "Preparando el terminal de configuración."
},
"AgentsPane": {
"d83834f5e6": "Detectando agents instalados…",
@@ -5278,7 +5280,10 @@
"4f2205f3b6": "Habilitar CLI de Orca",
"ff4b7e65d6": "Deje que los agents de codificación controlen el emulador móvil activo con los comandos CLI de Orca.",
"2a674aa810": "Control del emulador móvil del Agent",
- "cdeaed9e37": "Registré Orca CLI en PATH."
+ "cdeaed9e37": "Registré Orca CLI en PATH.",
+ "3d34423e88": "Registrando la CLI de Orca",
+ "3be27641c9": "para que los comandos del emulador se puedan ejecutar desde shells de agents.",
+ "3941719a56": "Comprobando la CLI de Orca antes de abrir la configuración de la skill."
},
"MobileEmulatorExamples": {
"edf13dd03b": "Copiar",
@@ -10459,7 +10464,10 @@
"21f5687c07": "Skill del CLI de Orca",
"64fb057667": "Enseña a los agents los comandos del emulador orca para este worktree.",
"5c59ea96ca": "Mobile emulator Orca CLI skill setup",
- "bff5341ac3": "Mobile emulator Orca CLI skill install terminal"
+ "bff5341ac3": "Mobile emulator Orca CLI skill install terminal",
+ "3d34423e88": "Registrando la CLI de Orca",
+ "3be27641c9": "para que los comandos del emulador se puedan ejecutar desde shells de agents.",
+ "3941719a56": "Comprobando la CLI de Orca antes de abrir la configuración de la skill."
},
"MobileEmulatorTabIntroCallout": {
"1924982130": "Descartar",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 8cce7c8bb3a..90ae3692240 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -4480,7 +4480,9 @@
"copiedCommand": "コマンドをコピーしました。",
"failedToCopyCommand": "コマンドのコピーに失敗しました。",
"copyCommandAria": "コマンドをコピー",
- "runCommandDescription": "Enter を押してコマンドを実行します。"
+ "runCommandDescription": "Enter を押してコマンドを実行します。",
+ "5f818f12ab": "準備中...",
+ "4c05b9d7cb": "セットアップ用ターミナルを準備しています。"
},
"AgentsPane": {
"d83834f5e6": "インストールされている agents を検出しています…",
@@ -5300,7 +5302,10 @@
"4f2205f3b6": "Orca CLI を有効にする",
"ff4b7e65d6": "コーディング agents が Orca CLI コマンドを使用してアクティブなモバイル エミュレータを制御できるようにします。",
"2a674aa810": "Agent モバイル エミュレータ制御",
- "cdeaed9e37": "Orca CLIをPATHに登録しました。"
+ "cdeaed9e37": "Orca CLIをPATHに登録しました。",
+ "3d34423e88": "Orca CLI を登録しています",
+ "3be27641c9": "agent のシェルからエミュレーターコマンドを実行できるようにします。",
+ "3941719a56": "スキル設定を開く前に Orca CLI を確認しています。"
},
"MobileEmulatorExamples": {
"edf13dd03b": "コピー",
@@ -10459,7 +10464,10 @@
"21f5687c07": "Orca CLI スキル",
"64fb057667": "このワークツリーの orca エミュレーターコマンドを agents に教えます。",
"5c59ea96ca": "Mobile emulator Orca CLI skill setup",
- "bff5341ac3": "Mobile emulator Orca CLI skill install terminal"
+ "bff5341ac3": "Mobile emulator Orca CLI skill install terminal",
+ "3d34423e88": "Orca CLI を登録しています",
+ "3be27641c9": "agent のシェルからエミュレーターコマンドを実行できるようにします。",
+ "3941719a56": "スキル設定を開く前に Orca CLI を確認しています。"
},
"MobileEmulatorTabIntroCallout": {
"1924982130": "閉じる",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 2bb1deaed57..e4b35727536 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -4480,7 +4480,9 @@
"copiedCommand": "명령 복사됨.",
"failedToCopyCommand": "명령 복사 실패.",
"copyCommandAria": "명령 복사",
- "runCommandDescription": "Enter를 눌러 명령을 실행하세요."
+ "runCommandDescription": "Enter를 눌러 명령을 실행하세요.",
+ "5f818f12ab": "준비 중...",
+ "4c05b9d7cb": "설정 터미널을 준비하고 있습니다."
},
"AgentsPane": {
"d83834f5e6": "설치된 agents 감지 중…",
@@ -5263,7 +5265,10 @@
"4f2205f3b6": "Orca CLI 활성화",
"ff4b7e65d6": "코딩 agents가 Orca CLI 명령을 사용하여 활성 모바일 에뮬레이터를 제어하도록 합니다.",
"2a674aa810": "Agent 모바일 에뮬레이터 제어",
- "cdeaed9e37": "PATH에 Orca CLI를 등록했습니다."
+ "cdeaed9e37": "PATH에 Orca CLI를 등록했습니다.",
+ "3d34423e88": "Orca CLI를 등록하는 중",
+ "3be27641c9": "agent 셸에서 에뮬레이터 명령을 실행할 수 있도록 합니다.",
+ "3941719a56": "스킬 설정을 열기 전에 Orca CLI를 확인하고 있습니다."
},
"MobileEmulatorExamples": {
"edf13dd03b": "복사",
@@ -10459,7 +10464,10 @@
"21f5687c07": "Orca CLI 스킬",
"64fb057667": "이 워크트리의 orca 에뮬레이터 명령을 agents에게 알려줍니다.",
"5c59ea96ca": "모바일 에뮬레이터 Orca CLI 스킬 설정",
- "bff5341ac3": "모바일 에뮬레이터 Orca CLI 스킬 설치 terminal"
+ "bff5341ac3": "모바일 에뮬레이터 Orca CLI 스킬 설치 terminal",
+ "3d34423e88": "Orca CLI를 등록하는 중",
+ "3be27641c9": "agent 셸에서 에뮬레이터 명령을 실행할 수 있도록 합니다.",
+ "3941719a56": "스킬 설정을 열기 전에 Orca CLI를 확인하고 있습니다."
},
"MobileEmulatorTabIntroCallout": {
"1924982130": "닫기",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index e162fc05023..0c7560e9790 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -4480,7 +4480,9 @@
"copiedCommand": "已复制命令。",
"failedToCopyCommand": "复制命令失败。",
"copyCommandAria": "复制命令",
- "runCommandDescription": "按 Enter 运行命令。"
+ "runCommandDescription": "按 Enter 运行命令。",
+ "5f818f12ab": "正在准备...",
+ "4c05b9d7cb": "正在准备设置终端。"
},
"AgentsPane": {
"d83834f5e6": "检测已安装的智能体...",
@@ -5263,7 +5265,10 @@
"4f2205f3b6": "启用 Orca CLI",
"ff4b7e65d6": "让编码智能体使用 Orca CLI 命令控制活动手机模拟器。",
"2a674aa810": "智能体手机模拟器控制",
- "cdeaed9e37": "在 PATH 中注册 Orca CLI。"
+ "cdeaed9e37": "在 PATH 中注册 Orca CLI。",
+ "3d34423e88": "正在注册 Orca CLI",
+ "3be27641c9": "以便智能体 shell 可以运行模拟器命令。",
+ "3941719a56": "打开技能设置前正在检查 Orca CLI。"
},
"MobileEmulatorExamples": {
"edf13dd03b": "复制",
@@ -10459,7 +10464,10 @@
"21f5687c07": "Orca CLI 技能",
"64fb057667": "教授智能体此工作区的 orca 模拟器命令。",
"5c59ea96ca": "移动端模拟器 Orca CLI 技能设置",
- "bff5341ac3": "移动端模拟器 Orca CLI 技能安装终端"
+ "bff5341ac3": "移动端模拟器 Orca CLI 技能安装终端",
+ "3d34423e88": "正在注册 Orca CLI",
+ "3be27641c9": "以便智能体 shell 可以运行模拟器命令。",
+ "3941719a56": "打开技能设置前正在检查 Orca CLI。"
},
"MobileEmulatorTabIntroCallout": {
"1924982130": "关闭",
From 85ef5871daed318d5120e1eea796632ba7a3d687 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 09:08:01 +0000
Subject: [PATCH 05/54] release: v1.4.103-rc.2
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 97c1b601ebb..f47a692ec93 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.103-rc.1",
+ "version": "1.4.103-rc.2",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From 5363fcd6689b85dbcfdd038bb569f1ee9d401fd0 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 27 Jun 2026 02:37:23 -0700
Subject: [PATCH 06/54] Update xterm beta packages (#6486)
---
...rm__addon-ligatures@0.11.0-beta.287.patch} | 0
package.json | 20 +--
pnpm-lock.yaml | 122 +++++++++---------
tests/e2e/terminal-output-scheduler.spec.ts | 21 ++-
4 files changed, 86 insertions(+), 77 deletions(-)
rename config/patches/{@xterm__addon-ligatures@0.11.0-beta.285.patch => @xterm__addon-ligatures@0.11.0-beta.287.patch} (100%)
diff --git a/config/patches/@xterm__addon-ligatures@0.11.0-beta.285.patch b/config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
similarity index 100%
rename from config/patches/@xterm__addon-ligatures@0.11.0-beta.285.patch
rename to config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
diff --git a/package.json b/package.json
index f47a692ec93..18f6d8d1a04 100644
--- a/package.json
+++ b/package.json
@@ -93,8 +93,8 @@
"@floating-ui/dom": "1.7.6",
"@linear/sdk": "^82.1.0",
"@parcel/watcher": "^2.5.6",
- "@xterm/addon-serialize": "0.15.0-beta.285",
- "@xterm/headless": "6.1.0-beta.285",
+ "@xterm/addon-serialize": "0.15.0-beta.287",
+ "@xterm/headless": "6.1.0-beta.287",
"agent-browser": "~0.27.0",
"electron-updater": "^6.8.3",
"i18next": "^26.3.1",
@@ -145,13 +145,13 @@
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"@vitejs/plugin-react": "^5.2.0",
- "@xterm/addon-fit": "0.12.0-beta.285",
- "@xterm/addon-ligatures": "0.11.0-beta.285",
- "@xterm/addon-search": "0.17.0-beta.285",
- "@xterm/addon-unicode11": "0.10.0-beta.285",
- "@xterm/addon-web-links": "0.13.0-beta.285",
- "@xterm/addon-webgl": "0.20.0-beta.284",
- "@xterm/xterm": "6.1.0-beta.285",
+ "@xterm/addon-fit": "0.12.0-beta.287",
+ "@xterm/addon-ligatures": "0.11.0-beta.287",
+ "@xterm/addon-search": "0.17.0-beta.287",
+ "@xterm/addon-unicode11": "0.10.0-beta.287",
+ "@xterm/addon-web-links": "0.13.0-beta.287",
+ "@xterm/addon-webgl": "0.20.0-beta.286",
+ "@xterm/xterm": "6.1.0-beta.287",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -247,7 +247,7 @@
],
"patchedDependencies": {
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
- "@xterm/addon-ligatures@0.11.0-beta.285": "config/patches/@xterm__addon-ligatures@0.11.0-beta.285.patch"
+ "@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch"
}
},
"reactDoctor": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 59194a81eee..265b68777ab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,9 +5,9 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
- '@xterm/addon-ligatures@0.11.0-beta.285':
+ '@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
- path: config/patches/@xterm__addon-ligatures@0.11.0-beta.285.patch
+ path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
path: config/patches/node-pty@1.1.0.patch
@@ -32,11 +32,11 @@ importers:
specifier: ^2.5.6
version: 2.5.6
'@xterm/addon-serialize':
- specifier: 0.15.0-beta.285
- version: 0.15.0-beta.285(@xterm/xterm@6.1.0-beta.285)
+ specifier: 0.15.0-beta.287
+ version: 0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)
'@xterm/headless':
- specifier: 6.1.0-beta.285
- version: 6.1.0-beta.285
+ specifier: 6.1.0-beta.287
+ version: 6.1.0-beta.287
agent-browser:
specifier: ~0.27.0
version: 0.27.0
@@ -183,26 +183,26 @@ importers:
specifier: ^5.2.0
version: 5.2.0(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@xterm/addon-fit':
- specifier: 0.12.0-beta.285
- version: 0.12.0-beta.285(@xterm/xterm@6.1.0-beta.285)
+ specifier: 0.12.0-beta.287
+ version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)
'@xterm/addon-ligatures':
- specifier: 0.11.0-beta.285
- version: 0.11.0-beta.285(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.285)
+ specifier: 0.11.0-beta.287
+ version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287)
'@xterm/addon-search':
- specifier: 0.17.0-beta.285
- version: 0.17.0-beta.285(@xterm/xterm@6.1.0-beta.285)
+ specifier: 0.17.0-beta.287
+ version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287)
'@xterm/addon-unicode11':
- specifier: 0.10.0-beta.285
- version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)
+ specifier: 0.10.0-beta.287
+ version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287)
'@xterm/addon-web-links':
- specifier: 0.13.0-beta.285
- version: 0.13.0-beta.285(@xterm/xterm@6.1.0-beta.285)
+ specifier: 0.13.0-beta.287
+ version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)
'@xterm/addon-webgl':
- specifier: 0.20.0-beta.284
- version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)
+ specifier: 0.20.0-beta.286
+ version: 0.20.0-beta.286(@xterm/xterm@6.1.0-beta.287)
'@xterm/xterm':
- specifier: 6.1.0-beta.285
- version: 6.1.0-beta.285
+ specifier: 6.1.0-beta.287
+ version: 6.1.0-beta.287
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -3131,47 +3131,47 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
- '@xterm/addon-fit@0.12.0-beta.285':
- resolution: {integrity: sha512-rSVi0nMbK+9sgL1QXmJlv3nWvktj0AZeRd52yVzyNhl5mxxEkvxiwFJZlcEBRg27ipeWVAs1PeuCz0A9QnNnAQ==}
+ '@xterm/addon-fit@0.12.0-beta.287':
+ resolution: {integrity: sha512-2MDj+J4x67bjOS/SuBPxSYEWH38NbX6ENV18RbKVOhfRYCX3yERnuHBOrgH9hYdY8rCtsLQmuVgF6cmvCJiV2w==}
peerDependencies:
- '@xterm/xterm': ^6.1.0-beta.285
+ '@xterm/xterm': ^6.1.0-beta.287
- '@xterm/addon-ligatures@0.11.0-beta.285':
- resolution: {integrity: sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==}
+ '@xterm/addon-ligatures@0.11.0-beta.287':
+ resolution: {integrity: sha512-NiCDOnrzgQe7Dkpab4QtQ8VpkcLC7p8WjyLbn1EcZxhbuNO4NmiXgiMbXYnzhyPTkdz9xOarwDRKB8ulMMTLTQ==}
engines: {node: '>8.0.0'}
peerDependencies:
- '@xterm/xterm': ^6.1.0-beta.285
+ '@xterm/xterm': ^6.1.0-beta.287
- '@xterm/addon-search@0.17.0-beta.285':
- resolution: {integrity: sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==}
+ '@xterm/addon-search@0.17.0-beta.287':
+ resolution: {integrity: sha512-nFyUwT/i7i4i1BgqYhQsHIuUGhE18g0KiuoxU6e78n63zGN6RVw5jExvZ8PPa3zVuu/vf8pJ9G9gQs2OmekX4g==}
peerDependencies:
- '@xterm/xterm': ^6.1.0-beta.285
+ '@xterm/xterm': ^6.1.0-beta.287
- '@xterm/addon-serialize@0.15.0-beta.285':
- resolution: {integrity: sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==}
+ '@xterm/addon-serialize@0.15.0-beta.287':
+ resolution: {integrity: sha512-UPN1vUaDabYmRr3LU4uQdGzsIJ8/+zJH8OQ+kTOPWtKG3ETkwu3z2tDtSZslDSSg/LtAUfUVtrQdbsxAl5KlLQ==}
peerDependencies:
- '@xterm/xterm': ^6.1.0-beta.285
+ '@xterm/xterm': ^6.1.0-beta.287
- '@xterm/addon-unicode11@0.10.0-beta.285':
- resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==}
+ '@xterm/addon-unicode11@0.10.0-beta.287':
+ resolution: {integrity: sha512-79JP5qtNh/+emyqW/1TPcFtPrmMIUgypCcx2g6Eo/vQrZF6YNaQrKkJTovMg2RoxbbRy72Oe1SZcpU2b37hzrw==}
peerDependencies:
- '@xterm/xterm': ^6.1.0-beta.285
+ '@xterm/xterm': ^6.1.0-beta.287
- '@xterm/addon-web-links@0.13.0-beta.285':
- resolution: {integrity: sha512-deEjcrV/LQFM031l6Jop2mVK79RJru6SlNCjtFXb0Ex5GwIcPZCGW4VdZYMU5toQ3mMDR6yrzWDsb9SFtDrMeA==}
+ '@xterm/addon-web-links@0.13.0-beta.287':
+ resolution: {integrity: sha512-1+5+zWtIGBOygy4yB9is0V9PwEZSz2NsRzQyNqGb+7hnTW2PXqvUCjq6GLvyUflHQw8VQ7jXtUuFE+ETVzcx+A==}
peerDependencies:
- '@xterm/xterm': ^6.1.0-beta.285
+ '@xterm/xterm': ^6.1.0-beta.287
- '@xterm/addon-webgl@0.20.0-beta.284':
- resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==}
+ '@xterm/addon-webgl@0.20.0-beta.286':
+ resolution: {integrity: sha512-Nvk9Jic7OCXtL1lS1/Ryurkpd2VElO7PCBOf+DEB3ODs9yU+F31sJwQD+LIdZ6GAJ+Atp7LuWHjSNhRv/xSY0w==}
peerDependencies:
- '@xterm/xterm': ^6.1.0-beta.285
+ '@xterm/xterm': ^6.1.0-beta.287
- '@xterm/headless@6.1.0-beta.285':
- resolution: {integrity: sha512-NJud+XEUjKMT2LwPqcIh/gazktV+R2AHjEPMQsn/l6+53rgFusuifmJjVkWLwZ228YYsUaN5+lJELeExS26q4A==}
+ '@xterm/headless@6.1.0-beta.287':
+ resolution: {integrity: sha512-3Jjh1uM9r+euj8Qosm/9SuZIVIZKMMus/Ekk9Spav842WVoFH3F1APTeHl3VynHNi1uHdEbb6QrnHzKA/iJlBQ==}
- '@xterm/xterm@6.1.0-beta.285':
- resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==}
+ '@xterm/xterm@6.1.0-beta.287':
+ resolution: {integrity: sha512-3aOHVlxlRVb3tOHI4b/z2O/OsRnqpW5u0qq8OqpqbwLVTbUhThmQ7mBKaehGdbsmcCCBTP1cBwhG0qcvuOjowA==}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
@@ -9146,39 +9146,39 @@ snapshots:
'@xmldom/xmldom@0.9.10':
optional: true
- '@xterm/addon-fit@0.12.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
+ '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
dependencies:
- '@xterm/xterm': 6.1.0-beta.285
+ '@xterm/xterm': 6.1.0-beta.287
- '@xterm/addon-ligatures@0.11.0-beta.285(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.285)':
+ '@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287)':
dependencies:
- '@xterm/xterm': 6.1.0-beta.285
+ '@xterm/xterm': 6.1.0-beta.287
lru-cache: 11.5.1
opentype.js: 2.0.0
- '@xterm/addon-search@0.17.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
+ '@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
dependencies:
- '@xterm/xterm': 6.1.0-beta.285
+ '@xterm/xterm': 6.1.0-beta.287
- '@xterm/addon-serialize@0.15.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
+ '@xterm/addon-serialize@0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
dependencies:
- '@xterm/xterm': 6.1.0-beta.285
+ '@xterm/xterm': 6.1.0-beta.287
- '@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
+ '@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
dependencies:
- '@xterm/xterm': 6.1.0-beta.285
+ '@xterm/xterm': 6.1.0-beta.287
- '@xterm/addon-web-links@0.13.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
+ '@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
dependencies:
- '@xterm/xterm': 6.1.0-beta.285
+ '@xterm/xterm': 6.1.0-beta.287
- '@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)':
+ '@xterm/addon-webgl@0.20.0-beta.286(@xterm/xterm@6.1.0-beta.287)':
dependencies:
- '@xterm/xterm': 6.1.0-beta.285
+ '@xterm/xterm': 6.1.0-beta.287
- '@xterm/headless@6.1.0-beta.285': {}
+ '@xterm/headless@6.1.0-beta.287': {}
- '@xterm/xterm@6.1.0-beta.285': {}
+ '@xterm/xterm@6.1.0-beta.287': {}
abbrev@4.0.0: {}
diff --git a/tests/e2e/terminal-output-scheduler.spec.ts b/tests/e2e/terminal-output-scheduler.spec.ts
index dd4089105b3..e4fa1cc4362 100644
--- a/tests/e2e/terminal-output-scheduler.spec.ts
+++ b/tests/e2e/terminal-output-scheduler.spec.ts
@@ -66,11 +66,20 @@ async function createTerminalTab(page: Page): Promise {
const tabsBefore = await countRenderedTabs(page)
const activeBefore = await getActiveTabId(page)
- await page.getByRole('button', { name: 'New tab' }).click()
- await page
- .getByRole('menuitem', { name: /New Terminal/i })
- .first()
- .click()
+ const createdTabId = await page.evaluate(() => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is not available')
+ }
+ const state = store.getState()
+ const worktreeId = state.activeWorktreeId
+ if (!worktreeId) {
+ throw new Error('createTerminalTab: active worktree id was unavailable')
+ }
+ // Why: this scheduler spec cares about mounted PTYs, not the tab menu.
+ // Store creation avoids hiding xterm regressions behind menu hit-testing flakes.
+ return state.createTab(worktreeId).id
+ })
await expect
.poll(() => countRenderedTabs(page), {
@@ -84,7 +93,7 @@ async function createTerminalTab(page: Page): Promise {
.poll(
async () => {
tabId = await getActiveTabId(page)
- return Boolean(tabId && tabId !== activeBefore)
+ return tabId === createdTabId && tabId !== activeBefore
},
{
timeout: 5_000,
From a8f949d24db743dd85078679f9a678789fe56272 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 02:37:59 -0700
Subject: [PATCH 07/54] Prevent review lookup failures from blocking PR
preparation (#6490)
Catch errors during hosted review lookup if local blockers are present.
This ensures that network or API failures do not completely hide the
Create PR preparation UI if we can still guide the user to resolve local
issues first.
---
.../hosted-review-creation.test.ts | 22 ++++++++++
.../source-control/hosted-review-creation.ts | 40 +++++++++++++------
2 files changed, 50 insertions(+), 12 deletions(-)
diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts
index aa0c0a32737..e50731c8588 100644
--- a/src/main/source-control/hosted-review-creation.test.ts
+++ b/src/main/source-control/hosted-review-creation.test.ts
@@ -594,6 +594,28 @@ describe('getHostedReviewCreationEligibility', () => {
})
})
+ it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => {
+ getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed'))
+
+ await expect(
+ getHostedReviewCreationEligibility({
+ repoPath: '/repo',
+ branch: 'feature/create-pr',
+ base: 'main',
+ hasUncommittedChanges: true,
+ hasUpstream: false,
+ ahead: 0,
+ behind: 0
+ })
+ ).resolves.toMatchObject({
+ provider: 'github',
+ canCreate: false,
+ blockedReason: 'dirty',
+ nextAction: 'commit',
+ head: 'feature/create-pr'
+ })
+ })
+
it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => {
await expect(
getHostedReviewCreationEligibility({
diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts
index 90159a3dac4..7b6709d2dd3 100644
--- a/src/main/source-control/hosted-review-creation.ts
+++ b/src/main/source-control/hosted-review-creation.ts
@@ -385,18 +385,34 @@ export async function getHostedReviewCreationEligibility(
const defaultBaseRef =
args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId, args))
const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null
- const review = await getHostedReviewForBranch({
- repoPath: args.repoPath,
- branch,
- linkedGitHubPR: args.linkedGitHubPR ?? null,
- fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null,
- linkedGitLabMR: args.linkedGitLabMR ?? null,
- linkedBitbucketPR: args.linkedBitbucketPR ?? null,
- linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null,
- linkedGiteaPR: args.linkedGiteaPR ?? null,
- connectionId: args.connectionId ?? null,
- ...hostedReviewExecutionContext(args)
- })
+ let review: Awaited> = null
+ try {
+ review = await getHostedReviewForBranch({
+ repoPath: args.repoPath,
+ branch,
+ linkedGitHubPR: args.linkedGitHubPR ?? null,
+ fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null,
+ linkedGitLabMR: args.linkedGitLabMR ?? null,
+ linkedBitbucketPR: args.linkedBitbucketPR ?? null,
+ linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null,
+ linkedGiteaPR: args.linkedGiteaPR ?? null,
+ connectionId: args.connectionId ?? null,
+ ...hostedReviewExecutionContext(args)
+ })
+ } catch (error) {
+ const canReturnLocalBlocker =
+ branch &&
+ branch !== 'HEAD' &&
+ supportsHostedReviewCreation(provider) &&
+ (!baseBranch || branch.toLowerCase() !== baseBranch.toLowerCase()) &&
+ (args.hasUncommittedChanges || args.hasUpstream !== true || (args.behind ?? 0) > 0)
+ if (!canReturnLocalBlocker) {
+ throw error
+ }
+ // Why: local blockers still let the UI offer Create PR preparation; a
+ // flaky existing-review lookup should not hide the affordance entirely.
+ console.warn('Hosted review lookup failed while resolving local review blocker:', error)
+ }
const baseResult = {
provider,
From 38483d5a696137be31afe79999295802148e5fd0 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 27 Jun 2026 02:47:54 -0700
Subject: [PATCH 08/54] Fix WSL watcher shutdown hang (#6489)
* Fix WSL watcher shutdown hang
* fix: harden WSL watcher startup failures
---------
Co-authored-by: Neil
---
src/main/ipc/filesystem-watcher-wsl.test.ts | 279 +++++++-------
src/main/ipc/filesystem-watcher-wsl.ts | 401 ++++++++++++--------
src/main/ipc/filesystem-watcher.ts | 6 +-
3 files changed, 385 insertions(+), 301 deletions(-)
diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts
index 8b310436608..9f649f35d8e 100644
--- a/src/main/ipc/filesystem-watcher-wsl.test.ts
+++ b/src/main/ipc/filesystem-watcher-wsl.test.ts
@@ -1,194 +1,175 @@
-import * as path from 'path'
+import { EventEmitter } from 'events'
+import { PassThrough } from 'stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { readdirMock } = vi.hoisted(() => ({
- readdirMock: vi.fn()
+const { spawnMock } = vi.hoisted(() => ({
+ spawnMock: vi.fn()
}))
-vi.mock('fs/promises', () => ({
- readdir: readdirMock
+vi.mock('child_process', () => ({
+ spawn: spawnMock
}))
import { createWslWatcher } from './filesystem-watcher-wsl'
-import type { WatchedRoot } from './filesystem-watcher-wsl'
+import type { WatchedRoot, WslWatcherDeps } from './filesystem-watcher-wsl'
+
+const SNAPSHOT_START = '\x1e'
+const SNAPSHOT_END = '\x1f'
+const ROOT_KEY = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
+
+class FakeChildProcess extends EventEmitter {
+ stdin = new PassThrough()
+ stdout = new PassThrough()
+ stderr = new PassThrough()
+ kill = vi.fn(() => {
+ this.emit('close', null, 'SIGTERM')
+ return true
+ })
+}
+
+function snapshotFrame(entries: [type: string, mtime: string, path: string][]): string {
+ return `${SNAPSHOT_START}${entries
+ .map(([type, mtime, entryPath]) => `${type}\t${mtime}\t${entryPath}\0`)
+ .join('')}${SNAPSHOT_END}`
+}
+
+type ScheduleBatchFlush = (rootKey: string, root: WatchedRoot) => void
+type ScheduleBatchFlushMock = ReturnType>
+
+function makeDeps(
+ scheduleBatchFlush: ScheduleBatchFlushMock = vi.fn()
+): WslWatcherDeps & {
+ scheduleBatchFlush: ScheduleBatchFlushMock
+ watchedRoots: Map
+} {
+ return {
+ ignoreDirs: ['node_modules', '.git'],
+ scheduleBatchFlush,
+ watchedRoots: new Map()
+ }
+}
+
+function startWatcher(deps = makeDeps()): {
+ child: FakeChildProcess
+ promise: Promise
+ deps: ReturnType
+} {
+ const child = new FakeChildProcess()
+ spawnMock.mockReturnValueOnce(child)
+ const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, deps)
+ return { child, promise, deps }
+}
+
+async function resolveInitialSnapshot(
+ child: FakeChildProcess,
+ promise: Promise
+): Promise {
+ child.stdout.write(snapshotFrame([['f', '1.0', '/home/me/repo/README.md']]))
+ return promise
+}
describe('createWslWatcher', () => {
- const rootPath = '/mnt/wsl/repo'
- const rootKey = rootPath
-
beforeEach(() => {
- vi.useFakeTimers()
- readdirMock.mockReset()
+ spawnMock.mockReset()
})
afterEach(() => {
- vi.useRealTimers()
+ vi.restoreAllMocks()
})
- function deps(scheduleBatchFlush: (rootKey: string, root: WatchedRoot) => void) {
- return {
- ignoreDirs: ['node_modules', '.git'],
- scheduleBatchFlush,
- watchedRoots: new Map()
- }
- }
+ it('spawns a WSL-native snapshot process for the distro path', async () => {
+ const { child, promise } = startWatcher()
+ child.stdout.write(snapshotFrame([]))
- function releasePollWith(
- releasePoll: ((entries: ReturnType[]) => void) | null,
- entries: ReturnType[]
- ): void {
- if (!releasePoll) {
- throw new Error('expected an in-flight poll')
- }
- releasePoll(entries)
- }
+ await promise
- function dirent(name: string, type: 'dir' | 'file' | 'symlink' = 'file') {
- return {
- name,
- isDirectory: () => type === 'dir',
- isSymbolicLink: () => type === 'symlink'
- }
- }
+ expect(spawnMock).toHaveBeenCalledWith(
+ 'wsl.exe',
+ ['-d', 'Ubuntu', '--', 'sh', '-s', '--', '/home/me/repo'],
+ expect.objectContaining({
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true
+ })
+ )
+ })
- it('does not overlap polling when a WSL snapshot scan is still in flight', async () => {
+ it('diffs WSL snapshots into create, update, and delete events', async () => {
const scheduleBatchFlush = vi.fn()
- let rootReads = 0
- let releasePoll: ((entries: ReturnType[]) => void) | null = null
+ const { child, promise } = startWatcher(makeDeps(scheduleBatchFlush))
- readdirMock.mockImplementation((dirPath: string) => {
- if (dirPath !== rootPath) {
- return Promise.resolve([])
- }
- rootReads += 1
- if (rootReads === 1) {
- return Promise.resolve([dirent('src', 'dir')])
- }
- if (rootReads === 2) {
- return new Promise[]>((resolve) => {
- releasePoll = resolve
- })
- }
- return Promise.resolve([dirent('src', 'dir')])
- })
-
- const root = await createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush))
-
- expect(rootReads).toBe(1)
- await vi.advanceTimersByTimeAsync(2_000)
- expect(rootReads).toBe(2)
- await vi.advanceTimersByTimeAsync(4_000)
- expect(rootReads).toBe(2)
-
- releasePollWith(releasePoll, [dirent('src', 'dir'), dirent('new-file.ts')])
- await vi.advanceTimersByTimeAsync(0)
+ child.stdout.write(
+ snapshotFrame([
+ ['f', '1.0', '/home/me/repo/README.md'],
+ ['f', '1.0', '/home/me/repo/old.txt']
+ ])
+ )
+ const root = await promise
+ child.stdout.write(
+ snapshotFrame([
+ ['f', '2.0', '/home/me/repo/README.md'],
+ ['f', '1.0', '/home/me/repo/new.txt']
+ ])
+ )
expect(scheduleBatchFlush).toHaveBeenCalledOnce()
- await root.subscription.unsubscribe()
+ expect(root.batch.events).toEqual([
+ { type: 'update', path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\README.md' },
+ { type: 'create', path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\new.txt' },
+ { type: 'delete', path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\old.txt' }
+ ])
})
- it('does not flush a poll that completes after unsubscribe', async () => {
+ it('turns watcher exit into an overflow refresh without retaining UNC paths', async () => {
const scheduleBatchFlush = vi.fn()
- let rootReads = 0
- let releasePoll: ((entries: ReturnType[]) => void) | null = null
+ const deps = makeDeps(scheduleBatchFlush)
+ const { child, promise } = startWatcher(deps)
+ const root = await resolveInitialSnapshot(child, promise)
+ deps.watchedRoots.set(ROOT_KEY, root)
- readdirMock.mockImplementation((dirPath: string) => {
- if (dirPath !== rootPath) {
- return Promise.resolve([])
- }
- rootReads += 1
- if (rootReads === 1) {
- return Promise.resolve([dirent('src', 'dir')])
- }
- return new Promise[]>((resolve) => {
- releasePoll = resolve
- })
- })
+ child.emit('close', null, 'SIGTERM')
+
+ expect(scheduleBatchFlush).toHaveBeenCalledOnce()
+ expect(root.batch.events).toEqual([])
+ expect(root.batch.overflowed).toBe(true)
+ expect(deps.watchedRoots.has(ROOT_KEY)).toBe(false)
+ })
+
+ it('kills the WSL child on unsubscribe without emitting a shutdown refresh', async () => {
+ const scheduleBatchFlush = vi.fn()
+ const { child, promise } = startWatcher(makeDeps(scheduleBatchFlush))
+ const root = await resolveInitialSnapshot(child, promise)
- const root = await createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush))
- await vi.advanceTimersByTimeAsync(2_000)
await root.subscription.unsubscribe()
- releasePollWith(releasePoll, [dirent('src', 'dir'), dirent('late-file.ts')])
- await vi.advanceTimersByTimeAsync(0)
-
+ expect(child.kill).toHaveBeenCalledOnce()
expect(scheduleBatchFlush).not.toHaveBeenCalled()
})
- it('does not probe root-level files during WSL snapshots', async () => {
- const scheduleBatchFlush = vi.fn()
+ it('rejects when the WSL process exits before the first snapshot', async () => {
+ const { child, promise } = startWatcher()
- readdirMock.mockImplementation((dirPath: string) => {
- if (dirPath === rootPath) {
- return Promise.resolve([
- dirent('src', 'dir'),
- dirent('README.md'),
- dirent('package.json'),
- dirent('linked-dir', 'symlink')
- ])
- }
- return Promise.resolve([])
- })
+ child.stderr.write('find failed')
+ child.emit('close', 1, null)
- const root = await createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush))
-
- const readPaths = readdirMock.mock.calls.map(([dirPath]) => dirPath)
- expect(readPaths).toEqual([
- rootPath,
- path.join(rootPath, 'src'),
- path.join(rootPath, 'linked-dir')
- ])
- expect(readPaths).not.toContain(path.join(rootPath, 'README.md'))
- expect(readPaths).not.toContain(path.join(rootPath, 'package.json'))
- await root.subscription.unsubscribe()
+ await expect(promise).rejects.toThrow('WSL watcher exited before first snapshot')
})
- it('limits concurrent child directory reads during WSL snapshots', async () => {
+ it('does not emit a shutdown refresh after a startup error', async () => {
const scheduleBatchFlush = vi.fn()
- const childDirs = Array.from({ length: 40 }, (_, index) => dirent(`dir-${index}`, 'dir'))
- let activeChildReads = 0
- let maxActiveChildReads = 0
+ const { child, promise } = startWatcher(makeDeps(scheduleBatchFlush))
- readdirMock.mockImplementation((dirPath: string) => {
- if (dirPath === rootPath) {
- return Promise.resolve(childDirs)
- }
- activeChildReads += 1
- maxActiveChildReads = Math.max(maxActiveChildReads, activeChildReads)
- return new Promise[]>((resolve) => {
- setTimeout(() => {
- activeChildReads -= 1
- resolve([])
- }, 1)
- })
- })
+ child.emit('error', new Error('spawn failed'))
+ child.emit('close', 1, null)
- const rootPromise = createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush))
- await Promise.resolve()
- await Promise.resolve()
-
- expect(maxActiveChildReads).toBeLessThanOrEqual(8)
- for (let i = 0; i < childDirs.length; i += 1) {
- await vi.advanceTimersByTimeAsync(1)
- }
-
- const root = await rootPromise
- expect(maxActiveChildReads).toBeLessThanOrEqual(8)
- await root.subscription.unsubscribe()
+ await expect(promise).rejects.toThrow('spawn failed')
+ expect(scheduleBatchFlush).not.toHaveBeenCalled()
})
- it('marks a large WSL poll event batch for overflow without retaining every event', async () => {
- const scheduleBatchFlush = vi.fn()
- const initialEntries = Array.from({ length: 200_000 }, (_, index) => dirent(`file-${index}.ts`))
+ it('rejects startup when WSL exits before reading the snapshot script', async () => {
+ const { child, promise } = startWatcher()
- readdirMock.mockResolvedValueOnce(initialEntries).mockResolvedValueOnce([])
+ child.stdin.emit('error', new Error('write EPIPE'))
- const root = await createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush))
- await vi.advanceTimersByTimeAsync(2_000)
-
- expect(scheduleBatchFlush).toHaveBeenCalledOnce()
- expect(root.batch.events).toHaveLength(0)
- expect(root.batch.overflowed).toBe(true)
- await root.subscription.unsubscribe()
+ await expect(promise).rejects.toThrow('write EPIPE')
})
})
diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts
index e7d045445d8..3ea523a0c8d 100644
--- a/src/main/ipc/filesystem-watcher-wsl.ts
+++ b/src/main/ipc/filesystem-watcher-wsl.ts
@@ -1,20 +1,16 @@
/**
- * Polling-based file watcher for WSL paths.
+ * WSL-native file watcher for WSL paths.
*
- * Why: @parcel/watcher uses ReadDirectoryChangesW which doesn't work across
- * the WSL network filesystem boundary (\\wsl.localhost\…). Instead of
- * requiring the user to install extra tools inside WSL, we poll the
- * directory tree via Node's fs.readdir (which works on UNC paths) and diff
- * against a snapshot to detect changes. A 2 s poll interval is a good
- * balance between responsiveness and CPU cost — nobody stares at the file
- * explorer waiting for instant refresh.
+ * Why: polling \\wsl.localhost from Windows keeps waking the distro after
+ * `wsl --shutdown`, which can make WSL look wedged. Keep the polling process
+ * inside the distro so shutdown kills it instead of Orca restarting WSL.
*/
-import type { Dirent } from 'fs'
-import { readdir } from 'fs/promises'
-import * as path from 'path'
+import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
+import { StringDecoder } from 'string_decoder'
import type { WebContents } from 'electron'
import type { Event as WatcherEvent } from '@parcel/watcher'
-import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch'
+import { queueWatcherEvents } from './filesystem-watcher-event-batch'
+import { parseWslUncPath } from '../../shared/wsl-paths'
export type WatcherSubscription = {
unsubscribe(): Promise
@@ -40,190 +36,297 @@ export type WslWatcherDeps = {
}
const POLL_INTERVAL_MS = 2000
-const SNAPSHOT_CHILD_READ_CONCURRENCY = 8
-const DIFF_EVENT_OVERFLOW_LIMIT = MAX_BATCHED_WATCHER_EVENTS + 1
+const POLL_INTERVAL_SECONDS = Math.max(1, Math.ceil(POLL_INTERVAL_MS / 1000))
+const STARTUP_TIMEOUT_MS = 10_000
+const SNAPSHOT_START = '\x1e'
+const SNAPSHOT_END = '\x1f'
+const MAX_STREAM_BUFFER_CHARS = 10 * 1024 * 1024
-type DirSnapshot = Map>
+type WslSnapshotEntry = {
+ path: string
+ type: string
+ mtime: string
+}
-async function readDirEntriesSafe(dirPath: string): Promise {
- try {
- const entries = await readdir(dirPath, { withFileTypes: true })
- return entries
- } catch {
- return []
+type WslSnapshot = Map
+
+function toWslUncPath(linuxPath: string, distro: string): string {
+ return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
+}
+
+function quoteSafeFindName(name: string): string {
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
+ throw new Error(`Unsupported WSL watcher ignore name: ${name}`)
}
+ return `'${name}'`
}
-function shouldIgnore(name: string, ignoreDirs: string[]): boolean {
- return ignoreDirs.includes(name)
+function buildPruneExpression(ignoreDirs: readonly string[]): string {
+ if (ignoreDirs.length === 0) {
+ return ''
+ }
+ const names = ignoreDirs.map((name) => `-name ${quoteSafeFindName(name)}`).join(' -o ')
+ return `\\( -type d \\( ${names} \\) -prune \\) -o`
}
-async function forEachWithConcurrency(
- items: readonly T[],
- limit: number,
- worker: (item: T) => Promise
-): Promise {
- let nextIndex = 0
- const workerCount = Math.min(limit, items.length)
- await Promise.all(
- Array.from({ length: workerCount }, async () => {
- while (nextIndex < items.length) {
- const item = items[nextIndex++]
- await worker(item)
- }
- })
- )
+function buildSnapshotScript(ignoreDirs: readonly string[]): string {
+ const prune = buildPruneExpression(ignoreDirs)
+ return [
+ 'set -efu',
+ 'root=$1',
+ 'while :; do',
+ " printf '\\036'",
+ ' if [ -d "$root" ]; then',
+ ` find "$root" -mindepth 1 -maxdepth 2 ${prune} -printf '%y\\t%T@\\t%p\\0' 2>/dev/null || true`,
+ ' fi',
+ " printf '\\037'",
+ ` sleep ${POLL_INTERVAL_SECONDS} || exit 0`,
+ 'done'
+ ].join('\n')
}
-/**
- * Take a snapshot of the root directory and one level of subdirectories.
- * Returns a map of dirPath → set of entry names.
- */
-async function takeSnapshot(rootPath: string, ignoreDirs: string[]): Promise {
- const snapshot: DirSnapshot = new Map()
-
- const rootEntries = await readDirEntriesSafe(rootPath)
- const filtered = rootEntries.filter((entry) => !shouldIgnore(entry.name, ignoreDirs))
- snapshot.set(rootPath, new Set(filtered.map((entry) => entry.name)))
-
- // Why: poll one level of subdirectories so changes inside immediate
- // children are detected, but use Dirent metadata to avoid probing every
- // root-level file with a failing readdir on each WSL poll.
- const childDirs = filtered.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
- await forEachWithConcurrency(childDirs, SNAPSHOT_CHILD_READ_CONCURRENCY, async (entry) => {
- const childPath = path.join(rootPath, entry.name)
- const childEntries = await readDirEntriesSafe(childPath)
- const childFiltered = childEntries
- .filter((childEntry) => !shouldIgnore(childEntry.name, ignoreDirs))
- .map((childEntry) => childEntry.name)
- snapshot.set(childPath, new Set(childFiltered))
- })
-
+function parseSnapshotFrame(frame: string, distro: string): WslSnapshot {
+ const snapshot: WslSnapshot = new Map()
+ for (const rawEntry of frame.split('\0')) {
+ if (!rawEntry) {
+ continue
+ }
+ const firstTab = rawEntry.indexOf('\t')
+ const secondTab = firstTab === -1 ? -1 : rawEntry.indexOf('\t', firstTab + 1)
+ if (firstTab <= 0 || secondTab <= firstTab + 1) {
+ continue
+ }
+ const linuxPath = rawEntry.slice(secondTab + 1)
+ if (!linuxPath.startsWith('/')) {
+ continue
+ }
+ const entry: WslSnapshotEntry = {
+ type: rawEntry.slice(0, firstTab),
+ mtime: rawEntry.slice(firstTab + 1, secondTab),
+ path: toWslUncPath(linuxPath, distro)
+ }
+ snapshot.set(entry.path, entry)
+ }
return snapshot
}
-function appendDiffEvent(events: WatcherEvent[], event: WatcherEvent): boolean {
- events.push(event)
- return events.length >= DIFF_EVENT_OVERFLOW_LIMIT
-}
-
-/**
- * Diff two snapshots and return synthetic watcher events.
- */
-function diffSnapshots(prev: DirSnapshot, next: DirSnapshot): WatcherEvent[] {
+function diffSnapshots(prev: WslSnapshot, next: WslSnapshot): WatcherEvent[] {
const events: WatcherEvent[] = []
- for (const [dirPath, nextEntries] of next) {
- const prevEntries = prev.get(dirPath)
- if (!prevEntries) {
- // New directory appeared — emit create for all entries
- for (const name of nextEntries) {
- if (
- appendDiffEvent(events, {
- type: 'create',
- path: path.join(dirPath, name)
- } as WatcherEvent)
- ) {
- return events
- }
- }
+ for (const [entryPath, nextEntry] of next) {
+ const prevEntry = prev.get(entryPath)
+ if (!prevEntry) {
+ events.push({ type: 'create', path: entryPath } as WatcherEvent)
continue
}
-
- // Check for new entries (create)
- for (const name of nextEntries) {
- if (!prevEntries.has(name)) {
- if (
- appendDiffEvent(events, {
- type: 'create',
- path: path.join(dirPath, name)
- } as WatcherEvent)
- ) {
- return events
- }
- }
+ if (prevEntry.type !== nextEntry.type) {
+ events.push({ type: 'delete', path: entryPath } as WatcherEvent)
+ events.push({ type: 'create', path: entryPath } as WatcherEvent)
+ continue
}
-
- // Check for removed entries (delete)
- for (const name of prevEntries) {
- if (!nextEntries.has(name)) {
- if (
- appendDiffEvent(events, {
- type: 'delete',
- path: path.join(dirPath, name)
- } as WatcherEvent)
- ) {
- return events
- }
- }
+ if (prevEntry.mtime !== nextEntry.mtime) {
+ events.push({ type: 'update', path: entryPath } as WatcherEvent)
}
}
- // Check for directories that disappeared entirely
- for (const [dirPath] of prev) {
- if (!next.has(dirPath)) {
- if (appendDiffEvent(events, { type: 'delete', path: dirPath } as WatcherEvent)) {
- return events
- }
+ for (const entryPath of prev.keys()) {
+ if (!next.has(entryPath)) {
+ events.push({ type: 'delete', path: entryPath } as WatcherEvent)
}
}
return events
}
+function markOverflowWithoutUncStat(root: WatchedRoot): void {
+ if (root.batch.timer) {
+ clearTimeout(root.batch.timer)
+ root.batch.timer = null
+ }
+ root.batch.events = []
+ root.batch.overflowed = true
+}
+
export async function createWslWatcher(
rootKey: string,
worktreePath: string,
deps: WslWatcherDeps
): Promise {
+ const wsl = parseWslUncPath(worktreePath)
+ if (!wsl) {
+ throw new Error(`Not a WSL path: ${worktreePath}`)
+ }
+ const distro = wsl.distro
+ const linuxPath = wsl.linuxPath
+
const root: WatchedRoot = {
subscription: null!,
listeners: new Map(),
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }
}
- // Take initial snapshot
- let prevSnapshot = await takeSnapshot(worktreePath, deps.ignoreDirs)
-
- let polling = false
let disposed = false
- const poll = async (): Promise => {
- if (polling || disposed) {
+ let prevSnapshot: WslSnapshot | null = null
+ let stopped = false
+ let streamBuffer = ''
+ const stdoutDecoder = new StringDecoder('utf8')
+ const stderrDecoder = new StringDecoder('utf8')
+ let stderrTail = ''
+
+ let resolveInitial!: () => void
+ let rejectInitial!: (error: Error) => void
+ let initialSettled = false
+ const initialSnapshotReady = new Promise((resolve, reject) => {
+ resolveInitial = resolve
+ rejectInitial = reject
+ })
+
+ function settleInitial(error?: Error): void {
+ if (initialSettled) {
return
}
- polling = true
- try {
- const nextSnapshot = await takeSnapshot(worktreePath, deps.ignoreDirs)
- if (disposed) {
- return
- }
- const events = diffSnapshots(prevSnapshot, nextSnapshot)
- prevSnapshot = nextSnapshot
-
- if (events.length > 0) {
- queueWatcherEvents(root.batch, events)
- deps.scheduleBatchFlush(rootKey, root)
- }
- } catch {
- // Why: if the WSL filesystem becomes temporarily unavailable
- // (e.g. WSL distro shuts down), skip this poll cycle rather
- // than crashing. The next cycle will retry.
- } finally {
- polling = false
+ initialSettled = true
+ if (error) {
+ rejectInitial(error)
+ } else {
+ resolveInitial()
}
}
- const intervalId = setInterval(() => {
- // Why: WSL UNC scans can exceed the poll interval on large repos or cold
- // network filesystems. Run at most one tree diff at a time so a slow scan
- // cannot stack concurrent readdir storms on the same root.
- void poll()
- }, POLL_INTERVAL_MS)
+ function signalWatcherStopped(): void {
+ if (stopped) {
+ return
+ }
+ if (!prevSnapshot) {
+ return
+ }
+ stopped = true
+ markOverflowWithoutUncStat(root)
+ deps.scheduleBatchFlush(rootKey, root)
+ deps.watchedRoots.delete(rootKey)
+ }
+
+ function ingestFrame(frame: string): void {
+ const nextSnapshot = parseSnapshotFrame(frame, distro)
+ if (!prevSnapshot) {
+ prevSnapshot = nextSnapshot
+ settleInitial()
+ return
+ }
+ const events = diffSnapshots(prevSnapshot, nextSnapshot)
+ prevSnapshot = nextSnapshot
+
+ if (events.length > 0) {
+ queueWatcherEvents(root.batch, events)
+ deps.scheduleBatchFlush(rootKey, root)
+ }
+ }
+
+ function drainFrames(): void {
+ while (true) {
+ const start = streamBuffer.indexOf(SNAPSHOT_START)
+ if (start === -1) {
+ streamBuffer = streamBuffer.slice(-1)
+ return
+ }
+ if (start > 0) {
+ streamBuffer = streamBuffer.slice(start)
+ }
+ const end = streamBuffer.indexOf(SNAPSHOT_END, 1)
+ if (end === -1) {
+ if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) {
+ streamBuffer = ''
+ markOverflowWithoutUncStat(root)
+ deps.scheduleBatchFlush(rootKey, root)
+ }
+ return
+ }
+ const frame = streamBuffer.slice(1, end)
+ streamBuffer = streamBuffer.slice(end + 1)
+ ingestFrame(frame)
+ }
+ }
+
+ let child: ChildProcessWithoutNullStreams
+ try {
+ child = spawn('wsl.exe', ['-d', distro, '--', 'sh', '-s', '--', linuxPath], {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true
+ })
+ } catch (error) {
+ throw error instanceof Error ? error : new Error(String(error))
+ }
+
+ const startupTimer = setTimeout(() => {
+ settleInitial(new Error(`Timed out starting WSL watcher for ${worktreePath}`))
+ child.kill()
+ }, STARTUP_TIMEOUT_MS)
+
+ child.stdin.on('error', (error) => {
+ // Why: WSL can exit before reading the script; handle EPIPE here so the
+ // startup failure rejects the watcher instead of crashing on a stream error.
+ if (!initialSettled) {
+ settleInitial(error)
+ }
+ })
+
+ child.stdout.on('data', (chunk: Buffer) => {
+ if (disposed) {
+ return
+ }
+ streamBuffer += stdoutDecoder.write(chunk)
+ drainFrames()
+ })
+
+ child.stderr.on('data', (chunk: Buffer) => {
+ stderrTail = (stderrTail + stderrDecoder.write(chunk)).slice(-4096)
+ })
+
+ child.stdout.on('error', (error) => {
+ if (!initialSettled) {
+ settleInitial(error)
+ return
+ }
+ if (!disposed) {
+ signalWatcherStopped()
+ }
+ })
+
+ child.stderr.on('error', () => {
+ // Ignore diagnostic stream failures; stdout/close determine watcher state.
+ })
+
+ child.once('error', (error) => {
+ if (!initialSettled) {
+ settleInitial(error)
+ return
+ }
+ if (!disposed) {
+ signalWatcherStopped()
+ }
+ })
+
+ child.once('close', (code, signal) => {
+ if (!initialSettled) {
+ const suffix = stderrTail.trim() ? `: ${stderrTail.trim()}` : ''
+ settleInitial(
+ new Error(`WSL watcher exited before first snapshot (${code ?? signal})${suffix}`)
+ )
+ return
+ }
+ if (!disposed) {
+ signalWatcherStopped()
+ }
+ })
+
+ child.stdin.end(buildSnapshotScript(deps.ignoreDirs))
+
+ await initialSnapshotReady.finally(() => clearTimeout(startupTimer))
root.subscription = {
unsubscribe: async () => {
disposed = true
- clearInterval(intervalId)
+ child.kill()
}
}
diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts
index ee6261366b6..8a0ffb1bf79 100644
--- a/src/main/ipc/filesystem-watcher.ts
+++ b/src/main/ipc/filesystem-watcher.ts
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: filesystem-watcher centralizes native
-(@parcel/watcher), WSL (inotifywait), and SSH remote watcher lifecycles in
+(@parcel/watcher), WSL-native snapshot, and SSH remote watcher lifecycles in
one module so subscription/cleanup invariants stay auditable from a single
file. Splitting by transport would scatter the shared debounce/coalesce
helpers and the common batch-flush path across three files. */
@@ -506,8 +506,8 @@ async function doInstallLocalWatcher(
}
try {
- // Why: WSL paths use inotifywait inside the Linux distro where
- // inotify works natively; native Windows paths use @parcel/watcher.
+ // Why: WSL paths use one snapshot subprocess inside the Linux distro so
+ // `wsl --shutdown` can kill it; native Windows paths use @parcel/watcher.
root = isWslPath(worktreePath)
? await createWslWatcher(rootKey, worktreePath, {
ignoreDirs: WATCHER_IGNORE_DIRS,
From 62918129d8ce5f70e47c8e3202b69712cf695afc Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 10:43:49 +0000
Subject: [PATCH 09/54] release: v1.4.103-rc.3 [rc-slot:2026-06-27-03]
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 18f6d8d1a04..a93b93f9819 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.103-rc.2",
+ "version": "1.4.103-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From 3ce184ca1610989a43ca6791719e1221d8008db3 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 12:46:04 +0000
Subject: [PATCH 10/54] Update README downloads badge
---
docs/assets/readme-downloads.svg | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index ea36a162cd5..e65adc95179 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
- downloads: 2.2m
+
+ downloads: 2.3m
@@ -15,7 +15,7 @@
downloads
downloads
- 2.2m
- 2.2m
+ 2.3m
+ 2.3m
From 867c57b93c2be36a5c1f3bba3a8faf537ad639b9 Mon Sep 17 00:00:00 2001
From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com>
Date: Sat, 27 Jun 2026 13:30:29 -0300
Subject: [PATCH 11/54] fix(renderer): answer OSC color queries from active
terminal theme (#6502)
* fix(renderer): answer OSC color queries from active terminal theme
- reply to OSC 10/11 foreground/background queries using the resolved xterm theme
- suppress replayed OSC color replies to avoid leaking escape output into fresh shells
- cover theme-derived color replies and replay suppression with focused tests
* test(renderer): cover terminal OSC color query replies
Co-authored-by: Orca
---------
Co-authored-by: Jinwoo-H
Co-authored-by: Orca
---
.../terminal-capability-replies.test.ts | 115 +++++++++++++++++-
.../terminal-capability-replies.ts | 103 +++++++++++++++-
tests/e2e/terminal-osc-color-queries.spec.ts | 91 ++++++++++++++
3 files changed, 305 insertions(+), 4 deletions(-)
create mode 100644 tests/e2e/terminal-osc-color-queries.spec.ts
diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts
index e4fbcd3371a..ed516c7f11f 100644
--- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts
@@ -62,6 +62,115 @@ describe('installTerminalCapabilityReplyHandlers', () => {
}
})
+ it('answers OSC foreground and background color queries from the active theme', async () => {
+ const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
+ term.options.theme = {
+ foreground: '#2e3434',
+ background: '#ffffff'
+ }
+ const sendInput = vi.fn<(data: string) => boolean>(() => true)
+ const disposable = installTerminalCapabilityReplyHandlers({
+ terminal: term as never,
+ parser: term.parser,
+ sendInput,
+ isReplaying: () => false
+ })
+
+ try {
+ await writeTerminal(term, '\x1b]10;?\x1b\\\x1b]11;?\x1b\\')
+
+ expect(sendInput).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
+ expect(sendInput).toHaveBeenCalledWith('\x1b]11;rgb:ffff/ffff/ffff\x1b\\')
+ } finally {
+ disposable.dispose()
+ term.dispose()
+ }
+ })
+
+ it('answers OSC color queries for active rgba and modern rgb theme colors', async () => {
+ const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
+ term.options.theme = {
+ foreground: 'rgb(245 245 244 / 92%)',
+ background: 'rgba(17, 34, 51, 0.5)'
+ }
+ const sendInput = vi.fn<(data: string) => boolean>(() => true)
+ const disposable = installTerminalCapabilityReplyHandlers({
+ terminal: term as never,
+ parser: term.parser,
+ sendInput,
+ isReplaying: () => false
+ })
+
+ try {
+ await writeTerminal(term, '\x1b]10;?\x1b\\\x1b]11;?\x1b\\')
+
+ expect(sendInput).toHaveBeenCalledWith('\x1b]10;rgb:f5f5/f5f5/f4f4\x1b\\')
+ expect(sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/2222/3333\x1b\\')
+ } finally {
+ disposable.dispose()
+ term.dispose()
+ }
+ })
+
+ it('leaves non-query OSC color commands to other handlers', async () => {
+ const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
+ term.options.theme = {
+ foreground: '#2e3434',
+ background: '#ffffff'
+ }
+ const sendInput = vi.fn<(data: string) => boolean>(() => true)
+ const returnValues: boolean[] = []
+ const disposable = installTerminalCapabilityReplyHandlers({
+ terminal: term as never,
+ parser: {
+ registerCsiHandler: (id, cb) =>
+ term.parser.registerCsiHandler(id, (params) => cb(params) === true),
+ registerOscHandler: (id, cb) =>
+ term.parser.registerOscHandler(id, (data) => {
+ const value = cb(data) === true
+ returnValues.push(value)
+ return value
+ })
+ },
+ sendInput,
+ isReplaying: () => false
+ })
+
+ try {
+ await writeTerminal(term, '\x1b]10;#123456\x1b\\')
+
+ expect(sendInput).not.toHaveBeenCalled()
+ expect(returnValues).toEqual([false])
+ } finally {
+ disposable.dispose()
+ term.dispose()
+ }
+ })
+
+ it('consumes replayed OSC color queries without sending input to the shell', async () => {
+ const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
+ term.options.theme = {
+ foreground: '#2e3434',
+ background: '#ffffff'
+ }
+ const sendInput = vi.fn<(data: string) => boolean>(() => true)
+ const disposable = installTerminalCapabilityReplyHandlers({
+ terminal: term as never,
+ parser: term.parser,
+ sendInput,
+ isReplaying: () => true
+ })
+
+ try {
+ await writeTerminal(term, '\x1b]11;?\x1b\\')
+
+ expect(sendInput).not.toHaveBeenCalled()
+ } finally {
+ disposable.dispose()
+ term.dispose()
+ }
+ })
+
it('answers window and cell pixel-size reports from renderer geometry', () => {
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const observe = createTerminalPixelSizeQueryResponder(
@@ -125,10 +234,12 @@ describe('installTerminalCapabilityReplyHandlers', () => {
parser: {
registerCsiHandler: (id, cb) =>
term.parser.registerCsiHandler(id, (params) => {
- const value = cb(params) as boolean
+ const value = cb(params) === true
returnValues.push(value)
return value
- })
+ }),
+ registerOscHandler: (id, cb) =>
+ term.parser.registerOscHandler(id, (data) => cb(data) === true)
},
sendInput,
isReplaying: () => false
diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts
index 11074945677..f200fa1c4e5 100644
--- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts
@@ -4,8 +4,8 @@ export const DEFAULT_DA1_RESPONSE = '\x1b[?1;2c'
export const CONPTY_DA1_RESPONSE = '\x1b[?61;4c'
type TerminalCapabilityRepliesDeps = {
- terminal: Pick
- parser: Pick
+ terminal: Pick
+ parser: Pick
sendInput: (data: string) => boolean | void
isReplaying: () => boolean
da1Response?: string
@@ -46,6 +46,77 @@ function disposeAll(disposables: IDisposable[]): void {
}
}
+function cssColorToOscRgb(value: string | undefined): string | null {
+ if (!value) {
+ return null
+ }
+ const trimmed = value.trim()
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(trimmed)?.[1]
+ if (hex) {
+ const expanded =
+ hex.length === 3
+ ? hex
+ .split('')
+ .map((char) => `${char}${char}`)
+ .join('')
+ : hex
+ return `rgb:${byteHexToWord(expanded.slice(0, 2))}/${byteHexToWord(
+ expanded.slice(2, 4)
+ )}/${byteHexToWord(expanded.slice(4, 6))}`
+ }
+ const rgb = /^rgba?\(\s*([^)]+)\)$/i.exec(trimmed)
+ if (!rgb) {
+ return null
+ }
+ const channels = parseCssRgbChannels(rgb[1])
+ if (!channels) {
+ return null
+ }
+ const [red, green, blue] = channels.map((byte) => byte.toString(16).padStart(2, '0').repeat(2))
+ return `rgb:${red}/${green}/${blue}`
+}
+
+function byteHexToWord(byte: string): string {
+ return byte.repeat(2)
+}
+
+function parseCssRgbChannels(body: string): [number, number, number] | null {
+ const colorPart = body.split('/')[0]?.trim()
+ if (!colorPart) {
+ return null
+ }
+ const components = colorPart.includes(',')
+ ? colorPart.split(',').slice(0, 3)
+ : colorPart.split(/\s+/).slice(0, 3)
+ if (components.length !== 3) {
+ return null
+ }
+ const channels = components.map((component) => parseCssRgbChannel(component.trim()))
+ if (channels.some((channel) => channel === null)) {
+ return null
+ }
+ return channels as [number, number, number]
+}
+
+function parseCssRgbChannel(component: string): number | null {
+ const percent = /^(\d+(?:\.\d+)?)%$/.exec(component)?.[1]
+ if (percent !== undefined) {
+ return clampByte((Number(percent) / 100) * 255)
+ }
+ if (!/^\d+(?:\.\d+)?$/.test(component)) {
+ return null
+ }
+ return clampByte(Number(component))
+}
+
+function clampByte(value: number): number {
+ return Math.min(255, Math.max(0, Math.round(value)))
+}
+
+function isOscColorQuery(data: string): boolean {
+ return data.trim() === '?'
+}
+
export function createTerminalPixelSizeQueryResponder(
terminal: Pick,
sendInput: (data: string) => boolean | void
@@ -99,6 +170,34 @@ export function installTerminalCapabilityReplyHandlers(
deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE)
}
return true
+ }),
+ deps.parser.registerOscHandler(10, (data) => {
+ if (!isOscColorQuery(data)) {
+ return false
+ }
+ if (deps.isReplaying()) {
+ return true
+ }
+ const foreground = cssColorToOscRgb(deps.terminal.options.theme?.foreground)
+ if (!foreground) {
+ return false
+ }
+ deps.sendInput(`\x1b]10;${foreground}\x1b\\`)
+ return true
+ }),
+ deps.parser.registerOscHandler(11, (data) => {
+ if (!isOscColorQuery(data)) {
+ return false
+ }
+ if (deps.isReplaying()) {
+ return true
+ }
+ const background = cssColorToOscRgb(deps.terminal.options.theme?.background)
+ if (!background) {
+ return false
+ }
+ deps.sendInput(`\x1b]11;${background}\x1b\\`)
+ return true
})
]
diff --git a/tests/e2e/terminal-osc-color-queries.spec.ts b/tests/e2e/terminal-osc-color-queries.spec.ts
new file mode 100644
index 00000000000..03cb37f1ac1
--- /dev/null
+++ b/tests/e2e/terminal-osc-color-queries.spec.ts
@@ -0,0 +1,91 @@
+import type { Page } from '@stablyai/playwright-test'
+import { test, expect } from './helpers/orca-app'
+import {
+ waitForActivePaneHookDescriptor,
+ waitForActivePanePtyId,
+ waitForActiveTerminalManager
+} from './helpers/terminal'
+import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection'
+import {
+ clearTerminalPtyWriteLog,
+ installTerminalPtyWriteSpy,
+ readTerminalPtyWriteEntries
+} from './helpers/terminal-pty-write-spy'
+
+type TerminalTheme = {
+ foreground: string
+ background: string
+}
+
+type TerminalPtyDataInjectionWindow = Window & {
+ __terminalPtyDataInjection?: {
+ inject: (paneKey: string, data: string) => boolean
+ }
+}
+
+async function setActiveTerminalTheme(page: Page, theme: TerminalTheme): Promise {
+ await page.evaluate((nextTheme) => {
+ 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 theme')
+ }
+ pane.terminal.options.theme = nextTheme
+ }, theme)
+}
+
+async function injectPtyOutput(page: Page, paneKey: string, data: string): Promise {
+ return page.evaluate(
+ ({ targetPaneKey, output }) =>
+ (window as TerminalPtyDataInjectionWindow).__terminalPtyDataInjection?.inject(
+ targetPaneKey,
+ output
+ ) ?? false,
+ { targetPaneKey: paneKey, output: data }
+ )
+}
+
+test('answers OSC foreground and background color queries from the active terminal theme', async ({
+ electronApp,
+ orcaPage
+}) => {
+ await installTerminalPtyWriteSpy(electronApp)
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ await ensureTerminalVisible(orcaPage)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+
+ const ptyId = await waitForActivePanePtyId(orcaPage)
+ const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage)
+ await waitForTerminalPtyDataInjector(orcaPage, paneKey)
+ await setActiveTerminalTheme(orcaPage, {
+ foreground: '#2e3434',
+ background: 'rgba(255, 255, 255, 1)'
+ })
+ await clearTerminalPtyWriteLog(electronApp)
+
+ const injected = await injectPtyOutput(orcaPage, paneKey, '\x1b]10;?\x1b\\\x1b]11;?\x1b\\')
+
+ expect(injected).toBe(true)
+ await expect
+ .poll(
+ async () =>
+ (await readTerminalPtyWriteEntries(electronApp))
+ .filter((entry) => entry.id === ptyId)
+ .map((entry) => entry.data),
+ {
+ timeout: 5_000,
+ message: 'OSC color query replies were not written to the active PTY'
+ }
+ )
+ .toEqual(['\x1b]10;rgb:2e2e/3434/3434\x1b\\', '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'])
+})
From c159b5b31acf4e517d38484cc97387232f6d9959 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sat, 27 Jun 2026 10:44:00 -0700
Subject: [PATCH 12/54] Update xterm beta packages (#6467)
Co-authored-by: Orca
From c7153a51b41390442dc34ad44bffa80ed3ba08da Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 17:49:27 +0000
Subject: [PATCH 13/54] release: v1.4.103-rc.4
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index a93b93f9819..0c72e80e5d7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.103-rc.3",
+ "version": "1.4.103-rc.4",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From 9f3cd6b934c9566a80f0fdbcef871b16fabc4da2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 18:48:05 +0000
Subject: [PATCH 14/54] release: v1.4.103
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 0c72e80e5d7..20ed5581e8e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.103-rc.4",
+ "version": "1.4.103",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From c0f4bdd0205b88274b7fe0e69e65227ecedfdf43 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 12:12:46 -0700
Subject: [PATCH 15/54] Default first-seen mobile terminal tabs to direct input
mode (#6509)
Automatically enable direct (live) terminal input for new terminal
handles on mobile, while allowing users to opt out back to buffered
input.
- Track defaulted handles to ensure list refreshes preserve manual
buffered-mode choices.
- Prune tracked handles from live input sets when terminals are closed.
- Add unit tests for defaulting and pruning logic.
- Include a design doc detailing goals and implementation notes.
---
.../app/h/[hostId]/session/[worktreeId].tsx | 88 ++++++++++++++++++-
.../mobile-terminal-direct-input-default.md | 83 +++++++++++++++++
.../src/terminal/terminal-live-input.test.ts | 53 +++++++++++
mobile/src/terminal/terminal-live-input.ts | 72 +++++++++++++++
4 files changed, 292 insertions(+), 4 deletions(-)
create mode 100644 mobile/mobile-terminal-direct-input-default.md
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 45b82de6075..8dbfb932c24 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -99,8 +99,10 @@ import {
} from '../../../../src/terminal/terminal-accessory-layout'
import {
clearTerminalLiveInputFocusTimer,
+ defaultTerminalLiveInputHandles,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
+ pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import {
@@ -937,6 +939,8 @@ export default function SessionScreen() {
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>(
() => new Set()
)
+ const liveInputTerminalHandlesRef = useRef>(new Set())
+ const defaultedLiveInputTerminalHandlesRef = useRef>(new Set())
const [activeHandle, setActiveHandle] = useState(null)
const [activeSessionTabId, setActiveSessionTabId] = useState(null)
const activeSessionTabIdRef = useRef(null)
@@ -1111,6 +1115,7 @@ export default function SessionScreen() {
sessionTabsRef.current = sessionTabs
activeSessionTabIdRef.current = activeSessionTabId
markdownDocsRef.current = markdownDocs
+ liveInputTerminalHandlesRef.current = liveInputTerminalHandles
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
createWarningState,
initialCreateWarning
@@ -1176,6 +1181,52 @@ export default function SessionScreen() {
[clearToastHideTimer]
)
+ // Why: direct input is now the mobile default, but only once per discovered
+ // handle so a user's buffered-mode toggle survives tab/list refreshes.
+ const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
+ const result = defaultTerminalLiveInputHandles(
+ liveInputTerminalHandlesRef.current,
+ defaultedLiveInputTerminalHandlesRef.current,
+ handles
+ )
+ if (!result.changed) {
+ return
+ }
+ const nextEnabledHandles = new Set(result.enabledHandles)
+ const nextDefaultedHandles = new Set(result.defaultedHandles)
+ liveInputTerminalHandlesRef.current = nextEnabledHandles
+ defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
+ setLiveInputTerminalHandles(nextEnabledHandles)
+ }, [])
+
+ const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => {
+ const result = pruneTerminalLiveInputHandles(
+ liveInputTerminalHandlesRef.current,
+ defaultedLiveInputTerminalHandlesRef.current,
+ liveHandles
+ )
+ if (!result.changed) {
+ return
+ }
+ const nextEnabledHandles = new Set(result.enabledHandles)
+ const nextDefaultedHandles = new Set(result.defaultedHandles)
+ liveInputTerminalHandlesRef.current = nextEnabledHandles
+ defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
+ setLiveInputTerminalHandles(nextEnabledHandles)
+ }, [])
+
+ const clearTerminalLiveInputDefault = useCallback(
+ (handle: string) => {
+ const liveHandles = new Set([
+ ...liveInputTerminalHandlesRef.current,
+ ...defaultedLiveInputTerminalHandlesRef.current
+ ])
+ liveHandles.delete(handle)
+ pruneTerminalHandlesFromLiveInput(liveHandles)
+ },
+ [pruneTerminalHandlesFromLiveInput]
+ )
+
const dictation = useMobileDictation({
client,
enabled: canSend,
@@ -1613,11 +1664,16 @@ export default function SessionScreen() {
}
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
+ // Why: terminal.list is the lifetime signal; session-tab snapshots can lag
+ // mobile-created tabs and must not erase a user's buffered-mode opt-out.
+ pruneTerminalHandlesFromLiveInput(liveHandles)
+ defaultTerminalHandlesToLiveInput([...liveHandles])
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
if (!liveHandles.has(handle)) {
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
+ clearTerminalLiveInputDefault(handle)
setTerminalKeyboardMetrics((prev) => {
if (!prev.has(handle)) {
return prev
@@ -1662,7 +1718,15 @@ export default function SessionScreen() {
fetchTerminalsInFlightRef.current = false
}
},
- [client, worktreeId, subscribeToTerminal, unsubscribeTerminal]
+ [
+ client,
+ worktreeId,
+ clearTerminalLiveInputDefault,
+ defaultTerminalHandlesToLiveInput,
+ pruneTerminalHandlesFromLiveInput,
+ subscribeToTerminal,
+ unsubscribeTerminal
+ ]
)
const applySessionTabs = useCallback(
@@ -1703,6 +1767,8 @@ export default function SessionScreen() {
// render loop where the subscription effect tears down and replays itself.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs)
+ const terminalTabHandles = terminalTabs.map((terminal) => terminal.handle)
+ defaultTerminalHandlesToLiveInput(terminalTabHandles)
const mergedTerminalsForActive = mergeTerminalRecordsByCurrentOrder(
terminalTabs,
terminalsRef.current
@@ -1797,7 +1863,7 @@ export default function SessionScreen() {
setActiveHandle(null)
}
},
- [subscribeToTerminal, unsubscribeTerminal]
+ [defaultTerminalHandlesToLiveInput, subscribeToTerminal, unsubscribeTerminal]
)
const readMarkdownTab = useCallback(
@@ -2588,6 +2654,8 @@ export default function SessionScreen() {
setSessionTabs([])
setActiveSessionTabId(null)
setLiveInputCapture('')
+ liveInputTerminalHandlesRef.current = new Set()
+ defaultedLiveInputTerminalHandlesRef.current = new Set()
setLiveInputTerminalHandles(new Set())
setMarkdownDocs(new Map())
setFileDocs(new Map())
@@ -2595,7 +2663,7 @@ export default function SessionScreen() {
return () => {
clearDelayedActionTimers()
}
- }, [clearDelayedActionTimers, clearTerminalCache, worktreeId])
+ }, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId])
useEffect(() => {
if (connState !== 'connected') {
@@ -2784,6 +2852,7 @@ export default function SessionScreen() {
pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null
pendingActiveTerminalHandleRef.current = handle
activeSessionTabTypeRef.current = 'terminal'
+ defaultTerminalHandlesToLiveInput([handle])
setActiveSessionTabId(matchingTab?.id ?? null)
const prev = activeHandleRef.current
activeHandleRef.current = handle
@@ -2811,7 +2880,14 @@ export default function SessionScreen() {
}
}
},
- [client, sessionTabs, subscribeToTerminal, unsubscribeTerminal, worktreeId]
+ [
+ client,
+ defaultTerminalHandlesToLiveInput,
+ sessionTabs,
+ subscribeToTerminal,
+ unsubscribeTerminal,
+ worktreeId
+ ]
)
const switchSessionTab = useCallback(
@@ -3171,6 +3247,7 @@ export default function SessionScreen() {
} else {
next.delete(activeHandle)
}
+ liveInputTerminalHandlesRef.current = next
return next
})
setLiveInputCapture('')
@@ -3814,6 +3891,7 @@ export default function SessionScreen() {
})
if (typeof created.terminal === 'string') {
const createdHandle = created.terminal
+ defaultTerminalHandlesToLiveInput([createdHandle])
activeHandleRef.current = createdHandle
setActiveHandle(createdHandle)
setTerminals((prev) => {
@@ -4057,6 +4135,7 @@ export default function SessionScreen() {
unsubscribeTerminal(target.handle)
terminalRefs.current.delete(target.handle)
initializedHandlesRef.current.delete(target.handle)
+ clearTerminalLiveInputDefault(target.handle)
const next = terminals.filter((terminal) => terminal.handle !== target.handle)
setTerminals(next)
terminalsRef.current = next
@@ -4089,6 +4168,7 @@ export default function SessionScreen() {
unsubscribeTerminal(tab.terminal)
terminalRefs.current.delete(tab.terminal)
initializedHandlesRef.current.delete(tab.terminal)
+ clearTerminalLiveInputDefault(tab.terminal)
}
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
// Why: tombstone the closed tab and rely on the subscription/poll
diff --git a/mobile/mobile-terminal-direct-input-default.md b/mobile/mobile-terminal-direct-input-default.md
new file mode 100644
index 00000000000..009d4653063
--- /dev/null
+++ b/mobile/mobile-terminal-direct-input-default.md
@@ -0,0 +1,83 @@
+# Mobile Terminal Direct Input Default
+
+## Context
+
+Orca Mobile currently has two terminal input modes:
+
+- Buffered command input: a visible command text field sends its content with Enter.
+- Direct terminal input: a hidden capture field forwards keyboard bytes directly to the PTY.
+
+Buffered input is safer for composing a full shell command, but it is awkward for terminal-native
+flows: shells, TUIs, REPLs, editors, prompts, and remote SSH sessions all expect keystrokes to land
+immediately. The mobile terminal already supports direct input via a per-terminal toggle; this
+change makes direct input the default mode when a terminal is first seen on mobile.
+
+## Goals
+
+- Make first-seen mobile terminal tabs start in direct terminal input mode.
+- Keep the existing accessory toggle so users can switch an individual terminal back to buffered
+ command input.
+- Preserve that manual opt-out while the terminal remains open and the session tab list refreshes.
+- Keep behavior local to the mobile client. Do not add host/runtime state or desktop-visible
+ preferences for this small default change.
+- Avoid opening the keyboard automatically just because a terminal becomes active. Tapping the
+ terminal should focus the direct input capture, matching the current live-input interaction model.
+
+## Non-goals
+
+- Removing buffered command input.
+- Changing `terminal.send`, mobile subscription, or PTY sizing semantics.
+- Persisting a user preference across app launches.
+- Changing accessory keys, dictation, paste, terminal gesture input, or mouse-aware TUI routing.
+
+## Design
+
+The mobile session screen keeps `liveInputTerminalHandles`, a set of terminal handles whose input
+bar is in direct mode. Today the set starts empty, so every terminal defaults to the buffered command
+box.
+
+The new behavior adds a companion "default already applied" set. When the mobile client discovers
+terminal handles through session tab snapshots, `terminal.list`, or local terminal creation, it adds
+only never-before-defaulted handles to `liveInputTerminalHandles`. If a user toggles a handle back to
+buffered input, the handle stays in the defaulted set, so future tab refreshes do not flip it back to
+direct input.
+
+Handle cleanup uses `terminal.list` as the terminal lifetime signal. Session tab snapshots can lag
+locally created or recently closed terminal tabs, so they should default never-before-seen handles but
+should not prune the live/defaulted sets.
+
+This keeps the default one-shot per handle:
+
+1. New handle appears.
+2. Mobile marks it direct input by default.
+3. User can toggle it to buffered input.
+4. Snapshot/list refreshes preserve the user's choice.
+5. Worktree route reset clears the default tracking for the next session scope.
+
+## UI Behavior
+
+When a terminal is in direct mode, the existing live input bar remains:
+
+- The accessory direct-input icon is active.
+- The bar says keyboard input goes directly to the terminal.
+- Tapping the terminal focuses the hidden capture input.
+- The buffered command box remains available by pressing the same mode toggle.
+
+When a terminal is in buffered mode, the existing command field remains unchanged.
+
+## SSH And Provider Notes
+
+SSH sessions are a primary reason for this default. Direct mode avoids local composition assumptions
+and sends the same PTY bytes regardless of whether the shell is local or remote. The change does not
+touch source-control provider behavior.
+
+## Validation
+
+- Unit test the one-shot default merge:
+ - first-seen handles are enabled and marked defaulted;
+ - an already defaulted handle is not re-enabled after manual opt-out;
+ - newly discovered handles are still enabled.
+- Unit test stale-handle pruning from the terminal lifetime list.
+- Run focused mobile terminal tests.
+- Launch Orca Mobile in the iOS simulator, reach the session terminal screen, and capture a
+ screenshot showing the direct-input bar as the default terminal input surface.
diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts
index 3b60155b86f..d5eb9f5d930 100644
--- a/mobile/src/terminal/terminal-live-input.test.ts
+++ b/mobile/src/terminal/terminal-live-input.test.ts
@@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIVE_INPUT_MAX_BYTES,
clearTerminalLiveInputFocusTimer,
+ defaultTerminalLiveInputHandles,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
+ pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus,
type TerminalLiveInputFocusTimerRef
} from './terminal-live-input'
@@ -34,6 +36,57 @@ describe('terminal live input', () => {
).toBe(false)
})
+ it('defaults first-seen terminal handles to live input once', () => {
+ const firstPass = defaultTerminalLiveInputHandles(new Set(), new Set(), ['pty-1'])
+
+ expect(firstPass.changed).toBe(true)
+ expect([...firstPass.enabledHandles]).toEqual(['pty-1'])
+ expect([...firstPass.defaultedHandles]).toEqual(['pty-1'])
+
+ const manuallyDisabled = new Set()
+ const secondPass = defaultTerminalLiveInputHandles(
+ manuallyDisabled,
+ firstPass.defaultedHandles,
+ ['pty-1', 'pty-2']
+ )
+
+ expect(secondPass.changed).toBe(true)
+ expect([...secondPass.enabledHandles]).toEqual(['pty-2'])
+ expect([...secondPass.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
+ })
+
+ it('does not allocate new live input sets when no handles need defaults', () => {
+ const enabled = new Set(['pty-1'])
+ const defaulted = new Set(['pty-1'])
+ const result = defaultTerminalLiveInputHandles(enabled, defaulted, ['pty-1'])
+
+ expect(result.changed).toBe(false)
+ expect(result.enabledHandles).toBe(enabled)
+ expect(result.defaultedHandles).toBe(defaulted)
+ })
+
+ it('prunes terminal handles that disappear from session snapshots', () => {
+ const result = pruneTerminalLiveInputHandles(
+ new Set(['pty-1', 'pty-stale']),
+ new Set(['pty-1', 'pty-2', 'pty-stale']),
+ new Set(['pty-1', 'pty-2'])
+ )
+
+ expect(result.changed).toBe(true)
+ expect([...result.enabledHandles]).toEqual(['pty-1'])
+ expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
+ })
+
+ it('does not allocate pruned live input sets when every tracked handle is live', () => {
+ const enabled = new Set(['pty-1'])
+ const defaulted = new Set(['pty-1'])
+ const result = pruneTerminalLiveInputHandles(enabled, defaulted, new Set(['pty-1', 'pty-2']))
+
+ expect(result.changed).toBe(false)
+ expect(result.enabledHandles).toBe(enabled)
+ expect(result.defaultedHandles).toBe(defaulted)
+ })
+
it('replaces pending deferred focus work', () => {
vi.useFakeTimers()
const timerRef = createTimerRef()
diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts
index f3e15fea20c..0cf72e8dad0 100644
--- a/mobile/src/terminal/terminal-live-input.ts
+++ b/mobile/src/terminal/terminal-live-input.ts
@@ -6,6 +6,14 @@ export type TerminalLiveInputFocusTimerRef = {
current: ReturnType | null
}
+export type TerminalLiveInputDefaultResult = {
+ enabledHandles: ReadonlySet
+ defaultedHandles: ReadonlySet
+ changed: boolean
+}
+
+export type TerminalLiveInputPruneResult = TerminalLiveInputDefaultResult
+
export function getTerminalLiveSpecialKeyBytes(key: string): string | null {
if (key === 'Backspace') {
return '\x7f'
@@ -20,6 +28,70 @@ export function isTerminalLiveInputWithinByteLimit(
return encoder.encode(text).byteLength <= maxBytes
}
+export function defaultTerminalLiveInputHandles(
+ enabledHandles: ReadonlySet,
+ defaultedHandles: ReadonlySet,
+ terminalHandles: readonly string[]
+): TerminalLiveInputDefaultResult {
+ let nextEnabledHandles: Set | null = null
+ let nextDefaultedHandles: Set | null = null
+
+ for (const handle of terminalHandles) {
+ if (defaultedHandles.has(handle)) {
+ continue
+ }
+ nextEnabledHandles ??= new Set(enabledHandles)
+ nextDefaultedHandles ??= new Set(defaultedHandles)
+ nextEnabledHandles.add(handle)
+ nextDefaultedHandles.add(handle)
+ }
+
+ if (!nextEnabledHandles || !nextDefaultedHandles) {
+ return { enabledHandles, defaultedHandles, changed: false }
+ }
+
+ return {
+ enabledHandles: nextEnabledHandles,
+ defaultedHandles: nextDefaultedHandles,
+ changed: true
+ }
+}
+
+export function pruneTerminalLiveInputHandles(
+ enabledHandles: ReadonlySet,
+ defaultedHandles: ReadonlySet,
+ liveTerminalHandles: ReadonlySet
+): TerminalLiveInputPruneResult {
+ let nextEnabledHandles: Set | null = null
+ let nextDefaultedHandles: Set | null = null
+
+ for (const handle of enabledHandles) {
+ if (liveTerminalHandles.has(handle)) {
+ continue
+ }
+ nextEnabledHandles ??= new Set(enabledHandles)
+ nextEnabledHandles.delete(handle)
+ }
+
+ for (const handle of defaultedHandles) {
+ if (liveTerminalHandles.has(handle)) {
+ continue
+ }
+ nextDefaultedHandles ??= new Set(defaultedHandles)
+ nextDefaultedHandles.delete(handle)
+ }
+
+ if (!nextEnabledHandles && !nextDefaultedHandles) {
+ return { enabledHandles, defaultedHandles, changed: false }
+ }
+
+ return {
+ enabledHandles: nextEnabledHandles ?? enabledHandles,
+ defaultedHandles: nextDefaultedHandles ?? defaultedHandles,
+ changed: true
+ }
+}
+
export function clearTerminalLiveInputFocusTimer(timerRef: TerminalLiveInputFocusTimerRef): void {
if (timerRef.current === null) {
return
From ef292cb0696c2a0d416bb0183555e5a71e88e65b Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 14:06:25 -0700
Subject: [PATCH 16/54] Map mobile special keys to terminal PTY bytes (#6515)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Expand live input mapping to handle escape, tab, delete, insert,
arrows, home, end, page navigation, and F1–F12 keys.
- Safe Map lookup avoids collisions with object prototype methods.
- Enter is excluded to prevent double-sent carriage returns.
- Standardizes byte translation via buildTerminalShortcutKey.
---
.../src/terminal/terminal-live-input.test.ts | 40 ++++++++++-
mobile/src/terminal/terminal-live-input.ts | 67 ++++++++++++++++++-
2 files changed, 102 insertions(+), 5 deletions(-)
diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts
index d5eb9f5d930..db3e96236bc 100644
--- a/mobile/src/terminal/terminal-live-input.test.ts
+++ b/mobile/src/terminal/terminal-live-input.test.ts
@@ -19,12 +19,48 @@ describe('terminal live input', () => {
vi.useRealTimers()
})
- it('maps phone keyboard special keys to PTY bytes', () => {
- expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f')
+ it.each([
+ ['Escape', '\x1b'],
+ ['Esc', '\x1b'],
+ ['Tab', '\t'],
+ ['Backspace', '\x7f'],
+ ['Delete', '\x1b[3~'],
+ ['Insert', '\x1b[2~'],
+ ['ArrowUp', '\x1b[A'],
+ ['ArrowDown', '\x1b[B'],
+ ['ArrowLeft', '\x1b[D'],
+ ['ArrowRight', '\x1b[C'],
+ ['Home', '\x1b[H'],
+ ['End', '\x1b[F'],
+ ['PageUp', '\x1b[5~'],
+ ['PageDown', '\x1b[6~'],
+ ['F1', '\x1bOP'],
+ ['F2', '\x1bOQ'],
+ ['F3', '\x1bOR'],
+ ['F4', '\x1bOS'],
+ ['F5', '\x1b[15~'],
+ ['F6', '\x1b[17~'],
+ ['F7', '\x1b[18~'],
+ ['F8', '\x1b[19~'],
+ ['F9', '\x1b[20~'],
+ ['F10', '\x1b[21~'],
+ ['F11', '\x1b[23~'],
+ ['F12', '\x1b[24~']
+ ])('maps %s to terminal PTY bytes', (key, bytes) => {
+ expect(getTerminalLiveSpecialKeyBytes(key)).toBe(bytes)
+ })
+
+ it('leaves submitted or printable keys on their existing input paths', () => {
expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull()
expect(getTerminalLiveSpecialKeyBytes('a')).toBeNull()
})
+ it('ignores object prototype names from native key events', () => {
+ expect(getTerminalLiveSpecialKeyBytes('constructor')).toBeNull()
+ expect(getTerminalLiveSpecialKeyBytes('toString')).toBeNull()
+ expect(getTerminalLiveSpecialKeyBytes('hasOwnProperty')).toBeNull()
+ })
+
it('enforces the paste-sized byte budget', () => {
expect(isTerminalLiveInputWithinByteLimit('hello')).toBe(true)
expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES))).toBe(true)
diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts
index 0cf72e8dad0..dd5f8e6a709 100644
--- a/mobile/src/terminal/terminal-live-input.ts
+++ b/mobile/src/terminal/terminal-live-input.ts
@@ -1,7 +1,67 @@
+import { buildTerminalShortcutKey } from './terminal-accessory-keys'
+
const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024
const encoder = new TextEncoder()
+type TerminalLiveSpecialKeyId =
+ | 'arrowDown'
+ | 'arrowLeft'
+ | 'arrowRight'
+ | 'arrowUp'
+ | 'backspace'
+ | 'delete'
+ | 'end'
+ | 'escape'
+ | 'f1'
+ | 'f2'
+ | 'f3'
+ | 'f4'
+ | 'f5'
+ | 'f6'
+ | 'f7'
+ | 'f8'
+ | 'f9'
+ | 'f10'
+ | 'f11'
+ | 'f12'
+ | 'home'
+ | 'insert'
+ | 'pageDown'
+ | 'pageUp'
+ | 'tab'
+
+// Why: Enter stays on onSubmitEditing; mapping it here can double-send carriage
+// returns when native TextInput emits both submit and key events.
+const TERMINAL_LIVE_SPECIAL_KEY_IDS = new Map([
+ ['Escape', 'escape'],
+ ['Esc', 'escape'],
+ ['Tab', 'tab'],
+ ['Backspace', 'backspace'],
+ ['Delete', 'delete'],
+ ['Insert', 'insert'],
+ ['ArrowUp', 'arrowUp'],
+ ['ArrowDown', 'arrowDown'],
+ ['ArrowLeft', 'arrowLeft'],
+ ['ArrowRight', 'arrowRight'],
+ ['Home', 'home'],
+ ['End', 'end'],
+ ['PageUp', 'pageUp'],
+ ['PageDown', 'pageDown'],
+ ['F1', 'f1'],
+ ['F2', 'f2'],
+ ['F3', 'f3'],
+ ['F4', 'f4'],
+ ['F5', 'f5'],
+ ['F6', 'f6'],
+ ['F7', 'f7'],
+ ['F8', 'f8'],
+ ['F9', 'f9'],
+ ['F10', 'f10'],
+ ['F11', 'f11'],
+ ['F12', 'f12']
+])
+
export type TerminalLiveInputFocusTimerRef = {
current: ReturnType | null
}
@@ -15,10 +75,11 @@ export type TerminalLiveInputDefaultResult = {
export type TerminalLiveInputPruneResult = TerminalLiveInputDefaultResult
export function getTerminalLiveSpecialKeyBytes(key: string): string | null {
- if (key === 'Backspace') {
- return '\x7f'
+ const shortcutKey = TERMINAL_LIVE_SPECIAL_KEY_IDS.get(key)
+ if (!shortcutKey) {
+ return null
}
- return null
+ return buildTerminalShortcutKey({ key: shortcutKey, modifiers: [] })?.bytes ?? null
}
export function isTerminalLiveInputWithinByteLimit(
From a94d692cd549e59a3e47d0a45e8229b12d8a2388 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 14:40:33 -0700
Subject: [PATCH 17/54] Update quick command button to use plus icon and
clearer label (#6517)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the play icon with a plus icon and update the label from
"Command" to "Add Quick Command…" (including localizations).
This provides clearer visual and textual cues that the action creates
a new command rather than running an existing one.
---
.../src/components/tab-bar/TabBarQuickCommandsButton.tsx | 8 ++++----
.../src/components/tab-bar/TabBarQuickCommandsMenu.tsx | 6 +++---
src/renderer/src/i18n/locales/en.json | 2 +-
src/renderer/src/i18n/locales/es.json | 2 +-
src/renderer/src/i18n/locales/ja.json | 2 +-
src/renderer/src/i18n/locales/ko.json | 2 +-
src/renderer/src/i18n/locales/zh.json | 2 +-
7 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx
index 389280cad53..b7c921962a7 100644
--- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
-import { Play } from 'lucide-react'
+import { Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
@@ -136,7 +136,7 @@ export function TabBarQuickCommandsButton({
return null
}
- // Empty state: single "+ Command" button that opens the dialog directly.
+ // Empty state: single add button that opens the dialog directly.
if (!hasAnyCommands) {
return (
<>
@@ -151,11 +151,11 @@ export function TabBarQuickCommandsButton({
'Add quick command'
)}
>
-
+
{translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831',
- 'Command'
+ 'Add Quick Command…'
)}
diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx
index 77b4173cb61..7605861bb33 100644
--- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ChevronDown, Pencil, Play, Trash2 } from 'lucide-react'
+import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react'
import {
Command,
CommandEmpty,
@@ -373,10 +373,10 @@ export function TabBarQuickCommandsMenu({
}}
className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
-
+
{translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831',
- 'Command'
+ 'Add Quick Command…'
)}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 86fadeaa74a..61e822e0d7f 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -2601,7 +2601,7 @@
"39676a184c": "Open any file, URL, agent, ..."
},
"TabBarQuickCommandsButton": {
- "a2c7a33831": "Command",
+ "a2c7a33831": "Add Quick Command…",
"20bbd75896": "No commands",
"b82e237a4b": "More quick commands",
"85482c57bc": "Run quick command",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index b3e36b78356..d52d7bbb308 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -2601,7 +2601,7 @@
"39676a184c": "Abra cualquier archivo, URL, agente, ..."
},
"TabBarQuickCommandsButton": {
- "a2c7a33831": "Comando",
+ "a2c7a33831": "Agregar comando rápido…",
"20bbd75896": "Sin comandos",
"b82e237a4b": "Comandos más rápidos",
"85482c57bc": "Ejecutar comando rápido",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 90ae3692240..c6de483b70f 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -2601,7 +2601,7 @@
"39676a184c": "任意のファイル、URL、agent などを開きます..."
},
"TabBarQuickCommandsButton": {
- "a2c7a33831": "コマンド",
+ "a2c7a33831": "クイックコマンドを追加…",
"20bbd75896": "コマンドはありません",
"b82e237a4b": "よりクイックなコマンド",
"85482c57bc": "クイックコマンドを実行する",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index e4b35727536..2c7e5beb47e 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -2601,7 +2601,7 @@
"39676a184c": "모든 파일, URL, agent를 엽니다..."
},
"TabBarQuickCommandsButton": {
- "a2c7a33831": "명령",
+ "a2c7a33831": "빠른 명령 추가...",
"20bbd75896": "명령 없음",
"b82e237a4b": "빠른 명령 더 보기",
"85482c57bc": "빠른 명령 실행",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 0c7560e9790..c95ce8fc541 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -2601,7 +2601,7 @@
"39676a184c": "打开任何文件、URL、智能体..."
},
"TabBarQuickCommandsButton": {
- "a2c7a33831": "命令",
+ "a2c7a33831": "添加快速命令...",
"20bbd75896": "无命令",
"b82e237a4b": "更快捷的命令",
"85482c57bc": "运行快速命令",
From a0e7004059dcf6b55bdf642015c73245d19498aa Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 21:55:54 +0000
Subject: [PATCH 18/54] release: v1.4.104-rc.0
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 20ed5581e8e..5b750629a19 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.103",
+ "version": "1.4.104-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From 70d49af5cd7386ad8f44ebda888e2a8291e74c96 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 22:27:08 +0000
Subject: [PATCH 19/54] release: v1.4.104-rc.1 [rc-slot:2026-06-27-15]
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 5b750629a19..9dd5552400b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.104-rc.0",
+ "version": "1.4.104-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From 32542a8e93782c0f7ba7a516b4aca7c87c78dccb Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 15:27:55 -0700
Subject: [PATCH 20/54] Pass PTY ID directly to terminal restore to prevent
race conditions (#6519)
* Pass ptyId directly to restorePaneTerminalFit to avoid race conditions
Avoid re-reading the pane transport after a mobile disconnect race,
which could otherwise restore a recycled pane instead. Passing the PTY
ID directly ensures the correct terminal is restored.
* Prevent reclaiming terminal if PTY ID does not match current pane
Avoid restoring a terminal with a stale PTY ID if a different terminal
has already reused the pane slot. Trigger a state override tick to make
stale portals disappear instead.
---
.../src/components/terminal-pane/TerminalPane.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index 27615fcedd5..b8e8cf1ab84 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -2264,14 +2264,17 @@ export default function TerminalPane({
}, [])
const restorePaneTerminalFit = useCallback(
- async (pane: ManagedPane): Promise => {
+ async (pane: ManagedPane, ptyId: string): Promise => {
// Why: local and remote runtime PTYs use different transports, but the
// desktop reclaim button should have one visible recovery behavior.
- const id = paneTransportsRef.current.get(pane.id)?.getPtyId()
- if (!id) {
+ // Why: the banner was rendered for this PTY; stale portals must disappear
+ // before they can reclaim a different terminal that reused this pane slot.
+ const currentPtyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
+ if (currentPtyId !== ptyId) {
+ setOverrideTick((n) => n + 1)
return
}
- const restored = await restoreTerminalFitToDesktop(id, settingsRef.current ?? undefined)
+ const restored = await restoreTerminalFitToDesktop(ptyId, settingsRef.current ?? undefined)
if (restored) {
scheduleRestoredTerminalRefit()
// Why: after the overlay unmounts, focus would otherwise stay on the
@@ -2678,7 +2681,7 @@ export default function TerminalPane({
driver={driver}
hasFitOverride={hasFitOverride}
rootClassName="mobile-driver-banner"
- onAction={() => restorePaneTerminalFit(pane)}
+ onAction={() => restorePaneTerminalFit(pane, ptyId)}
onAllAction={() => restoreAllTerminalFits(pane)}
/>,
pane.container,
From 14f0096077e5db03424271b08c7dfcb8e9580b43 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 15:56:21 -0700
Subject: [PATCH 21/54] Omit unsupported combined diff tabs from mobile session
snapshots (#6520)
Combined diff tabs (such as branch, commit, or uncommitted changes)
use display labels as relative paths and require the desktop-only
combined renderer. Excluding these tabs from the mobile snapshots
prevents mobile clients from incorrectly attempting to read them
via files.read.
---
.../src/runtime/sync-runtime-graph.test.ts | 79 +++++++++++++++++++
.../src/runtime/sync-runtime-graph.ts | 33 ++++++--
2 files changed, 107 insertions(+), 5 deletions(-)
diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts
index 08509c65b5d..2de176df7b9 100644
--- a/src/renderer/src/runtime/sync-runtime-graph.test.ts
+++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts
@@ -725,6 +725,85 @@ describe('buildMobileSessionTabSnapshots', () => {
expect(tab).not.toHaveProperty('diffSource')
})
+ it.each([
+ ['combined-branch', 'wt-1::all-diffs::branch::main', 'Branch Changes (main)'],
+ ['combined-commit', 'wt-1::all-diffs::commit::abc123', 'Commit abc123'],
+ ['combined-all', 'wt-1::all-diffs::uncommitted', 'All Changes'],
+ ['combined-uncommitted', 'wt-1::all-diffs::uncommitted::unstaged', 'Changes']
+ ] as const)('omits unsupported %s diff tabs from mobile file snapshots', (source, id, label) => {
+ const state = makeState({
+ browserTabsByWorktree: {},
+ tabBarOrderByWorktree: { 'wt-1': [id] },
+ activeFileId: id,
+ activeFileIdByWorktree: { 'wt-1': id },
+ activeTabType: 'editor',
+ activeTabTypeByWorktree: { 'wt-1': 'editor' },
+ openFiles: [
+ {
+ id,
+ filePath: '/repo',
+ relativePath: label,
+ worktreeId: 'wt-1',
+ language: 'plaintext',
+ mode: 'diff',
+ diffSource: source,
+ isDirty: false
+ }
+ ]
+ })
+
+ const snapshot = buildMobileSessionTabSnapshots(state)[0]
+
+ expect(snapshot?.tabs).toEqual([])
+ expect(snapshot?.activeTabId).toBeNull()
+ expect(snapshot?.activeTabType).toBeNull()
+ })
+
+ it('does not recover unsupported combined diff tabs through split-group fallback', () => {
+ const combinedId = 'wt-1::all-diffs::branch::main'
+ const state = makeState({
+ activeGroupIdByWorktree: { 'wt-1': 'group-right' },
+ groupsByWorktree: {
+ 'wt-1': [
+ {
+ id: 'group-right',
+ activeTabId: 'combined-tab-right',
+ tabOrder: [],
+ recentTabIds: []
+ }
+ ]
+ } as unknown as AppState['groupsByWorktree'],
+ unifiedTabsByWorktree: {
+ 'wt-1': [
+ {
+ id: 'combined-tab-right',
+ groupId: 'group-right',
+ contentType: 'diff',
+ entityId: combinedId,
+ title: 'Branch Changes (main)'
+ }
+ ]
+ } as unknown as AppState['unifiedTabsByWorktree'],
+ openFiles: [
+ {
+ id: combinedId,
+ filePath: '/repo',
+ relativePath: 'Branch Changes (main)',
+ worktreeId: 'wt-1',
+ language: 'plaintext',
+ mode: 'diff',
+ diffSource: 'combined-branch',
+ isDirty: false
+ }
+ ]
+ })
+
+ const snapshot = buildMobileSessionTabSnapshots(state)[0]
+
+ expect(snapshot?.tabs).toEqual([])
+ expect(snapshot?.tabGroups).toBeUndefined()
+ })
+
it('publishes a missing non-markdown editor with its unified tab id and split group', () => {
const fileId = '/repo/src/app.ts'
const state = makeState({
diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts
index f8ebf0cbb2c..27f3113316b 100644
--- a/src/renderer/src/runtime/sync-runtime-graph.ts
+++ b/src/renderer/src/runtime/sync-runtime-graph.ts
@@ -700,7 +700,11 @@ export function buildMobileSessionTabSnapshots(
const unifiedTabByIdForWorktree = new Map(
(state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
- const editorIds = openFileIndexes.idsByWorktree.get(worktreeId) ?? []
+ const openFilesForWorktree = openFileIndexes.byWorktreeAndId.get(worktreeId)
+ const editorIds = (openFileIndexes.idsByWorktree.get(worktreeId) ?? []).filter((fileId) => {
+ const file = openFilesForWorktree?.get(fileId)
+ return file ? isMobilePublishableOpenFile(file) : false
+ })
const publishableTerminalIds = [...terminalTabByIdForWorktree.values()]
.filter((terminal) => !isWebOnlyMirroredTerminalTab(state, terminal))
.map((terminal) => terminal.id)
@@ -732,8 +736,8 @@ export function buildMobileSessionTabSnapshots(
)
)
} else if (item.type === 'editor') {
- const file = openFileIndexes.byWorktreeAndId.get(worktreeId)?.get(item.id)
- if (!file) {
+ const file = openFilesForWorktree?.get(item.id)
+ if (!file || !isMobilePublishableOpenFile(file)) {
continue
}
const markdown = buildMobileMarkdownTab(
@@ -774,7 +778,6 @@ export function buildMobileSessionTabSnapshots(
// Why: split-group projection can miss plain editor files during hydration.
// Publish the missing file so paired mobile/web clients still mirror it.
const fallbackEditorTabs: FallbackEditorTabTarget[] = []
- const openFilesForWorktree = openFileIndexes.byWorktreeAndId.get(worktreeId)
if (openFilesForWorktree) {
const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId)
const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId))
@@ -783,7 +786,7 @@ export function buildMobileSessionTabSnapshots(
continue
}
const file = openFilesForWorktree.get(unifiedTab.entityId)
- if (!file) {
+ if (!file || !isMobilePublishableOpenFile(file)) {
continue
}
const markdown = buildMobileMarkdownTab(
@@ -802,6 +805,9 @@ export function buildMobileSessionTabSnapshots(
emittedEditorTabIds.add(unifiedTab.id)
}
for (const file of openFilesForWorktree.values()) {
+ if (!isMobilePublishableOpenFile(file)) {
+ continue
+ }
if (emittedEditorFileIds.has(file.id)) {
continue
}
@@ -1435,6 +1441,23 @@ function isMobileFileDiffSource(
return diffSource === 'staged' || diffSource === 'unstaged'
}
+function isMobileUnsupportedCombinedDiffSource(
+ diffSource: AppState['openFiles'][number]['diffSource']
+): boolean {
+ return (
+ diffSource === 'combined-all' ||
+ diffSource === 'combined-uncommitted' ||
+ diffSource === 'combined-branch' ||
+ diffSource === 'combined-commit'
+ )
+}
+
+function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean {
+ // Why: combined diff tabs use display labels as relative paths and require
+ // the desktop combined renderer; mobile would otherwise try files.read.
+ return !isMobileUnsupportedCombinedDiffSource(file.diffSource)
+}
+
function buildMobileBrowserTab(
state: AppState,
workspace: NonNullable[number],
From 76331ff5977f434bba019f9f07c108de3240d994 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 18:34:58 -0700
Subject: [PATCH 22/54] Create pr not working on mobile (#6523)
* Implement automated git preparation workflow for mobile PR creation
Introduce a structured hosted review intent preparation workflow to handle
staging, AI commit message generation, committing, and pushing changes
automatically before displaying the pull request composer on mobile.
- Map creation block reasons to descriptive user-facing validation errors
(e.g., dirty working tree, default branch, detached head) to match desktop.
- Decouple hosted-review business logic into a dedicated service helper.
- Update source control runner hooks to handle the new preparation flow.
* Refactor mobile PR creation to run intent and open URL directly
Remove MobilePrComposeSheet and the local compose form, moving instead
to a direct PR creation workflow that matches the desktop experience.
- Add runMobileHostedReviewCreateIntent to handle the full prepare,
push, and create sequence.
- Replace useMobileOpenPrSheetRunner with useMobileCreatePrRunner to
trigger the creation workflow and directly open the created PR URL.
- Simplify state management by removing showPrSheet, prPrefill, and
associated local compose sheets.
* Propagate git status and commit state on PR creation failure
Update `MobileHostedReviewCreateIntentOutcome` and the local change
commit helper to include optional `committed` and `status` fields in
their failure results.
This ensures that if PR preparation fails, callers still receive the
current repository status and know if their local changes have already
been committed.
* Add tests for mobile hosted review creation flow
Introduce unit tests for runMobileHostedReviewCreateIntent to verify
different scenarios of creating a hosted review on mobile, including:
- Successful flow including staging, committing, pushing, and creating
- Eligibility block handling (e.g., authentication requirements)
- Error reporting when creation fails after an automatic commit
* Block mobile PR creation on unresolved conflicts and refresh status
Prevent creating a hosted review on mobile when there are unresolved
merge conflicts. Also, return the latest git status on failures and
reload it in the UI to keep the source control screen in sync.
* Prefer fetched PR head SHA over cached status SHA for PR checks
On mobile, a create command can commit before opening the review,
meaning the fetched PR's head SHA is fresher than the route's cached
status SHA. Prioritizing the fetched PR head SHA ensures we fetch checks
for the most up-to-date commit.
* Fix mobile PR creation errors and validate branch presence
- Reject branch matches when the status branch is null or missing to
prevent PR creation when the branch is lost.
- Display actual PR creation errors in the sidebar instead of silently
ignoring them on failure.
- Trim leading and trailing whitespace from the base branch reference
before persisting the worktree link.
---
mobile/app/h/[hostId]/pr/[worktreeId].tsx | 3 +-
.../app/h/[hostId]/session/[worktreeId].tsx | 2 +
.../components/MobileDiffReviewScreenView.tsx | 3 +
mobile/src/components/MobilePRSidebar.tsx | 20 +-
.../pr-sidebar/MobilePrComposeForm.tsx | 32 +-
.../pr-sidebar/MobilePrViewPanel.tsx | 4 +
.../pr-sidebar/PrSidebarCreateEmptyState.tsx | 73 ++--
mobile/src/session/SessionDockColumn.tsx | 6 +
mobile/src/session/mobile-pr-sidebar-state.ts | 4 +-
.../use-mobile-pr-branch-context.test.ts | 3 +-
.../session/use-mobile-pr-branch-context.ts | 12 +-
.../use-mobile-pr-sidebar-controller.test.ts | 4 +-
.../MobileSourceControlModals.tsx | 26 +-
.../MobileSourceControlPanel.tsx | 6 +-
...hosted-review-create-intent-runner.test.ts | 154 +++++++++
...bile-hosted-review-create-intent-runner.ts | 86 +++++
...mobile-hosted-review-create-intent.test.ts | 210 ++++++++++++
.../mobile-hosted-review-create-intent.ts | 316 ++++++++++++++++++
.../mobile-hosted-review-service.ts | 267 +++++++++++++++
.../mobile-open-pr-prefill.test.ts | 22 +-
.../source-control/mobile-open-pr-prefill.ts | 30 +-
.../mobile-pr-create-flow.test.ts | 280 ++++++++++++++++
.../source-control/mobile-pr-create.test.ts | 219 ++++++------
mobile/src/source-control/mobile-pr-create.ts | 235 ++++---------
.../use-mobile-create-pr-runner.ts | 108 ++++++
.../use-mobile-source-control-action-sheet.ts | 11 +-
.../use-mobile-source-control-runners.ts | 68 ++--
.../use-mobile-source-control-state.ts | 10 +-
28 files changed, 1761 insertions(+), 453 deletions(-)
create mode 100644 mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts
create mode 100644 mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts
create mode 100644 mobile/src/source-control/mobile-hosted-review-create-intent.test.ts
create mode 100644 mobile/src/source-control/mobile-hosted-review-create-intent.ts
create mode 100644 mobile/src/source-control/mobile-hosted-review-service.ts
create mode 100644 mobile/src/source-control/mobile-pr-create-flow.test.ts
create mode 100644 mobile/src/source-control/use-mobile-create-pr-runner.ts
diff --git a/mobile/app/h/[hostId]/pr/[worktreeId].tsx b/mobile/app/h/[hostId]/pr/[worktreeId].tsx
index 6db023bee08..2e0530b0f4b 100644
--- a/mobile/app/h/[hostId]/pr/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/pr/[worktreeId].tsx
@@ -8,7 +8,7 @@ import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobileP
export default function MobilePrViewScreen() {
const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>()
const { client, state: connState } = useHostClient(hostId)
- const { branch, headSha, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({
+ const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({
client,
connState,
worktreeId
@@ -21,6 +21,7 @@ export default function MobilePrViewScreen() {
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
+ gitStatus={status}
isGithubRepo={isGithubRepo}
branchContextLoaded={loaded && repoLoaded}
embedded={false}
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 8dbfb932c24..5175ba90c98 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -897,6 +897,7 @@ export default function SessionScreen() {
const {
branch: prBranch,
headSha: prHeadSha,
+ status: prStatus,
isGithubRepo: prIsGithubRepo,
repoLoaded: prRepoContextLoaded,
loaded: prContextLoaded
@@ -5126,6 +5127,7 @@ export default function SessionScreen() {
connState={connState}
branch={prBranch}
headSha={prHeadSha}
+ gitStatus={prStatus}
isGithubRepo={prIsGithubRepo}
branchContextLoaded={prContextLoaded && prRepoContextLoaded}
availableWidth={sessionContentRowWidth}
diff --git a/mobile/src/components/MobileDiffReviewScreenView.tsx b/mobile/src/components/MobileDiffReviewScreenView.tsx
index b63cd3d6b9e..c472d1482db 100644
--- a/mobile/src/components/MobileDiffReviewScreenView.tsx
+++ b/mobile/src/components/MobileDiffReviewScreenView.tsx
@@ -32,6 +32,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
// Inline-dock the sidebar only when wide and the repo is GitHub; otherwise it
// lives in the RightDrawer overlay toggled by showPRSidebar.
const showInlineDock = presentationMode === 'inline' && controller.prSidebarIsGithubRepo
+ const gitStatus = controller.screenState.kind === 'ready' ? controller.screenState.status : null
// The docked sidebar has no trigger to tap, so load its PR data once it becomes
// visible (the overlay loads on trigger press instead).
@@ -120,6 +121,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
connState={controller.connState}
worktreeId={controller.worktreeId}
gitBranch={controller.prSidebarBranch}
+ gitStatus={gitStatus}
headSha={controller.prSidebarHeadSha}
bottomInset={insets.bottom}
/>
@@ -140,6 +142,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
connState={controller.connState}
worktreeId={controller.worktreeId}
gitBranch={controller.prSidebarBranch}
+ gitStatus={gitStatus}
headSha={controller.prSidebarHeadSha}
/>
diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx
index 2dc90611a98..71cc33d5afe 100644
--- a/mobile/src/components/MobilePRSidebar.tsx
+++ b/mobile/src/components/MobilePRSidebar.tsx
@@ -17,6 +17,7 @@ import { useMobilePrAiTriage, type MobilePrAiTriage } from '../session/use-mobil
import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
import { mobilePrSidebarStyles as styles } from './pr-sidebar/mobile-pr-sidebar-styles'
+import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import { PRSidebarHeader } from './pr-sidebar/PRSidebarHeader'
import { PRConflictingFilesSection } from './pr-sidebar/PRConflictingFilesSection'
import { PRActionsSection } from './pr-sidebar/PRActionsSection'
@@ -34,17 +35,13 @@ type Props = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
- // Current git branch — feeds the create-PR prefill in the no-PR empty state.
gitBranch: string | null
+ gitStatus: MobileGitStatusResult | null
headSha: string | null
- // Applied by the docked column so content clears the home indicator (the screen's
- // SafeAreaView is edges={['top']} only).
bottomInset?: number
}
-// The shell switches on the controller's state machine and renders the sections
-// (header/actions/reviewers/checks). The mutation hook is created here (hooks must
-// run unconditionally) and only fires once a PR is ready. Style only from mobile-theme.
+// Mutation hooks run unconditionally here and gate internally until a PR is ready.
export function MobilePRSidebar({
state,
onRetry,
@@ -53,6 +50,7 @@ export function MobilePRSidebar({
connState,
worktreeId,
gitBranch,
+ gitStatus,
headSha,
bottomInset = 0
}: Props) {
@@ -74,8 +72,6 @@ export function MobilePRSidebar({
prRepo,
refetch
})
- // Separate hook for the interactive comment timeline (reply/resolve/add). Like
- // useMobilePrActions it must run unconditionally; it gates internally on a client.
const commentActions = useMobilePrCommentActions({
client,
connState,
@@ -84,8 +80,6 @@ export function MobilePRSidebar({
prRepo,
refetch
})
- // Inline title-edit action. Like the others it must run unconditionally and gates
- // internally on a client; refetches authoritative PR data after a successful edit.
const titleAction = useMobilePrTitleAction({
client,
connState,
@@ -94,8 +88,6 @@ export function MobilePRSidebar({
prRepo,
refetch
})
- // AI triage (Fix checks / Resolve conflicts). Like the other hooks it must run
- // unconditionally; it gates internally on a connected client.
const triage = useMobilePrAiTriage({ client, connState, worktreeId })
return (
@@ -113,6 +105,7 @@ export function MobilePRSidebar({
client={client}
worktreeId={worktreeId}
gitBranch={gitBranch}
+ gitStatus={gitStatus}
actions={actions}
commentActions={commentActions}
titleAction={titleAction}
@@ -130,6 +123,7 @@ function PrSidebarContent({
client,
worktreeId,
gitBranch,
+ gitStatus,
actions,
commentActions,
titleAction,
@@ -142,6 +136,7 @@ function PrSidebarContent({
client: RpcClient | null
worktreeId: string
gitBranch: string | null
+ gitStatus: MobileGitStatusResult | null
actions: MobilePrActions
commentActions: MobilePrCommentActions
titleAction: MobilePrTitleAction
@@ -194,6 +189,7 @@ function PrSidebarContent({
client={client}
worktreeId={worktreeId}
gitBranch={gitBranch}
+ gitStatus={gitStatus}
onCreated={refetch}
/>
)
diff --git a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx
index 765a30dba7e..618e399d1a0 100644
--- a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx
+++ b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx
@@ -8,12 +8,16 @@ import {
TriangleAlert,
X
} from 'lucide-react-native'
-import type { HostedReviewProvider } from '../../../../src/shared/hosted-review'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
import type { RpcSuccess } from '../../transport/types'
import { triggerError, triggerSuccess } from '../../platform/haptics'
-import { createMobilePr } from '../../source-control/mobile-pr-create'
+import {
+ createMobilePr,
+ getMobilePrCreateSuccessWarning,
+ shouldPushBeforeMobilePrCreate,
+ type MobilePrPrefill
+} from '../../source-control/mobile-pr-create'
import { hostedReviewCopy } from '../../source-control/hosted-review-copy'
import {
getPrComposeDisabledReason,
@@ -22,12 +26,7 @@ import {
import { MobilePrBasePicker } from '../MobilePrBasePicker'
import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles'
-export type PrComposePrefill = {
- base: string
- title: string
- body: string
- provider: HostedReviewProvider
-}
+export type PrComposePrefill = MobilePrPrefill
type Props = {
client: RpcClient | null
@@ -61,6 +60,7 @@ export function MobilePrComposeForm({
const [generating, setGenerating] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
+ const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill)
const generate = useCallback(async () => {
if (!client || generating) {
@@ -128,13 +128,12 @@ export function MobilePrComposeForm({
...(head ? { head } : {}),
title,
body,
- draft
+ draft,
+ pushBeforeCreate
})
if (outcome.ok) {
triggerSuccess()
- const warning = outcome.linkError
- ? `${copy.titleLabel} created, but Orca could not refresh it yet.`
- : undefined
+ const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider)
if (warning) {
setError(warning)
}
@@ -156,6 +155,7 @@ export function MobilePrComposeForm({
head,
onCreated,
prefill.provider,
+ pushBeforeCreate,
submitting,
title,
worktreeId
@@ -279,7 +279,13 @@ export function MobilePrComposeForm({
)}
- {draft ? `Create draft ${copy.shortLabel}` : `Create ${copy.shortLabel}`}
+ {pushBeforeCreate
+ ? draft
+ ? `Push & create draft ${copy.shortLabel}`
+ : `Push & create ${copy.shortLabel}`
+ : draft
+ ? `Create draft ${copy.shortLabel}`
+ : `Create ${copy.shortLabel}`}
diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
index b733c5f4f13..e1b19263f54 100644
--- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
+++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
@@ -6,6 +6,7 @@ import { ChevronLeft, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
+import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
@@ -15,6 +16,7 @@ type Props = {
worktreeId: string
branch: string | null
headSha: string | null
+ gitStatus: MobileGitStatusResult | null
isGithubRepo?: boolean
branchContextLoaded?: boolean
// Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close
@@ -29,6 +31,7 @@ export function MobilePrViewPanel({
worktreeId,
branch,
headSha,
+ gitStatus,
isGithubRepo = true,
branchContextLoaded = true,
embedded = false,
@@ -79,6 +82,7 @@ export function MobilePrViewPanel({
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
+ gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
/>
diff --git a/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx b/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx
index 3f3b74eeb7a..605eafafaf8 100644
--- a/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx
+++ b/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx
@@ -3,10 +3,11 @@ import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { GitPullRequestArrow, Link2, RefreshCw } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
-import { resolveMobilePrPrefill, type MobilePrPrefill } from '../../source-control/mobile-pr-create'
+import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
+import { mobileHostedReviewCreateIntentProgressMessage } from '../../source-control/mobile-hosted-review-create-intent'
+import { runMobileHostedReviewCreateIntent } from '../../source-control/mobile-hosted-review-create-intent-runner'
import { fetchWorktreeLinkedPR } from '../../source-control/mobile-pr-link'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
-import { MobilePrComposeForm } from './MobilePrComposeForm'
import { MobileLinkPrForm } from './MobileLinkPrForm'
import { prCreateEmptyStateStyles as styles } from './pr-create-empty-state-styles'
@@ -14,17 +15,23 @@ type Props = {
client: RpcClient | null
worktreeId: string
gitBranch: string | null
+ gitStatus: MobileGitStatusResult | null
// Refetches the sidebar after create or an explicit empty-state refresh.
onCreated: () => void
}
-type Mode = 'choose' | 'create' | 'link'
+type Mode = 'choose' | 'link'
// Empty state for a branch with no PR: create a new PR, or link an existing one
// (the no-PR surface is the natural home for linking — desktop's link entry lives
// on its PR card, but on mobile this is where a user lands with nothing linked).
-export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCreated }: Props) {
- const [prefill, setPrefill] = useState(null)
+export function PrSidebarCreateEmptyState({
+ client,
+ worktreeId,
+ gitBranch,
+ gitStatus,
+ onCreated
+}: Props) {
const [mode, setMode] = useState('choose')
const [loading, setLoading] = useState(false)
const [createWarning, setCreateWarning] = useState(null)
@@ -61,22 +68,28 @@ export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCre
setCreateWarning(null)
setLoading(true)
try {
- // Git-status fields are best-effort here (the sidebar has no working-tree
- // state); base/title/body come from host eligibility regardless, and create
- // does the authoritative branch-state validation.
- const resolved = await resolveMobilePrPrefill(client, worktreeId, {
- branch: gitBranch ?? undefined,
- title: gitBranch ?? '',
- hasUncommittedChanges: false,
- hasUpstream: true,
- ahead: 1,
- behind: 0
+ if (!gitBranch) {
+ setCreateWarning('Check out a branch before creating a pull request.')
+ return
+ }
+ // Why: mobile skips the local compose step here and runs the hosted create
+ // flow directly so PR creation matches the automated hosted-review path.
+ const outcome = await runMobileHostedReviewCreateIntent(client, worktreeId, {
+ branch: gitBranch,
+ title: gitBranch,
+ status: gitStatus,
+ onProgress: (progress) =>
+ setCreateWarning(mobileHostedReviewCreateIntentProgressMessage(progress))
})
- setPrefill(resolved)
- setMode('create')
- } catch {
- // Best-effort: if prefill resolution rejects, leave the empty state so the
- // user can retry rather than surfacing an unhandled rejection.
+ if (!outcome.ok) {
+ setCreateWarning(outcome.error)
+ return
+ }
+ setCreateWarning(outcome.warning ?? null)
+ openMobilePrUrl(outcome.url)
+ onCreated()
+ } catch (err) {
+ setCreateWarning(err instanceof Error ? err.message : 'Failed to create pull request.')
} finally {
setLoading(false)
}
@@ -84,26 +97,6 @@ export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCre
const canCreate = !!client && !!gitBranch
- if (mode === 'create' && prefill) {
- return (
-
- setMode('choose')}
- onCreated={(url, warning) => {
- setMode('choose')
- setCreateWarning(warning ?? null)
- openMobilePrUrl(url)
- onCreated()
- }}
- />
-
- )
- }
-
if (mode === 'link') {
return (
diff --git a/mobile/src/session/SessionDockColumn.tsx b/mobile/src/session/SessionDockColumn.tsx
index 9b02b97580a..877973e5d0d 100644
--- a/mobile/src/session/SessionDockColumn.tsx
+++ b/mobile/src/session/SessionDockColumn.tsx
@@ -8,6 +8,7 @@ import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel'
import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles'
import { useMobileDockResize } from './use-mobile-dock-resize'
import type { ActivePanel } from './session-panel-host'
+import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
type Props = {
activePanel: Exclude
@@ -18,6 +19,7 @@ type Props = {
connState: ConnectionState
branch: string | null
headSha: string | null
+ gitStatus: MobileGitStatusResult | null
isGithubRepo: boolean
branchContextLoaded: boolean
availableWidth: number
@@ -40,6 +42,7 @@ export function SessionDockColumn({
connState,
branch,
headSha,
+ gitStatus,
isGithubRepo,
branchContextLoaded,
availableWidth,
@@ -60,6 +63,7 @@ export function SessionDockColumn({
connState={connState}
branch={branch}
headSha={headSha}
+ gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
onRequestClose={onRequestClose}
@@ -79,6 +83,7 @@ const DockPanelContent = memo(function DockPanelContent({
connState,
branch,
headSha,
+ gitStatus,
isGithubRepo,
branchContextLoaded,
onRequestClose
@@ -113,6 +118,7 @@ const DockPanelContent = memo(function DockPanelContent({
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
+ gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
embedded
diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts
index 16b7533f7ba..5f5b679b210 100644
--- a/mobile/src/session/mobile-pr-sidebar-state.ts
+++ b/mobile/src/session/mobile-pr-sidebar-state.ts
@@ -103,7 +103,9 @@ export async function loadPrSidebarData(
const pr = prOutcome.result
const checksOutcome = await deps.fetchPRChecks(args.worktreeId, {
prNumber: pr.number,
- headSha: args.headSha ?? pr.headSha ?? null,
+ // Why: mobile create can commit before opening the review; the fetched PR
+ // head is fresher than the route's cached git.status head.
+ headSha: pr.headSha ?? args.headSha ?? null,
// Prefer the fetched PR's own repo identity so fork PRs key their cached
// checks correctly; fall back to an explicit override then null.
prRepo: pr.prRepo ?? args.prRepo ?? null
diff --git a/mobile/src/session/use-mobile-pr-branch-context.test.ts b/mobile/src/session/use-mobile-pr-branch-context.test.ts
index 4890f887b46..7ac21cf3006 100644
--- a/mobile/src/session/use-mobile-pr-branch-context.test.ts
+++ b/mobile/src/session/use-mobile-pr-branch-context.test.ts
@@ -78,7 +78,7 @@ describe('deriveMobilePrBranchContext', () => {
it('does not throw on null status and null branchCompare', () => {
expect(() => deriveMobilePrBranchContext(null, null)).not.toThrow()
const result = deriveMobilePrBranchContext(null, null)
- expect(result).toEqual({ branch: null, headSha: null })
+ expect(result).toEqual({ branch: null, headSha: null, status: null })
})
})
@@ -109,6 +109,7 @@ describe('loadMobilePrBranchContext', () => {
expect(out).toEqual({
branch: 'feat',
headSha: 'sha-status',
+ status: expect.objectContaining({ branch: 'feat', head: 'sha-status' }),
isGithubRepo: true,
repoLoaded: true,
loaded: true
diff --git a/mobile/src/session/use-mobile-pr-branch-context.ts b/mobile/src/session/use-mobile-pr-branch-context.ts
index b0f4a9baa64..ef2cf1a48d5 100644
--- a/mobile/src/session/use-mobile-pr-branch-context.ts
+++ b/mobile/src/session/use-mobile-pr-branch-context.ts
@@ -10,6 +10,7 @@ import { readMobileBranchCompareResult, readMobileGitStatusResult } from './mobi
export type MobilePrBranchContext = {
branch: string | null
headSha: string | null
+ status: MobileGitStatusResult | null
isGithubRepo: boolean
repoLoaded: boolean
loaded: boolean
@@ -22,10 +23,11 @@ export type MobilePrBranchContext = {
export function deriveMobilePrBranchContext(
status: MobileGitStatusResult | null,
branchCompare: MobileGitBranchCompareResult | null
-): { branch: string | null; headSha: string | null } {
+): { branch: string | null; headSha: string | null; status: MobileGitStatusResult | null } {
return {
branch: status?.branch ?? null,
- headSha: status?.head ?? branchCompare?.summary.headOid ?? null
+ headSha: status?.head ?? branchCompare?.summary.headOid ?? null,
+ status
}
}
@@ -41,6 +43,7 @@ export function useMobilePrBranchContext(input: {
const [context, setContext] = useState({
branch: null,
headSha: null,
+ status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
@@ -54,6 +57,7 @@ export function useMobilePrBranchContext(input: {
setContext({
branch: null,
headSha: null,
+ status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
@@ -63,6 +67,7 @@ export function useMobilePrBranchContext(input: {
setContext({
branch: null,
headSha: null,
+ status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
@@ -108,6 +113,7 @@ export function useMobilePrBranchContext(input: {
...prev,
branch: null,
headSha: null,
+ status: null,
loaded: true
}))
}
@@ -142,7 +148,7 @@ export async function loadMobilePrRepoContext(
export async function loadMobilePrBranchIdentity(
client: RpcClient,
worktreeId: string
-): Promise> {
+): Promise> {
const [status, branchCompare] = await Promise.all([
readGitStatus(client, worktreeId),
// Why: the standalone PR entry point only needs branchCompare as a head-SHA
diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
index 5fc684acfe8..005c4dc0e5b 100644
--- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
+++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
@@ -85,10 +85,10 @@ describe('loadPrSidebarData', () => {
expect(d.fetchWorkItemDetails).not.toHaveBeenCalled()
// forBranch's PR number is threaded into prForBranch as the linked hint.
expect(d.fetchPRForBranch).toHaveBeenCalledWith('w', { branch: 'feat', linkedPRNumber: 7 })
- // headSha forwarded to checks (status SHA wins over pr.headSha).
+ // The fetched PR head wins over the route's cached status SHA.
expect(d.fetchPRChecks).toHaveBeenCalledWith('w', {
prNumber: 7,
- headSha: 'sha-status',
+ headSha: 'sha-pr',
prRepo: null
})
})
diff --git a/mobile/src/source-control/MobileSourceControlModals.tsx b/mobile/src/source-control/MobileSourceControlModals.tsx
index bd47a1901aa..b58d2520723 100644
--- a/mobile/src/source-control/MobileSourceControlModals.tsx
+++ b/mobile/src/source-control/MobileSourceControlModals.tsx
@@ -1,28 +1,23 @@
import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
import { ConfirmModal } from '../components/ConfirmModal'
import { PickerModal } from '../components/PickerModal'
-import { MobilePrComposeSheet, openMobilePrUrl } from '../components/MobilePrComposeSheet'
+import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
import { MobileBranchDiffPreviewDrawer } from './MobileBranchDiffPreviewDrawer'
import type { MobileSourceControlState } from './use-mobile-source-control-state'
type Props = {
state: MobileSourceControlState
- worktreeId: string
actionSheetActions: ActionSheetAction[]
}
-export function MobileSourceControlModals({ state, worktreeId, actionSheetActions }: Props) {
+export function MobileSourceControlModals({ state, actionSheetActions }: Props) {
const {
- client,
branchDiffPreview,
setBranchDiffPreview,
showActionSheet,
setShowActionSheet,
discardTarget,
setDiscardTarget,
- showPrSheet,
- setShowPrSheet,
- prPrefill,
showBranchPicker,
setShowBranchPicker,
localBranches,
@@ -30,9 +25,7 @@ export function MobileSourceControlModals({ state, worktreeId, actionSheetAction
setCreatedPrUrl,
createdPrWarning,
setCreatedPrWarning,
- status,
branchLabel,
- loadStatus,
checkoutBranch,
runGitAction
} = state
@@ -74,21 +67,6 @@ export function MobileSourceControlModals({ state, worktreeId, actionSheetAction
onCancel={() => setDiscardTarget(null)}
/>
- setShowPrSheet(false)}
- onCreated={(url, warning) => {
- setShowPrSheet(false)
- setCreatedPrUrl(url)
- setCreatedPrWarning(warning ?? null)
- void loadStatus({ preserveReadyOnFailure: true, force: true })
- }}
- />
-
)}
-
+
)
}
diff --git a/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts
new file mode 100644
index 00000000000..bf79182beae
--- /dev/null
+++ b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import { runMobileHostedReviewCreateIntent } from './mobile-hosted-review-create-intent-runner'
+
+function ok(result: unknown): RpcSuccess {
+ return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
+}
+
+function fail(message: string): RpcFailure {
+ return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
+}
+
+function status(entries: unknown[], upstreamStatus = { hasUpstream: true, ahead: 0, behind: 0 }) {
+ return {
+ entries,
+ conflictOperation: 'unknown',
+ branch: 'feature/x',
+ head: 'sha',
+ upstreamStatus
+ }
+}
+
+function entry(area: 'unstaged' | 'staged') {
+ return { path: 'a.ts', status: 'modified', area }
+}
+
+function eligibility(overrides: Record) {
+ return {
+ provider: 'github',
+ review: null,
+ defaultBaseRef: 'main',
+ title: 'Ship mobile PR create',
+ body: 'Generated body',
+ ...overrides
+ }
+}
+
+function clientWith(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ return responses.shift() ?? fail(`unexpected ${method}`)
+ })
+ }
+}
+
+describe('runMobileHostedReviewCreateIntent', () => {
+ it('prepares the branch and creates the hosted review in one flow', async () => {
+ const client = clientWith([
+ ok(status([entry('unstaged')])),
+ ok({ success: true }),
+ ok(status([entry('staged')])),
+ ok({ success: true, message: 'Ship mobile PR create' }),
+ ok({ success: true }),
+ ok(status([], { hasUpstream: true, ahead: 1, behind: 0 })),
+ ok(eligibility({ canCreate: false, blockedReason: 'needs_push', nextAction: 'push' })),
+ ok({ success: true }),
+ ok(status([], { hasUpstream: true, ahead: 0, behind: 0 })),
+ ok(eligibility({ canCreate: true, blockedReason: null, nextAction: null })),
+ ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
+ ok({ worktree: { linkedPR: 42 } })
+ ])
+ const progress: string[] = []
+
+ const result = await runMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null,
+ onProgress: (step) => progress.push(step)
+ })
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ ok: true,
+ committed: true,
+ url: 'https://github.com/o/r/pull/42'
+ })
+ )
+ expect(progress).toEqual([
+ 'staging',
+ 'generating_commit_message',
+ 'committing',
+ 'pushing',
+ 'creating_review'
+ ])
+ expect(client.calls.map((call) => call.method)).toEqual([
+ 'git.status',
+ 'git.bulkStage',
+ 'git.status',
+ 'git.generateCommitMessage',
+ 'git.commit',
+ 'git.status',
+ 'hostedReview.getCreationEligibility',
+ 'git.push',
+ 'git.status',
+ 'hostedReview.getCreationEligibility',
+ 'hostedReview.create',
+ 'worktree.set'
+ ])
+ })
+
+ it('does not create when eligibility remains blocked', async () => {
+ const client = clientWith([
+ ok(status([])),
+ ok(eligibility({ canCreate: false, blockedReason: 'auth_required', nextAction: 'auth' }))
+ ])
+
+ await expect(
+ runMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null
+ })
+ ).resolves.toEqual({
+ ok: false,
+ error: 'Authenticate before creating a pull request.',
+ committed: false,
+ status: expect.objectContaining({ entries: [] })
+ })
+ expect(client.calls.map((call) => call.method)).toEqual([
+ 'git.status',
+ 'hostedReview.getCreationEligibility'
+ ])
+ })
+
+ it('reports committed work when creation fails after the automatic commit', async () => {
+ const client = clientWith([
+ ok(status([entry('staged')])),
+ ok({ success: true }),
+ ok(status([])),
+ ok(eligibility({ canCreate: true, blockedReason: null, nextAction: null })),
+ ok({ ok: false, code: 'validation', error: 'Create PR failed: bad base' })
+ ])
+
+ await expect(
+ runMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null,
+ commitMessage: 'Use my message'
+ })
+ ).resolves.toEqual({
+ ok: false,
+ error: 'Create PR failed: bad base',
+ committed: true,
+ status: expect.objectContaining({ entries: [] })
+ })
+ })
+})
diff --git a/mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts
new file mode 100644
index 00000000000..16e6a117f04
--- /dev/null
+++ b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts
@@ -0,0 +1,86 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { MobileGitStatusResult } from './mobile-git-status'
+import {
+ createMobilePr,
+ getMobilePrCreateBlockMessage,
+ getMobilePrCreateSuccessWarning,
+ shouldPushBeforeMobilePrCreate,
+ type MobilePrPrefill
+} from './mobile-pr-create'
+import {
+ prepareMobileHostedReviewCreateIntent,
+ type MobileHostedReviewCreateIntentProgress
+} from './mobile-hosted-review-create-intent'
+
+type RunInput = {
+ branch: string
+ title: string
+ status: MobileGitStatusResult | null
+ commitMessage?: string
+ onProgress?: (progress: MobileHostedReviewCreateIntentProgress) => void
+}
+
+export type MobileHostedReviewCreateIntentRunOutcome =
+ | {
+ ok: true
+ url: string
+ warning?: string
+ prefill: MobilePrPrefill
+ status: MobileGitStatusResult | null
+ committed: boolean
+ }
+ | {
+ ok: false
+ error: string
+ committed?: boolean
+ status?: MobileGitStatusResult | null
+ }
+
+export async function runMobileHostedReviewCreateIntent(
+ client: Pick,
+ worktreeId: string,
+ input: RunInput
+): Promise {
+ const prepared = await prepareMobileHostedReviewCreateIntent(client, worktreeId, input)
+ if (!prepared.ok) {
+ return prepared
+ }
+
+ const blockedMessage = getMobilePrCreateBlockMessage(prepared.prefill)
+ if (blockedMessage) {
+ return {
+ ok: false,
+ error: blockedMessage,
+ committed: prepared.committed,
+ status: prepared.status
+ }
+ }
+
+ input.onProgress?.('creating_review')
+ const created = await createMobilePr(client, worktreeId, {
+ provider: prepared.prefill.provider,
+ base: prepared.prefill.base,
+ head: input.branch,
+ title: prepared.prefill.title,
+ body: prepared.prefill.body,
+ draft: false,
+ pushBeforeCreate: shouldPushBeforeMobilePrCreate(prepared.prefill)
+ })
+ if (!created.ok) {
+ return {
+ ok: false,
+ error: created.error,
+ committed: prepared.committed,
+ status: prepared.status
+ }
+ }
+
+ return {
+ ok: true,
+ url: created.url,
+ warning: getMobilePrCreateSuccessWarning(created, prepared.prefill.provider),
+ prefill: prepared.prefill,
+ status: prepared.status,
+ committed: prepared.committed
+ }
+}
diff --git a/mobile/src/source-control/mobile-hosted-review-create-intent.test.ts b/mobile/src/source-control/mobile-hosted-review-create-intent.test.ts
new file mode 100644
index 00000000000..b15153a1cf9
--- /dev/null
+++ b/mobile/src/source-control/mobile-hosted-review-create-intent.test.ts
@@ -0,0 +1,210 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import { prepareMobileHostedReviewCreateIntent } from './mobile-hosted-review-create-intent'
+
+function ok(result: unknown): RpcSuccess {
+ return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
+}
+
+function fail(message: string): RpcFailure {
+ return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
+}
+
+function status(entries: unknown[], upstreamStatus = { hasUpstream: true, ahead: 0, behind: 0 }) {
+ return {
+ entries,
+ conflictOperation: 'unknown',
+ branch: 'feature/x',
+ head: 'sha',
+ upstreamStatus
+ }
+}
+
+function entry(area: 'unstaged' | 'untracked' | 'staged') {
+ return { path: 'a.ts', status: 'modified', area }
+}
+
+function unresolvedEntry(area: 'unstaged' | 'staged') {
+ return { path: 'conflicted.ts', status: 'modified', area, conflictStatus: 'unresolved' }
+}
+
+function eligibility(overrides: Record) {
+ return {
+ provider: 'github',
+ review: null,
+ defaultBaseRef: 'main',
+ title: 'feature/x',
+ body: '',
+ ...overrides
+ }
+}
+
+function clientWith(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ return responses.shift() ?? fail(`unexpected ${method}`)
+ })
+ }
+}
+
+describe('prepareMobileHostedReviewCreateIntent', () => {
+ it('stages, generates a commit, commits, pushes, then returns an eligible prefill', async () => {
+ const client = clientWith([
+ ok(status([entry('unstaged')])),
+ ok({ success: true }),
+ ok(status([entry('staged')])),
+ ok({ success: true, message: 'Ship mobile PR create' }),
+ ok({ success: true }),
+ ok(status([], { hasUpstream: true, ahead: 1, behind: 0 })),
+ ok(eligibility({ canCreate: false, blockedReason: 'needs_push', nextAction: 'push' })),
+ ok({ success: true }),
+ ok(status([], { hasUpstream: true, ahead: 0, behind: 0 })),
+ ok(eligibility({ canCreate: true, blockedReason: null, nextAction: null }))
+ ])
+ const progress: string[] = []
+
+ const result = await prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null,
+ onProgress: (step) => progress.push(step)
+ })
+
+ expect(result).toEqual({
+ ok: true,
+ committed: true,
+ status: expect.objectContaining({
+ entries: [],
+ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
+ }),
+ prefill: expect.objectContaining({ canCreate: true, blockedReason: null })
+ })
+ expect(progress).toEqual(['staging', 'generating_commit_message', 'committing', 'pushing'])
+ expect(client.calls.map((call) => call.method)).toEqual([
+ 'git.status',
+ 'git.bulkStage',
+ 'git.status',
+ 'git.generateCommitMessage',
+ 'git.commit',
+ 'git.status',
+ 'hostedReview.getCreationEligibility',
+ 'git.push',
+ 'git.status',
+ 'hostedReview.getCreationEligibility'
+ ])
+ })
+
+ it('uses a provided commit message instead of generating one', async () => {
+ const client = clientWith([
+ ok(status([entry('staged')])),
+ ok({ success: true }),
+ ok(status([], { hasUpstream: true, ahead: 0, behind: 0 })),
+ ok(eligibility({ canCreate: true, blockedReason: null, nextAction: null }))
+ ])
+
+ await expect(
+ prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null,
+ commitMessage: 'Use my draft'
+ })
+ ).resolves.toEqual(expect.objectContaining({ ok: true, committed: true }))
+
+ expect(client.calls.map((call) => call.method)).toEqual([
+ 'git.status',
+ 'git.commit',
+ 'git.status',
+ 'hostedReview.getCreationEligibility'
+ ])
+ expect(client.calls[1].params).toEqual({
+ worktree: 'id:repo-1::/tmp/wt',
+ message: 'Use my draft'
+ })
+ })
+
+ it('blocks when refreshed status loses its branch during staging', async () => {
+ const client = clientWith([
+ ok(status([entry('unstaged')])),
+ ok({ success: true }),
+ ok({ ...status([entry('staged')]), branch: null })
+ ])
+
+ const result = await prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ error: 'Branch changed while preparing the pull request.',
+ committed: false,
+ status: expect.objectContaining({
+ entries: [expect.objectContaining({ area: 'staged' })]
+ })
+ })
+ expect(result.status?.branch).toBeUndefined()
+
+ expect(client.calls.map((call) => call.method)).toEqual([
+ 'git.status',
+ 'git.bulkStage',
+ 'git.status'
+ ])
+ })
+
+ it('returns an actionable error when commit message generation fails', async () => {
+ const client = clientWith([
+ ok(status([entry('staged')])),
+ ok({ success: false, error: 'no model configured' })
+ ])
+
+ await expect(
+ prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null
+ })
+ ).resolves.toEqual({
+ ok: false,
+ error: 'Could not generate a commit message. Add one in Source Control, then retry.',
+ committed: false,
+ status: expect.objectContaining({
+ entries: [expect.objectContaining({ area: 'staged' })]
+ })
+ })
+
+ expect(client.calls.map((call) => call.method)).toEqual([
+ 'git.status',
+ 'git.generateCommitMessage'
+ ])
+ })
+
+ it('blocks unresolved conflicts before attempting a commit', async () => {
+ const client = clientWith([ok(status([entry('staged'), unresolvedEntry('unstaged')]))])
+
+ await expect(
+ prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
+ branch: 'feature/x',
+ title: 'feature/x',
+ status: null,
+ commitMessage: 'Use my message'
+ })
+ ).resolves.toEqual({
+ ok: false,
+ error: 'Resolve conflicts before creating a pull request.',
+ committed: false,
+ status: expect.objectContaining({
+ entries: expect.arrayContaining([expect.objectContaining({ path: 'conflicted.ts' })])
+ })
+ })
+
+ expect(client.calls.map((call) => call.method)).toEqual(['git.status'])
+ })
+})
diff --git a/mobile/src/source-control/mobile-hosted-review-create-intent.ts b/mobile/src/source-control/mobile-hosted-review-create-intent.ts
new file mode 100644
index 00000000000..2e459e1eedb
--- /dev/null
+++ b/mobile/src/source-control/mobile-hosted-review-create-intent.ts
@@ -0,0 +1,316 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc'
+import { requestMobileCommitMessage } from './mobile-commit-message-ai'
+import { getStageablePaths, type MobileGitStatusResult } from './mobile-git-status'
+import { getMobilePrEligibilityReadiness } from './mobile-open-pr-prefill'
+import { resolveMobilePrPrefill, type MobilePrPrefill } from './mobile-pr-create'
+
+export type MobileHostedReviewCreateIntentProgress =
+ | 'staging'
+ | 'generating_commit_message'
+ | 'committing'
+ | 'publishing'
+ | 'pushing'
+ | 'force_pushing'
+ | 'creating_review'
+
+export type MobileHostedReviewCreateIntentOutcome =
+ | {
+ ok: true
+ prefill: MobilePrPrefill
+ status: MobileGitStatusResult | null
+ committed: boolean
+ }
+ | { ok: false; error: string; committed?: boolean; status?: MobileGitStatusResult | null }
+
+type PrepareInput = {
+ branch: string
+ title: string
+ status: MobileGitStatusResult | null
+ commitMessage?: string
+ onProgress?: (progress: MobileHostedReviewCreateIntentProgress) => void
+}
+
+export function mobileHostedReviewCreateIntentProgressMessage(
+ progress: MobileHostedReviewCreateIntentProgress
+): string {
+ switch (progress) {
+ case 'staging':
+ return 'Staging changes...'
+ case 'generating_commit_message':
+ return 'Generating commit message...'
+ case 'committing':
+ return 'Committing changes...'
+ case 'publishing':
+ return 'Publishing branch...'
+ case 'pushing':
+ return 'Pushing commits...'
+ case 'force_pushing':
+ return 'Force pushing with lease...'
+ case 'creating_review':
+ return 'Creating review...'
+ }
+}
+
+async function readStatus(
+ client: Pick,
+ worktreeId: string
+): Promise {
+ const response = await client.sendRequest('git.status', { worktree: `id:${worktreeId}` })
+ if (!response.ok) {
+ return null
+ }
+ return readMobileGitStatusResult((response as RpcSuccess).result)
+}
+
+function branchStillMatches(inputBranch: string, status: MobileGitStatusResult | null): boolean {
+ const branch = status?.branch
+ if (!branch) {
+ return false
+ }
+ return branch === inputBranch || branch === `refs/heads/${inputBranch}`
+}
+
+function hasUnresolvedConflicts(status: MobileGitStatusResult | null): boolean {
+ return status?.entries.some((entry) => entry.conflictStatus === 'unresolved') === true
+}
+
+async function sendGitMutation(
+ client: Pick,
+ method: string,
+ params: Record,
+ fallback: string
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ try {
+ const response = await client.sendRequest(method, params)
+ if (!response.ok) {
+ return { ok: false, error: response.error?.message || fallback }
+ }
+ return { ok: true }
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : fallback }
+ }
+}
+
+async function commitStagedChanges(
+ client: Pick,
+ worktreeId: string,
+ message: string
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ try {
+ const response = await client.sendRequest('git.commit', {
+ worktree: `id:${worktreeId}`,
+ message
+ })
+ if (!response.ok) {
+ return { ok: false, error: response.error?.message || 'Commit failed' }
+ }
+ const result = (response as RpcSuccess).result as { success?: boolean; error?: string }
+ if (result?.success !== true) {
+ return { ok: false, error: result?.error || 'Commit failed' }
+ }
+ return { ok: true }
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : 'Commit failed' }
+ }
+}
+
+async function resolvePrefillFromStatus(
+ client: Pick,
+ worktreeId: string,
+ branch: string,
+ title: string,
+ status: MobileGitStatusResult | null
+): Promise {
+ return resolveMobilePrPrefill(client, worktreeId, {
+ branch,
+ title,
+ ...getMobilePrEligibilityReadiness(status)
+ })
+}
+
+async function ensureLocalChangesCommitted(
+ client: Pick,
+ worktreeId: string,
+ input: PrepareInput,
+ currentStatus: MobileGitStatusResult | null
+): Promise<
+ | { ok: true; status: MobileGitStatusResult | null; committed: boolean }
+ | { ok: false; error: string; committed?: boolean; status?: MobileGitStatusResult | null }
+> {
+ if ((currentStatus?.entries.length ?? 0) === 0) {
+ return { ok: true, status: currentStatus, committed: false }
+ }
+ if (hasUnresolvedConflicts(currentStatus)) {
+ return {
+ ok: false,
+ error: 'Resolve conflicts before creating a pull request.',
+ committed: false,
+ status: currentStatus
+ }
+ }
+
+ const stagePaths = getStageablePaths(currentStatus?.entries ?? [])
+ if (stagePaths.length > 0) {
+ input.onProgress?.('staging')
+ const staged = await sendGitMutation(
+ client,
+ 'git.bulkStage',
+ { worktree: `id:${worktreeId}`, filePaths: stagePaths },
+ 'Failed to stage changes'
+ )
+ if (!staged.ok) {
+ return staged
+ }
+ currentStatus = await readStatus(client, worktreeId)
+ if (!branchStillMatches(input.branch, currentStatus)) {
+ return {
+ ok: false,
+ error: 'Branch changed while preparing the pull request.',
+ committed: false,
+ status: currentStatus
+ }
+ }
+ }
+
+ const hasStagedChanges = currentStatus?.entries.some((entry) => entry.area === 'staged') === true
+ if (!hasStagedChanges) {
+ return {
+ ok: false,
+ error: 'Resolve or stage changes before creating a pull request.',
+ committed: false,
+ status: currentStatus
+ }
+ }
+
+ let message = input.commitMessage?.trim() ?? ''
+ if (!message) {
+ input.onProgress?.('generating_commit_message')
+ const generated = await requestMobileCommitMessage(client, worktreeId)
+ if (!generated.success) {
+ return {
+ ok: false,
+ error: 'Could not generate a commit message. Add one in Source Control, then retry.',
+ committed: false,
+ status: currentStatus
+ }
+ }
+ message = generated.message
+ }
+
+ input.onProgress?.('committing')
+ const committed = await commitStagedChanges(client, worktreeId, message)
+ if (!committed.ok) {
+ return { ...committed, committed: false, status: currentStatus }
+ }
+ currentStatus = await readStatus(client, worktreeId)
+ if (!branchStillMatches(input.branch, currentStatus)) {
+ return {
+ ok: false,
+ error: 'Branch changed while preparing the pull request.',
+ committed: true,
+ status: currentStatus
+ }
+ }
+ return { ok: true, status: currentStatus, committed: true }
+}
+
+async function applyRemotePrerequisite(
+ client: Pick,
+ worktreeId: string,
+ prefill: MobilePrPrefill,
+ input: PrepareInput
+): Promise<{ ok: true; ran: boolean } | { ok: false; error: string }> {
+ switch (prefill.blockedReason) {
+ case 'no_upstream': {
+ input.onProgress?.('publishing')
+ const result = await sendGitMutation(
+ client,
+ 'git.push',
+ { worktree: `id:${worktreeId}`, publish: true },
+ 'Failed to publish branch'
+ )
+ return result.ok ? { ok: true, ran: true } : result
+ }
+ case 'needs_push': {
+ input.onProgress?.('pushing')
+ const result = await sendGitMutation(
+ client,
+ 'git.push',
+ { worktree: `id:${worktreeId}` },
+ 'Failed to push commits'
+ )
+ return result.ok ? { ok: true, ran: true } : result
+ }
+ case 'needs_sync':
+ if (input.status?.upstreamStatus?.behindCommitsArePatchEquivalent !== true) {
+ return { ok: true, ran: false }
+ }
+ input.onProgress?.('force_pushing')
+ const result = await sendGitMutation(
+ client,
+ 'git.push',
+ { worktree: `id:${worktreeId}`, forceWithLease: true },
+ 'Failed to force push with lease'
+ )
+ return result.ok ? { ok: true, ran: true } : result
+ default:
+ return { ok: true, ran: false }
+ }
+}
+
+export async function prepareMobileHostedReviewCreateIntent(
+ client: Pick,
+ worktreeId: string,
+ input: PrepareInput
+): Promise {
+ let currentStatus = (await readStatus(client, worktreeId)) ?? input.status
+ if (!branchStillMatches(input.branch, currentStatus)) {
+ return { ok: false, error: 'Branch changed while preparing the pull request.' }
+ }
+
+ const committed = await ensureLocalChangesCommitted(client, worktreeId, input, currentStatus)
+ if (!committed.ok) {
+ return committed
+ }
+ currentStatus = committed.status
+
+ let prefill = await resolvePrefillFromStatus(
+ client,
+ worktreeId,
+ input.branch,
+ input.title,
+ currentStatus
+ )
+ for (let attempts = 0; attempts < 2; attempts++) {
+ const remote = await applyRemotePrerequisite(client, worktreeId, prefill, {
+ ...input,
+ status: currentStatus
+ })
+ if (!remote.ok) {
+ return { ...remote, committed: committed.committed, status: currentStatus }
+ }
+ if (!remote.ran) {
+ break
+ }
+ currentStatus = await readStatus(client, worktreeId)
+ if (!branchStillMatches(input.branch, currentStatus)) {
+ return {
+ ok: false,
+ error: 'Branch changed while preparing the pull request.',
+ committed: committed.committed,
+ status: currentStatus
+ }
+ }
+ prefill = await resolvePrefillFromStatus(
+ client,
+ worktreeId,
+ input.branch,
+ input.title,
+ currentStatus
+ )
+ }
+
+ return { ok: true, prefill, status: currentStatus, committed: committed.committed }
+}
diff --git a/mobile/src/source-control/mobile-hosted-review-service.ts b/mobile/src/source-control/mobile-hosted-review-service.ts
new file mode 100644
index 00000000000..c219586edc7
--- /dev/null
+++ b/mobile/src/source-control/mobile-hosted-review-service.ts
@@ -0,0 +1,267 @@
+import type {
+ CreateHostedReviewResult,
+ HostedReviewCreationBlockedReason,
+ HostedReviewCreationEligibility,
+ HostedReviewCreationNextAction,
+ HostedReviewProvider
+} from '../../../src/shared/hosted-review'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+import { hostedReviewCopy } from './hosted-review-copy'
+import { linkMobileHostedReview } from './mobile-pr-link'
+
+// The mobile worktree id is `${repoId}::${path}`; hosted-review RPCs expect the
+// repo selector separately, matching the desktop/runtime hosted-review service.
+export function mobileRepoSelectorFromWorktreeId(worktreeId: string): string {
+ const separatorIdx = worktreeId.indexOf('::')
+ const repoId = separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
+ return `id:${repoId}`
+}
+
+export type MobileHostedReviewEligibilityInput = {
+ branch: string
+ base?: string | null
+ hasUncommittedChanges?: boolean
+ hasUpstream?: boolean
+ ahead?: number
+ behind?: number
+ linkedGitHubPR?: number | null
+ linkedGitLabMR?: number | null
+}
+
+export async function fetchMobileHostedReviewEligibility(
+ client: Pick,
+ worktreeId: string,
+ input: MobileHostedReviewEligibilityInput
+): Promise {
+ const response = await client.sendRequest('hostedReview.getCreationEligibility', {
+ repo: mobileRepoSelectorFromWorktreeId(worktreeId),
+ worktree: `id:${worktreeId}`,
+ branch: input.branch,
+ base: input.base ?? null,
+ ...(input.hasUncommittedChanges !== undefined
+ ? { hasUncommittedChanges: input.hasUncommittedChanges }
+ : {}),
+ ...(input.hasUpstream !== undefined ? { hasUpstream: input.hasUpstream } : {}),
+ ...(input.ahead !== undefined ? { ahead: input.ahead } : {}),
+ ...(input.behind !== undefined ? { behind: input.behind } : {}),
+ linkedGitHubPR: input.linkedGitHubPR ?? null,
+ linkedGitLabMR: input.linkedGitLabMR ?? null
+ })
+ if (!response.ok) {
+ return null
+ }
+ return (response as RpcSuccess).result as HostedReviewCreationEligibility
+}
+
+export type MobileHostedReviewPrefill = {
+ provider: HostedReviewProvider
+ base: string
+ title: string
+ body: string
+ canCreate?: boolean
+ blockedReason?: HostedReviewCreationBlockedReason
+ nextAction?: HostedReviewCreationNextAction
+}
+
+// Resolve the mobile compose prefill from the same hosted-review eligibility
+// service desktop uses. If eligibility is unavailable, return a blocked prefill
+// instead of inventing a provider/base locally.
+export async function resolveMobileHostedReviewPrefill(
+ client: Pick,
+ worktreeId: string,
+ args: {
+ branch: string | undefined
+ title: string
+ hasUncommittedChanges?: boolean
+ hasUpstream?: boolean
+ ahead?: number
+ behind?: number
+ }
+): Promise {
+ const fallback: MobileHostedReviewPrefill = {
+ provider: 'github',
+ base: 'main',
+ title: args.title,
+ body: ''
+ }
+ if (!args.branch) {
+ return { ...fallback, canCreate: false, blockedReason: 'detached_head', nextAction: null }
+ }
+ try {
+ const eligibility = await fetchMobileHostedReviewEligibility(client, worktreeId, {
+ branch: args.branch,
+ hasUncommittedChanges: args.hasUncommittedChanges,
+ hasUpstream: args.hasUpstream,
+ ahead: args.ahead,
+ behind: args.behind
+ })
+ if (!eligibility) {
+ return { ...fallback, canCreate: false, blockedReason: null, nextAction: null }
+ }
+ return {
+ provider: eligibility.provider,
+ base: eligibility.defaultBaseRef || 'main',
+ title: eligibility.title || args.title,
+ body: eligibility.body || '',
+ canCreate: eligibility.canCreate,
+ blockedReason: eligibility.blockedReason,
+ nextAction: eligibility.nextAction
+ }
+ } catch {
+ return { ...fallback, canCreate: false, blockedReason: null, nextAction: null }
+ }
+}
+
+export function shouldPushBeforeMobileHostedReviewCreate(
+ prefill: Pick
+): boolean {
+ return prefill.blockedReason === 'needs_push'
+}
+
+export type MobileHostedReviewCreateInput = {
+ provider: HostedReviewProvider
+ base: string
+ head?: string
+ title: string
+ body: string
+ draft: boolean
+ useTemplate?: boolean
+ pushBeforeCreate?: boolean
+}
+
+// Builds the hostedReview.create params, trimming title/body and dropping empty
+// optional fields so the host's required-string validation passes cleanly.
+export function buildMobileHostedReviewCreateParams(
+ worktreeId: string,
+ input: MobileHostedReviewCreateInput
+): Record {
+ return {
+ repo: mobileRepoSelectorFromWorktreeId(worktreeId),
+ worktree: `id:${worktreeId}`,
+ provider: input.provider,
+ base: input.base.trim(),
+ ...(input.head && input.head.trim().length > 0 ? { head: input.head.trim() } : {}),
+ title: input.title.trim(),
+ ...(input.body.trim().length > 0 ? { body: input.body.trim() } : {}),
+ draft: input.draft,
+ ...(input.useTemplate !== undefined ? { useTemplate: input.useTemplate } : {})
+ }
+}
+
+export type MobileHostedReviewCreateOutcome =
+ | { ok: true; url: string; number?: number; existing?: boolean; linkError?: string }
+ | { ok: false; error: string }
+
+async function pushMobileBranchBeforeCreate(
+ client: Pick,
+ worktreeId: string
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ try {
+ const response = await client.sendRequest('git.push', { worktree: `id:${worktreeId}` })
+ if (!response.ok) {
+ return { ok: false, error: 'Push failed. Resolve the push error, then try again.' }
+ }
+ return { ok: true }
+ } catch {
+ return { ok: false, error: 'Push failed. Resolve the push error, then try again.' }
+ }
+}
+
+function formatMobileHostedReviewCreateError(
+ result: CreateHostedReviewResult,
+ pushed: boolean,
+ shortLabel: string
+): string {
+ if (result.ok) {
+ return ''
+ }
+ if (!pushed) {
+ return result.error
+ }
+ const prefix = new RegExp(`^Create ${shortLabel} failed:\\s*`, 'i')
+ return `Push succeeded, but ${shortLabel} creation failed: ${result.error.replace(prefix, '')}`
+}
+
+async function finishMobileHostedReviewCreateSuccess(
+ client: Pick,
+ worktreeId: string,
+ input: MobileHostedReviewCreateInput,
+ result: { number: number; url: string },
+ existing?: boolean
+): Promise {
+ const baseRef = input.base.trim()
+ const linked = await linkMobileHostedReview(client, worktreeId, input.provider, result.number, {
+ // Why: mobile branch compare cannot infer the new hosted review's target
+ // base from renderer cache; persist the submitted base for the refresh.
+ baseRef
+ })
+ return {
+ ok: true,
+ url: result.url,
+ number: result.number,
+ ...(existing ? { existing: true } : {}),
+ ...(linked.ok ? {} : { linkError: linked.error })
+ }
+}
+
+export async function createMobileHostedReview(
+ client: Pick,
+ worktreeId: string,
+ input: MobileHostedReviewCreateInput
+): Promise {
+ let pushed = false
+ try {
+ if (input.pushBeforeCreate) {
+ const push = await pushMobileBranchBeforeCreate(client, worktreeId)
+ if (!push.ok) {
+ return push
+ }
+ pushed = true
+ }
+ const response = await client.sendRequest(
+ 'hostedReview.create',
+ buildMobileHostedReviewCreateParams(worktreeId, input)
+ )
+ if (!response.ok) {
+ return { ok: false, error: response.error?.message || 'Failed to create pull request' }
+ }
+ const result = (response as RpcSuccess).result as CreateHostedReviewResult
+ if (result.ok) {
+ return finishMobileHostedReviewCreateSuccess(client, worktreeId, input, result)
+ }
+ if (result.existingReview?.url) {
+ const number = result.existingReview.number
+ if (!number) {
+ return {
+ ok: true,
+ url: result.existingReview.url,
+ existing: true
+ }
+ }
+ return finishMobileHostedReviewCreateSuccess(
+ client,
+ worktreeId,
+ input,
+ { number, url: result.existingReview.url },
+ true
+ )
+ }
+ return {
+ ok: false,
+ error:
+ formatMobileHostedReviewCreateError(
+ result,
+ pushed,
+ hostedReviewCopy(input.provider).shortLabel
+ ) || 'Failed to create pull request'
+ }
+ } catch (err) {
+ // Why: create review runs from an inline form; transport drops should surface
+ // as form errors instead of escaping as unhandled promise rejections.
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : 'Failed to create pull request'
+ }
+ }
+}
diff --git a/mobile/src/source-control/mobile-open-pr-prefill.test.ts b/mobile/src/source-control/mobile-open-pr-prefill.test.ts
index a3f67fde7f1..cf977b0b2b2 100644
--- a/mobile/src/source-control/mobile-open-pr-prefill.test.ts
+++ b/mobile/src/source-control/mobile-open-pr-prefill.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { readFreshGitStatus } from './mobile-open-pr-prefill'
+import { getMobilePrEligibilityReadiness, readFreshGitStatus } from './mobile-open-pr-prefill'
import type { MobileGitStatusResult } from './mobile-git-status'
const fallback = { branch: 'old', entries: [] } as unknown as MobileGitStatusResult
@@ -33,3 +33,23 @@ describe('readFreshGitStatus', () => {
expect(out).toBe(fallback)
})
})
+
+describe('getMobilePrEligibilityReadiness', () => {
+ it('keeps readiness fields absent when git status is unknown', () => {
+ expect(getMobilePrEligibilityReadiness(null)).toEqual({})
+ })
+
+ it('derives dirty and upstream readiness from git status', () => {
+ const status = {
+ entries: [{ path: 'a.ts' }],
+ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 1 }
+ } as unknown as MobileGitStatusResult
+
+ expect(getMobilePrEligibilityReadiness(status)).toEqual({
+ hasUncommittedChanges: true,
+ hasUpstream: true,
+ ahead: 2,
+ behind: 1
+ })
+ })
+})
diff --git a/mobile/src/source-control/mobile-open-pr-prefill.ts b/mobile/src/source-control/mobile-open-pr-prefill.ts
index 70b203057b8..705b4dc68e5 100644
--- a/mobile/src/source-control/mobile-open-pr-prefill.ts
+++ b/mobile/src/source-control/mobile-open-pr-prefill.ts
@@ -31,13 +31,33 @@ export async function buildOpenPrPrefill(
if (!client) {
return { provider: 'github', base: 'main', title: branchLabel, body: '' }
}
- const up = status?.upstreamStatus
+ const gitReadiness = getMobilePrEligibilityReadiness(status)
return resolveMobilePrPrefill(client, worktreeId, {
branch: status?.branch,
title: branchLabel,
- hasUncommittedChanges: (status?.entries?.length ?? 0) > 0,
- hasUpstream: up?.hasUpstream === true,
- ahead: up?.ahead ?? 0,
- behind: up?.behind ?? 0
+ ...gitReadiness
})
}
+
+export function getMobilePrEligibilityReadiness(status: MobileGitStatusResult | null): {
+ hasUncommittedChanges?: boolean
+ hasUpstream?: boolean
+ ahead?: number
+ behind?: number
+} {
+ if (!status) {
+ return {}
+ }
+ const up = status?.upstreamStatus
+ const upstreamReadiness = up
+ ? {
+ hasUpstream: up.hasUpstream,
+ ahead: up.ahead,
+ behind: up.behind
+ }
+ : {}
+ return {
+ hasUncommittedChanges: (status.entries?.length ?? 0) > 0,
+ ...upstreamReadiness
+ }
+}
diff --git a/mobile/src/source-control/mobile-pr-create-flow.test.ts b/mobile/src/source-control/mobile-pr-create-flow.test.ts
new file mode 100644
index 00000000000..78a99dcce07
--- /dev/null
+++ b/mobile/src/source-control/mobile-pr-create-flow.test.ts
@@ -0,0 +1,280 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import { createMobilePr } from './mobile-pr-create'
+
+function ok(result: unknown): RpcSuccess {
+ return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
+}
+
+function fail(message: string): RpcFailure {
+ return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
+}
+
+function clientWith(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ return responses.shift() ?? fail('unexpected')
+ })
+ }
+}
+
+describe('createMobilePr', () => {
+ it('returns the url on success', async () => {
+ const client = clientWith([
+ ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
+ ok({ worktree: { linkedPR: 42 } })
+ ])
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
+ expect(client.calls[0].method).toBe('hostedReview.create')
+ expect(client.calls[1]).toEqual({
+ method: 'worktree.set',
+ params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedPR: 42 }
+ })
+ })
+
+ it('persists the same trimmed base ref used for creation', async () => {
+ const client = clientWith([
+ ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
+ ok({ worktree: { linkedPR: 42 } })
+ ])
+
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: ' main ',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
+ expect(client.calls[0].params).toMatchObject({ base: 'main' })
+ expect(client.calls[1]).toEqual({
+ method: 'worktree.set',
+ params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedPR: 42 }
+ })
+ })
+
+ it('links created merge requests through the provider-specific worktree field', async () => {
+ const client = clientWith([
+ ok({ ok: true, number: 7, url: 'https://gitlab.com/o/r/-/merge_requests/7' }),
+ ok({ worktree: { linkedGitLabMR: 7 } })
+ ])
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'gitlab',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({
+ ok: true,
+ number: 7,
+ url: 'https://gitlab.com/o/r/-/merge_requests/7'
+ })
+ expect(client.calls[1]).toEqual({
+ method: 'worktree.set',
+ params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedGitLabMR: 7 }
+ })
+ })
+
+ it('keeps the created url when the metadata link refresh fails', async () => {
+ const client = clientWith([
+ ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
+ fail('metadata failed')
+ ])
+
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({
+ ok: true,
+ number: 42,
+ url: 'https://github.com/o/r/pull/42',
+ linkError: 'metadata failed'
+ })
+ })
+
+ it('pushes before create when eligibility requires it', async () => {
+ const client = clientWith([
+ ok({ success: true }),
+ ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
+ ok({ worktree: { linkedPR: 42 } })
+ ])
+
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false,
+ pushBeforeCreate: true
+ })
+ ).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
+ expect(client.calls.map((call) => call.method)).toEqual([
+ 'git.push',
+ 'hostedReview.create',
+ 'worktree.set'
+ ])
+ expect(client.calls[0].params).toEqual({ worktree: 'id:repo-1::/tmp/wt' })
+ })
+
+ it('stops before create when the required push fails', async () => {
+ const client = clientWith([fail('rejected')])
+
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false,
+ pushBeforeCreate: true
+ })
+ ).resolves.toEqual({
+ ok: false,
+ error: 'Push failed. Resolve the push error, then try again.'
+ })
+ expect(client.calls.map((call) => call.method)).toEqual(['git.push'])
+ })
+
+ it('returns existing reviews as linkable success outcomes', async () => {
+ const client = clientWith([
+ ok({
+ ok: false,
+ code: 'already_exists',
+ error: 'Already open',
+ existingReview: { number: 42, url: 'https://github.com/o/r/pull/42' }
+ }),
+ ok({ worktree: { linkedPR: 42 } })
+ ])
+
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({
+ ok: true,
+ existing: true,
+ number: 42,
+ url: 'https://github.com/o/r/pull/42'
+ })
+ expect(client.calls[1]).toEqual({
+ method: 'worktree.set',
+ params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedPR: 42 }
+ })
+ })
+
+ it('returns existing review urls even when no number is available', async () => {
+ const client = clientWith([
+ ok({
+ ok: false,
+ code: 'already_exists',
+ error: 'Already open',
+ existingReview: { url: 'https://github.com/o/r/pull/42' }
+ })
+ ])
+
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({
+ ok: true,
+ existing: true,
+ url: 'https://github.com/o/r/pull/42'
+ })
+ expect(client.calls).toHaveLength(1)
+ })
+
+ it('formats create failures after a successful required push like desktop', async () => {
+ const client = clientWith([
+ ok({ success: true }),
+ ok({ ok: false, code: 'validation', error: 'Create PR failed: bad base' })
+ ])
+
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false,
+ pushBeforeCreate: true
+ })
+ ).resolves.toEqual({
+ ok: false,
+ error: 'Push succeeded, but PR creation failed: bad base'
+ })
+ })
+
+ it('maps a host failure result to { ok:false }', async () => {
+ const client = clientWith([ok({ ok: false, code: 'validation', error: 'Push first' })])
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({ ok: false, error: 'Push first' })
+ })
+
+ it('maps an RPC transport failure to { ok:false }', async () => {
+ const client = clientWith([fail('disconnected')])
+ const result = await createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ expect(result).toEqual({ ok: false, error: 'disconnected' })
+ })
+
+ it('normalizes a thrown sendRequest into { ok:false }', async () => {
+ const client = {
+ sendRequest: vi.fn(async () => {
+ throw new Error('socket hung up')
+ })
+ } as unknown as Pick
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({ ok: false, error: 'socket hung up' })
+ })
+})
diff --git a/mobile/src/source-control/mobile-pr-create.test.ts b/mobile/src/source-control/mobile-pr-create.test.ts
index e82fb4eed55..ce877dc3c41 100644
--- a/mobile/src/source-control/mobile-pr-create.test.ts
+++ b/mobile/src/source-control/mobile-pr-create.test.ts
@@ -1,11 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
+import { shouldOpenChecksPanelCreateComposer } from '../../../src/renderer/src/components/right-sidebar/checks-panel-review-creation'
import {
buildMobilePrCreateParams,
- createMobilePr,
+ getMobilePrCreateBlockMessage,
mobileRepoSelectorFromWorktreeId,
- resolveMobilePrPrefill
+ resolveMobilePrPrefill,
+ shouldPushBeforeMobilePrCreate
} from './mobile-pr-create'
function ok(result: unknown): RpcSuccess {
@@ -27,6 +30,22 @@ function clientWith(responses: RpcResponse[]): Pick &
}
}
+function eligibility(
+ overrides: Partial = {}
+): HostedReviewCreationEligibility {
+ return {
+ provider: 'github',
+ review: null,
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null,
+ defaultBaseRef: 'main',
+ title: 'Add feature',
+ body: '',
+ ...overrides
+ }
+}
+
describe('mobileRepoSelectorFromWorktreeId', () => {
it('extracts the repo id before the :: separator', () => {
expect(mobileRepoSelectorFromWorktreeId('repo-1::/tmp/wt')).toBe('id:repo-1')
@@ -39,10 +58,11 @@ describe('buildMobilePrCreateParams', () => {
expect(
buildMobilePrCreateParams('repo-1::/tmp/wt', {
provider: 'github',
- base: 'main',
+ base: ' main ',
title: ' Add feature ',
body: ' ',
- draft: false
+ draft: false,
+ useTemplate: true
})
).toEqual({
repo: 'id:repo-1',
@@ -50,7 +70,8 @@ describe('buildMobilePrCreateParams', () => {
provider: 'github',
base: 'main',
title: 'Add feature',
- draft: false
+ draft: false,
+ useTemplate: true
})
})
@@ -67,114 +88,39 @@ describe('buildMobilePrCreateParams', () => {
})
})
-describe('createMobilePr', () => {
- it('returns the url on success', async () => {
- const client = clientWith([
- ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
- ok({ worktree: { linkedPR: 42 } })
- ])
- await expect(
- createMobilePr(client, 'repo-1::/tmp/wt', {
- provider: 'github',
- base: 'main',
- title: 'T',
- body: '',
- draft: false
- })
- ).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
- expect(client.calls[0].method).toBe('hostedReview.create')
- expect(client.calls[1]).toEqual({
- method: 'worktree.set',
- params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedPR: 42 }
+describe('mobile create form gating parity', () => {
+ it.each([
+ { reason: null, canCreate: true },
+ { reason: 'dirty', canCreate: false },
+ { reason: 'detached_head', canCreate: false },
+ { reason: 'default_branch', canCreate: false },
+ { reason: 'no_upstream', canCreate: false },
+ { reason: 'needs_push', canCreate: false },
+ { reason: 'needs_sync', canCreate: false },
+ { reason: 'auth_required', canCreate: false },
+ { reason: 'unsupported_provider', canCreate: false },
+ { reason: 'existing_review', canCreate: false },
+ { reason: 'fork_head_unsupported', canCreate: false }
+ ] as const)('matches desktop composer gating for $reason', ({ reason, canCreate }) => {
+ const desktopEligibility = eligibility({ canCreate, blockedReason: reason })
+ const desktopAllowsComposer = shouldOpenChecksPanelCreateComposer({
+ activeReview: null,
+ isFolder: false,
+ branch: 'feature/x',
+ hostedReviewCreation: desktopEligibility
})
- })
+ const mobileAllowsComposer =
+ getMobilePrCreateBlockMessage({
+ provider: desktopEligibility.provider,
+ base: desktopEligibility.defaultBaseRef ?? 'main',
+ title: desktopEligibility.title ?? 'feature/x',
+ body: desktopEligibility.body ?? '',
+ canCreate: desktopEligibility.canCreate,
+ blockedReason: desktopEligibility.blockedReason,
+ nextAction: desktopEligibility.nextAction
+ }) === null
- it('links created merge requests through the provider-specific worktree field', async () => {
- const client = clientWith([
- ok({ ok: true, number: 7, url: 'https://gitlab.com/o/r/-/merge_requests/7' }),
- ok({ worktree: { linkedGitLabMR: 7 } })
- ])
- await expect(
- createMobilePr(client, 'repo-1::/tmp/wt', {
- provider: 'gitlab',
- base: 'main',
- title: 'T',
- body: '',
- draft: false
- })
- ).resolves.toEqual({
- ok: true,
- number: 7,
- url: 'https://gitlab.com/o/r/-/merge_requests/7'
- })
- expect(client.calls[1]).toEqual({
- method: 'worktree.set',
- params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedGitLabMR: 7 }
- })
- })
-
- it('keeps the created url when the metadata link refresh fails', async () => {
- const client = clientWith([
- ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
- fail('metadata failed')
- ])
-
- await expect(
- createMobilePr(client, 'repo-1::/tmp/wt', {
- provider: 'github',
- base: 'main',
- title: 'T',
- body: '',
- draft: false
- })
- ).resolves.toEqual({
- ok: true,
- number: 42,
- url: 'https://github.com/o/r/pull/42',
- linkError: 'metadata failed'
- })
- })
-
- it('maps a host failure result to { ok:false }', async () => {
- const client = clientWith([ok({ ok: false, code: 'needs_push', error: 'Push first' })])
- await expect(
- createMobilePr(client, 'repo-1::/tmp/wt', {
- provider: 'github',
- base: 'main',
- title: 'T',
- body: '',
- draft: false
- })
- ).resolves.toEqual({ ok: false, error: 'Push first' })
- })
-
- it('maps an RPC transport failure to { ok:false }', async () => {
- const client = clientWith([fail('disconnected')])
- const result = await createMobilePr(client, 'repo-1::/tmp/wt', {
- provider: 'github',
- base: 'main',
- title: 'T',
- body: '',
- draft: false
- })
- expect(result).toEqual({ ok: false, error: 'disconnected' })
- })
-
- it('normalizes a thrown sendRequest into { ok:false }', async () => {
- const client = {
- sendRequest: vi.fn(async () => {
- throw new Error('socket hung up')
- })
- } as unknown as Pick
- await expect(
- createMobilePr(client, 'repo-1::/tmp/wt', {
- provider: 'github',
- base: 'main',
- title: 'T',
- body: '',
- draft: false
- })
- ).resolves.toEqual({ ok: false, error: 'socket hung up' })
+ expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
})
})
@@ -205,27 +151,70 @@ describe('resolveMobilePrPrefill', () => {
provider: 'gitlab',
base: 'develop',
title: 'Add feature',
- body: 'Body'
+ body: 'Body',
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null
})
})
- it('falls back to github/main when eligibility is unavailable', async () => {
+ it('marks needs_push eligibility for submit-time push parity', async () => {
+ const client = clientWith([
+ ok({
+ provider: 'github',
+ canCreate: false,
+ review: null,
+ blockedReason: 'needs_push',
+ nextAction: 'push',
+ defaultBaseRef: 'main',
+ title: 'Add feature',
+ body: ''
+ })
+ ])
+ const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
+ expect(shouldPushBeforeMobilePrCreate(prefill)).toBe(true)
+ expect(getMobilePrCreateBlockMessage(prefill)).toBeNull()
+ })
+
+ it('returns a mobile block message for desktop-blocked create states', async () => {
+ const client = clientWith([
+ ok({
+ provider: 'github',
+ canCreate: false,
+ review: null,
+ blockedReason: 'dirty',
+ nextAction: 'commit',
+ defaultBaseRef: 'main'
+ })
+ ])
+ const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
+ expect(getMobilePrCreateBlockMessage(prefill)).toBe(
+ 'Commit changes before creating a pull request.'
+ )
+ })
+
+ it('returns a blocked fallback when eligibility is unavailable', async () => {
const client = clientWith([fail('nope')])
await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({
provider: 'github',
base: 'main',
title: 'feature/x',
- body: ''
+ body: '',
+ canCreate: false,
+ blockedReason: null,
+ nextAction: null
})
})
- it('falls back without calling the RPC when there is no branch', async () => {
+ it('blocks without calling the RPC when there is no branch', async () => {
const client = clientWith([])
const result = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', {
...baseArgs,
branch: undefined
})
expect(result.provider).toBe('github')
+ expect(result.canCreate).toBe(false)
+ expect(result.blockedReason).toBe('detached_head')
expect(client.calls).toEqual([])
})
})
diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts
index 5dba2167d87..675df20bedb 100644
--- a/mobile/src/source-control/mobile-pr-create.ts
+++ b/mobile/src/source-control/mobile-pr-create.ts
@@ -1,179 +1,74 @@
-import type {
- CreateHostedReviewResult,
- HostedReviewCreationEligibility,
- HostedReviewProvider
-} from '../../../src/shared/hosted-review'
-import type { RpcClient } from '../transport/rpc-client'
-import type { RpcSuccess } from '../transport/types'
-import { linkMobileHostedReview } from './mobile-pr-link'
+import { hostedReviewCopy } from './hosted-review-copy'
+import {
+ buildMobileHostedReviewCreateParams,
+ createMobileHostedReview,
+ fetchMobileHostedReviewEligibility,
+ mobileRepoSelectorFromWorktreeId,
+ resolveMobileHostedReviewPrefill,
+ shouldPushBeforeMobileHostedReviewCreate,
+ type MobileHostedReviewCreateInput,
+ type MobileHostedReviewCreateOutcome,
+ type MobileHostedReviewEligibilityInput,
+ type MobileHostedReviewPrefill
+} from './mobile-hosted-review-service'
-// The mobile worktree id is `${repoId}::${path}`; the repo selector the host
-// hosted-review RPCs expect is `id:${repoId}`.
-export function mobileRepoSelectorFromWorktreeId(worktreeId: string): string {
- const separatorIdx = worktreeId.indexOf('::')
- const repoId = separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
- return `id:${repoId}`
+export type MobilePrEligibilityInput = MobileHostedReviewEligibilityInput
+export type MobilePrPrefill = MobileHostedReviewPrefill
+export type MobilePrCreateInput = MobileHostedReviewCreateInput
+export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome
+
+export {
+ buildMobileHostedReviewCreateParams as buildMobilePrCreateParams,
+ createMobileHostedReview as createMobilePr,
+ fetchMobileHostedReviewEligibility as fetchMobilePrEligibility,
+ mobileRepoSelectorFromWorktreeId,
+ resolveMobileHostedReviewPrefill as resolveMobilePrPrefill,
+ shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate
}
-export type MobilePrEligibilityInput = {
- branch: string
- base?: string | null
- hasUncommittedChanges: boolean
- hasUpstream: boolean
- ahead: number
- behind: number
- linkedGitHubPR?: number | null
- linkedGitLabMR?: number | null
+export function getMobilePrCreateSuccessWarning(
+ outcome: Extract,
+ provider: MobilePrPrefill['provider']
+): string | undefined {
+ const copy = hostedReviewCopy(provider)
+ if (outcome.existing) {
+ return outcome.number
+ ? `${copy.titleLabel} #${outcome.number} is already open.`
+ : `${copy.titleLabel} is already open.`
+ }
+ if (outcome.linkError) {
+ return `${copy.titleLabel} created, but Orca could not refresh it yet.`
+ }
+ return undefined
}
-export async function fetchMobilePrEligibility(
- client: Pick,
- worktreeId: string,
- input: MobilePrEligibilityInput
-): Promise {
- const response = await client.sendRequest('hostedReview.getCreationEligibility', {
- repo: mobileRepoSelectorFromWorktreeId(worktreeId),
- worktree: `id:${worktreeId}`,
- branch: input.branch,
- base: input.base ?? null,
- hasUncommittedChanges: input.hasUncommittedChanges,
- hasUpstream: input.hasUpstream,
- ahead: input.ahead,
- behind: input.behind,
- linkedGitHubPR: input.linkedGitHubPR ?? null,
- linkedGitLabMR: input.linkedGitLabMR ?? null
- })
- if (!response.ok) {
+export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string | null {
+ if (prefill.canCreate !== false || shouldPushBeforeMobileHostedReviewCreate(prefill)) {
return null
}
- return (response as RpcSuccess).result as HostedReviewCreationEligibility
-}
-
-export type MobilePrPrefill = {
- provider: HostedReviewProvider
- base: string
- title: string
- body: string
-}
-
-// Fetches hosted-review eligibility and derives the PR compose prefill from it
-// — so non-GitHub repos (e.g. GitLab) get the right provider/base instead of a
-// hardcoded one. Falls back to a github/main default (with the branch label as
-// title) when branch/eligibility is unavailable.
-export async function resolveMobilePrPrefill(
- client: Pick,
- worktreeId: string,
- args: {
- branch: string | undefined
- title: string
- hasUncommittedChanges: boolean
- hasUpstream: boolean
- ahead: number
- behind: number
- }
-): Promise {
- const fallback: MobilePrPrefill = {
- provider: 'github',
- base: 'main',
- title: args.title,
- body: ''
- }
- if (!args.branch) {
- return fallback
- }
- try {
- const eligibility = await fetchMobilePrEligibility(client, worktreeId, {
- branch: args.branch,
- hasUncommittedChanges: args.hasUncommittedChanges,
- hasUpstream: args.hasUpstream,
- ahead: args.ahead,
- behind: args.behind
- })
- if (!eligibility) {
- return fallback
- }
- return {
- provider: eligibility.provider,
- base: eligibility.defaultBaseRef || 'main',
- title: eligibility.title || args.title,
- body: eligibility.body || ''
- }
- } catch {
- return fallback
- }
-}
-
-export type MobilePrCreateInput = {
- provider: HostedReviewProvider
- base: string
- head?: string
- title: string
- body: string
- draft: boolean
-}
-
-// Builds the hostedReview.create params, trimming title/body and dropping empty
-// optional fields so the host's required-string validation passes cleanly.
-export function buildMobilePrCreateParams(
- worktreeId: string,
- input: MobilePrCreateInput
-): Record {
- return {
- repo: mobileRepoSelectorFromWorktreeId(worktreeId),
- worktree: `id:${worktreeId}`,
- provider: input.provider,
- base: input.base,
- ...(input.head && input.head.length > 0 ? { head: input.head } : {}),
- title: input.title.trim(),
- ...(input.body.trim().length > 0 ? { body: input.body.trim() } : {}),
- draft: input.draft
- }
-}
-
-export type MobilePrCreateOutcome =
- | { ok: true; url: string; number: number; linkError?: string }
- | { ok: false; error: string }
-
-export async function createMobilePr(
- client: Pick,
- worktreeId: string,
- input: MobilePrCreateInput
-): Promise {
- try {
- const response = await client.sendRequest(
- 'hostedReview.create',
- buildMobilePrCreateParams(worktreeId, input)
- )
- if (!response.ok) {
- return { ok: false, error: response.error?.message || 'Failed to create pull request' }
- }
- const result = (response as RpcSuccess).result as CreateHostedReviewResult
- if (result.ok) {
- const linked = await linkMobileHostedReview(
- client,
- worktreeId,
- input.provider,
- result.number,
- {
- // Why: mobile branch compare cannot infer the new hosted review's target
- // base from renderer cache; persist the submitted base for the refresh.
- baseRef: input.base
- }
- )
- return {
- ok: true,
- url: result.url,
- number: result.number,
- ...(linked.ok ? {} : { linkError: linked.error })
- }
- }
- return { ok: false, error: result.error || 'Failed to create pull request' }
- } catch (err) {
- // Why: create-PR runs from an inline form; transport drops should surface as
- // form errors instead of escaping as unhandled promise rejections.
- return {
- ok: false,
- error: err instanceof Error ? err.message : 'Failed to create pull request'
- }
+ const copy = hostedReviewCopy(prefill.provider)
+ switch (prefill.blockedReason) {
+ case 'dirty':
+ return `Commit changes before creating a ${copy.reviewLabel}.`
+ case 'detached_head':
+ return `Check out a branch before creating a ${copy.reviewLabel}.`
+ case 'default_branch':
+ return `Switch to a feature branch before creating a ${copy.reviewLabel}.`
+ case 'no_upstream':
+ return `Publish commits before creating a ${copy.reviewLabel}.`
+ case 'needs_sync':
+ return `Sync this branch before creating a ${copy.reviewLabel}.`
+ case 'auth_required':
+ return `Authenticate before creating a ${copy.reviewLabel}.`
+ case 'unsupported_provider':
+ return `Creating ${copy.reviewLabel}s is not supported for this repo.`
+ case 'existing_review':
+ return `A ${copy.reviewLabel} already exists for this branch.`
+ case 'fork_head_unsupported':
+ return `Creating a ${copy.reviewLabel} from this fork is not supported.`
+ case 'needs_push':
+ case null:
+ case undefined:
+ return `This branch is not ready for a ${copy.reviewLabel} yet.`
}
}
diff --git a/mobile/src/source-control/use-mobile-create-pr-runner.ts b/mobile/src/source-control/use-mobile-create-pr-runner.ts
new file mode 100644
index 00000000000..9952faaaa3d
--- /dev/null
+++ b/mobile/src/source-control/use-mobile-create-pr-runner.ts
@@ -0,0 +1,108 @@
+import { useCallback, type MutableRefObject } from 'react'
+import type { RpcClient } from '../transport/rpc-client'
+import { triggerError } from '../platform/haptics'
+import type { MobileGitStatusResult } from './mobile-git-status'
+import type { LoadStatusOptions } from './mobile-source-control-screen-state'
+import {
+ mobileHostedReviewCreateIntentProgressMessage,
+ type MobileHostedReviewCreateIntentProgress
+} from './mobile-hosted-review-create-intent'
+import {
+ runMobileHostedReviewCreateIntent,
+ type MobileHostedReviewCreateIntentRunOutcome
+} from './mobile-hosted-review-create-intent-runner'
+
+type RunGitWorkflow = (actionId: string, runner: () => Promise) => Promise
+type LoadStatus = (options?: LoadStatusOptions) => Promise
+
+type Params = {
+ client: RpcClient | null
+ worktreeId: string
+ status: MobileGitStatusResult | null
+ branchLabel: string
+ commitMessage: string
+ mountedRef: MutableRefObject
+ runGitWorkflow: RunGitWorkflow
+ loadStatus: LoadStatus
+ setActionError: (next: string | null) => void
+ setCommitMessage: (next: string) => void
+ setShowActionSheet: (next: boolean) => void
+ setCreatedPrUrl: (next: string | null) => void
+ setCreatedPrWarning: (next: string | null) => void
+}
+
+export function useMobileCreatePrRunner({
+ client,
+ worktreeId,
+ status,
+ branchLabel,
+ commitMessage,
+ mountedRef,
+ runGitWorkflow,
+ loadStatus,
+ setActionError,
+ setCommitMessage,
+ setShowActionSheet,
+ setCreatedPrUrl,
+ setCreatedPrWarning
+}: Params) {
+ return useCallback(
+ async (pushFirst: boolean) => {
+ setShowActionSheet(false)
+ const branch = status?.branch
+ if (!client || !branch) {
+ triggerError()
+ setActionError('Check out a branch before creating a pull request.')
+ return
+ }
+ const created: { current: MobileHostedReviewCreateIntentRunOutcome | null } = {
+ current: null
+ }
+ const ran = await runGitWorkflow(pushFirst ? 'push-create-pr' : 'create-pr', async () => {
+ created.current = await runMobileHostedReviewCreateIntent(client, worktreeId, {
+ branch,
+ title: branchLabel,
+ status,
+ commitMessage,
+ onProgress: (progress: MobileHostedReviewCreateIntentProgress) =>
+ setActionError(mobileHostedReviewCreateIntentProgressMessage(progress))
+ })
+ if (!created.current.ok) {
+ throw new Error(created.current.error)
+ }
+ })
+ const outcome = created.current
+ if (outcome?.committed && mountedRef.current) {
+ setCommitMessage('')
+ }
+ if (!ran && outcome?.status !== undefined && mountedRef.current) {
+ await loadStatus({
+ preserveReadyOnFailure: true,
+ clearActionErrorOnSuccess: false,
+ force: true
+ })
+ }
+ if (!ran || !mountedRef.current || !outcome || !outcome.ok) {
+ return
+ }
+ setActionError(null)
+ setCreatedPrUrl(outcome.url)
+ setCreatedPrWarning(outcome.warning ?? null)
+ },
+ [
+ branchLabel,
+ client,
+ commitMessage,
+ loadStatus,
+ mountedRef,
+ runGitWorkflow,
+ setActionError,
+ setCommitMessage,
+ setCreatedPrUrl,
+ setCreatedPrWarning,
+ setShowActionSheet,
+ status,
+ worktreeId
+ ]
+ )
+}
diff --git a/mobile/src/source-control/use-mobile-source-control-action-sheet.ts b/mobile/src/source-control/use-mobile-source-control-action-sheet.ts
index 8511996fe82..cedf3dad521 100644
--- a/mobile/src/source-control/use-mobile-source-control-action-sheet.ts
+++ b/mobile/src/source-control/use-mobile-source-control-action-sheet.ts
@@ -23,7 +23,7 @@ export function useMobileSourceControlActionSheet(
runActionSheetGitSequence,
runActionSheetGitSync,
runActionSheetRebase,
- openPrSheet,
+ createPr,
openBranchPicker,
openHistory
} = state
@@ -38,7 +38,8 @@ export function useMobileSourceControlActionSheet(
busyAction,
openingPath,
openingBranchPath,
- prAvailable: upstreamKnown && upstream?.hasUpstream === true,
+ // Why: the create intent can publish/push before creating, matching desktop.
+ prAvailable: upstreamKnown,
handlers: {
commit: () => void runActionSheetCommit(),
commitPush: () =>
@@ -55,8 +56,8 @@ export function useMobileSourceControlActionSheet(
fastForward: () =>
void runActionSheetGitSequence('fast-forward', [{ method: 'git.fastForward' }]),
rebase: () => void runActionSheetRebase(),
- createPr: () => void openPrSheet(false),
- pushAndCreatePr: () => void openPrSheet(true),
+ createPr: () => void createPr(false),
+ pushAndCreatePr: () => void createPr(true),
checkout: () => void openBranchPicker(),
history: () => void openHistory()
}
@@ -64,11 +65,11 @@ export function useMobileSourceControlActionSheet(
[
busyAction,
commitMessage,
+ createPr,
openBranchPicker,
openHistory,
openingBranchPath,
openingPath,
- openPrSheet,
runActionSheetCommit,
runActionSheetCommitSequence,
runActionSheetCommitSync,
diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts
index 79c22c3b170..a04ead69690 100644
--- a/mobile/src/source-control/use-mobile-source-control-runners.ts
+++ b/mobile/src/source-control/use-mobile-source-control-runners.ts
@@ -2,11 +2,10 @@ import { useCallback, type MutableRefObject } from 'react'
import { useRouter } from 'expo-router'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
-import type { MobilePrPrefill } from './mobile-pr-create'
-import { buildOpenPrPrefill, readFreshGitStatus } from './mobile-open-pr-prefill'
import { useMobileCommitMessageGeneration } from './use-mobile-commit-message-generation'
import { useMobileSourceControlCommitRunners } from './use-mobile-source-control-commit-runners'
import { useMobileSourceControlActionSheetRunners } from './use-mobile-source-control-action-sheet-runners'
+import { useMobileCreatePrRunner } from './use-mobile-create-pr-runner'
import type { RuntimeGitLocalBranches } from '../../../src/shared/runtime-types'
import type { MobileGitStatusResult } from './mobile-git-status'
import type { LoadStatusOptions } from './mobile-source-control-screen-state'
@@ -38,8 +37,8 @@ type Params = {
setShowActionSheet: (next: boolean) => void
setLocalBranches: (next: RuntimeGitLocalBranches | null) => void
setShowBranchPicker: (next: boolean) => void
- setPrPrefill: (next: MobilePrPrefill | null) => void
- setShowPrSheet: (next: boolean) => void
+ setCreatedPrUrl: (next: string | null) => void
+ setCreatedPrWarning: (next: string | null) => void
}
// All git workflow + action-sheet runners for the source-control panel. Split
@@ -70,8 +69,8 @@ export function useMobileSourceControlRunners(params: Params) {
setShowActionSheet,
setLocalBranches,
setShowBranchPicker,
- setPrPrefill,
- setShowPrSheet
+ setCreatedPrUrl,
+ setCreatedPrWarning
} = params
const runGitWorkflow = useCallback(
@@ -184,46 +183,21 @@ export function useMobileSourceControlRunners(params: Params) {
setActionError
})
- const openPrSheet = useCallback(
- async (pushFirst: boolean) => {
- setShowActionSheet(false)
- let effectiveStatus = status
- if (pushFirst) {
- const pushed = await runGitWorkflow('push-create-pr', async () => {
- await sendGitRequest('git.push')
- })
- if (!pushed || !mountedRef.current) {
- return
- }
- // Why: the captured `status` predates the push, so its upstream/ahead data is
- // stale; read fresh git.status so the prefill reflects the just-pushed branch.
- if (client) {
- effectiveStatus = await readFreshGitStatus(worktreeId, status, sendGitRequest)
- if (!mountedRef.current) {
- return
- }
- }
- }
- const prefill = await buildOpenPrPrefill(client, worktreeId, effectiveStatus, branchLabel)
- if (!mountedRef.current) {
- return
- }
- setPrPrefill(prefill)
- setShowPrSheet(true)
- },
- [
- branchLabel,
- client,
- mountedRef,
- runGitWorkflow,
- sendGitRequest,
- setPrPrefill,
- setShowActionSheet,
- setShowPrSheet,
- status,
- worktreeId
- ]
- )
+ const createPr = useMobileCreatePrRunner({
+ client,
+ worktreeId,
+ status,
+ branchLabel,
+ commitMessage,
+ mountedRef,
+ runGitWorkflow,
+ loadStatus,
+ setActionError,
+ setCommitMessage,
+ setShowActionSheet,
+ setCreatedPrUrl,
+ setCreatedPrWarning
+ })
const openBranchPicker = useCallback(() => {
setShowActionSheet(false)
@@ -304,7 +278,7 @@ export function useMobileSourceControlRunners(params: Params) {
commit,
generateCommitMessage,
cancelGenerateCommitMessage,
- openPrSheet,
+ createPr,
openBranchPicker,
openHistory,
checkoutBranch,
diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts
index 10a7c181343..6ca585a3a4c 100644
--- a/mobile/src/source-control/use-mobile-source-control-state.ts
+++ b/mobile/src/source-control/use-mobile-source-control-state.ts
@@ -3,7 +3,6 @@ import { Keyboard, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { getWorktreeLabel } from '../session/worktree-label'
-import type { MobilePrPrefill } from './mobile-pr-create'
import { useMobileGitRequests } from './use-mobile-git-requests'
import { useMobileSourceControlLoaders } from './use-mobile-source-control-loaders'
import { useMobileSourceControlOpeners } from './use-mobile-source-control-openers'
@@ -50,12 +49,10 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const [busyAction, setBusyAction] = useState(null)
const [commitMessage, setCommitMessage] = useState('')
const [generatingMessage, setGeneratingMessage] = useState(false)
- const [showPrSheet, setShowPrSheet] = useState(false)
const [showBranchPicker, setShowBranchPicker] = useState(false)
const [localBranches, setLocalBranches] = useState(null)
const [createdPrUrl, setCreatedPrUrl] = useState(null)
const [createdPrWarning, setCreatedPrWarning] = useState(null)
- const [prPrefill, setPrPrefill] = useState(null)
const [discardTarget, setDiscardTarget] = useState(null)
const [showActionSheet, setShowActionSheet] = useState(false)
const [actionError, setActionError] = useState(null)
@@ -203,8 +200,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
setShowActionSheet,
setLocalBranches,
setShowBranchPicker,
- setPrPrefill,
- setShowPrSheet
+ setCreatedPrUrl,
+ setCreatedPrWarning
})
const primaryAction = useMemo(
() =>
@@ -261,8 +258,6 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
commitMessage,
setCommitMessage,
generatingMessage,
- showPrSheet,
- setShowPrSheet,
showBranchPicker,
setShowBranchPicker,
localBranches,
@@ -270,7 +265,6 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
setCreatedPrUrl,
createdPrWarning,
setCreatedPrWarning,
- prPrefill,
discardTarget,
setDiscardTarget,
showActionSheet,
From c16aa89ea7d70f34b110c9a1383794d8ffba9cb6 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 18:48:14 -0700
Subject: [PATCH 23/54] Improve mobile emulator pairing startup (#6527)
* Implement automated git preparation workflow for mobile PR creation
Introduce a structured hosted review intent preparation workflow to handle
staging, AI commit message generation, committing, and pushing changes
automatically before displaying the pull request composer on mobile.
- Map creation block reasons to descriptive user-facing validation errors
(e.g., dirty working tree, default branch, detached head) to match desktop.
- Decouple hosted-review business logic into a dedicated service helper.
- Update source control runner hooks to handle the new preparation flow.
* Refactor mobile PR creation to run intent and open URL directly
Remove MobilePrComposeSheet and the local compose form, moving instead
to a direct PR creation workflow that matches the desktop experience.
- Add runMobileHostedReviewCreateIntent to handle the full prepare,
push, and create sequence.
- Replace useMobileOpenPrSheetRunner with useMobileCreatePrRunner to
trigger the creation workflow and directly open the created PR URL.
- Simplify state management by removing showPrSheet, prPrefill, and
associated local compose sheets.
* Propagate git status and commit state on PR creation failure
Update `MobileHostedReviewCreateIntentOutcome` and the local change
commit helper to include optional `committed` and `status` fields in
their failure results.
This ensures that if PR preparation fails, callers still receive the
current repository status and know if their local changes have already
been committed.
* Add tests for mobile hosted review creation flow
Introduce unit tests for runMobileHostedReviewCreateIntent to verify
different scenarios of creating a hosted review on mobile, including:
- Successful flow including staging, committing, pushing, and creating
- Eligibility block handling (e.g., authentication requirements)
- Error reporting when creation fails after an automatic commit
* Block mobile PR creation on unresolved conflicts and refresh status
Prevent creating a hosted review on mobile when there are unresolved
merge conflicts. Also, return the latest git status on failures and
reload it in the UI to keep the source control screen in sync.
* Prefer fetched PR head SHA over cached status SHA for PR checks
On mobile, a create command can commit before opening the review,
meaning the fetched PR's head SHA is fresher than the route's cached
status SHA. Prioritizing the fetched PR head SHA ensures we fetch checks
for the most up-to-date commit.
* Fix mobile PR creation errors and validate branch presence
- Reject branch matches when the status branch is null or missing to
prevent PR creation when the branch is lost.
- Display actual PR creation errors in the sidebar instead of silently
ignoring them on failure.
- Trim leading and trailing whitespace from the base branch reference
before persisting the worktree link.
* Improve mobile emulator pairing startup
---
.../start-emulator-pairing-runtime.mjs | 154 ++++++++++++++++++
mobile/scripts/start-emulator.mjs | 90 +++++++++-
2 files changed, 240 insertions(+), 4 deletions(-)
create mode 100644 mobile/scripts/start-emulator-pairing-runtime.mjs
diff --git a/mobile/scripts/start-emulator-pairing-runtime.mjs b/mobile/scripts/start-emulator-pairing-runtime.mjs
new file mode 100644
index 00000000000..8d345348752
--- /dev/null
+++ b/mobile/scripts/start-emulator-pairing-runtime.mjs
@@ -0,0 +1,154 @@
+import { spawn } from 'node:child_process'
+import { mkdtempSync } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import process from 'node:process'
+import readline from 'node:readline'
+
+function primaryLanIp(lanIpCandidates) {
+ return lanIpCandidates()[0] || '127.0.0.1'
+}
+
+export async function startHeadlessPairingRuntime({
+ enabled,
+ orcaCli,
+ cwd,
+ lanIpCandidates,
+ logStep,
+ logSuccess
+}) {
+ if (!enabled) {
+ return null
+ }
+
+ logStep('0', 'Starting temporary desktop runtime for mobile pairing...')
+ const runDir = mkdtempSync(path.join(os.tmpdir(), 'orca-mobile-run.'))
+ const userData = path.join(runDir, 'userData')
+ const pairingAddress = primaryLanIp(lanIpCandidates)
+ const child = spawn(
+ orcaCli,
+ ['serve', '--mobile-pairing', '--pairing-address', pairingAddress, '--json'],
+ {
+ cwd,
+ env: {
+ ...process.env,
+ ORCA_E2E_USER_DATA_DIR: userData
+ },
+ stdio: ['ignore', 'pipe', 'pipe']
+ }
+ )
+
+ return await waitForPairingRuntime({ child, userData, pairingAddress, logSuccess })
+}
+
+export async function registerWorktreeForPairingRuntime(runtime, worktree, tools) {
+ if (!runtime) {
+ return
+ }
+ tools.logStep('0.1', 'Registering current worktree in temporary runtime...')
+ await tools.orca(['repo', 'add', '--path', worktree, '--json'], {
+ cwd: worktree,
+ env: runtime.env,
+ timeout: 60000
+ })
+ tools.logSuccess('Registered worktree for mobile runtime')
+}
+
+async function waitForPairingRuntime({ child, userData, pairingAddress, logSuccess }) {
+ let output = ''
+ let stderr = ''
+ let resolved = false
+ let exited = false
+ let rl = null
+ let rlErr = null
+
+ const stop = () => {
+ if (!exited) {
+ child.kill('SIGTERM')
+ }
+ rl?.close()
+ rlErr?.close()
+ child.stdout?.destroy()
+ child.stderr?.destroy()
+ }
+
+ const runtimeResult = (pairingUrl) => ({
+ pairingUrl,
+ userData,
+ process: child,
+ env: {
+ ...process.env,
+ ORCA_USER_DATA_PATH: userData
+ },
+ stop
+ })
+
+ return await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ if (!resolved) {
+ resolved = true
+ stop()
+ reject(new Error('Timeout waiting for temporary desktop runtime pairing URL'))
+ }
+ }, 120000)
+
+ const finishResolve = (pairingUrl) => {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ clearTimeout(timeout)
+ logSuccess(`Temporary desktop runtime ready (${pairingAddress})`)
+ resolve(runtimeResult(pairingUrl))
+ }
+
+ const finishReject = (error) => {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ clearTimeout(timeout)
+ stop()
+ reject(error)
+ }
+
+ rl = readline.createInterface({ input: child.stdout })
+ rl.on('line', (line) => {
+ output += line + '\n'
+ handleRuntimeLine(line, finishResolve)
+ })
+
+ rlErr = readline.createInterface({ input: child.stderr })
+ rlErr.on('line', (line) => {
+ stderr += line + '\n'
+ })
+
+ child.on('error', (error) => {
+ finishReject(new Error(`Failed to start temporary desktop runtime: ${error.message}`))
+ })
+
+ child.on('exit', (code) => {
+ exited = true
+ if (!resolved) {
+ const detail = stderr.trim() || output.trim() || `exit code ${code}`
+ finishReject(new Error(`Temporary desktop runtime exited before pairing: ${detail}`))
+ }
+ })
+ })
+}
+
+function handleRuntimeLine(line, finishResolve) {
+ const trimmed = line.trim()
+ if (!trimmed.startsWith('{')) {
+ return
+ }
+ try {
+ const result = JSON.parse(trimmed)
+ const pairingUrl = result?.pairing?.url
+ if (typeof pairingUrl === 'string' && pairingUrl.length > 0) {
+ finishResolve(pairingUrl)
+ }
+ } catch {
+ // Ignore non-JSON log lines from Electron startup.
+ }
+}
diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs
index 7b71dd6a3ee..15af9382708 100755
--- a/mobile/scripts/start-emulator.mjs
+++ b/mobile/scripts/start-emulator.mjs
@@ -11,16 +11,22 @@
* --device Device name (default: 'iPhone 17 Pro')
* --port Metro port (default: Expo default)
* --no-open Don't open the app URL automatically
+ * --no-pair Don't create a temporary paired desktop runtime
* --wait-for-ready Wait for Metro to be ready before opening URL
* --screenshot Take a screenshot after opening
*/
import { spawn, execFile } from 'node:child_process'
+import { existsSync } from 'node:fs'
import os from 'node:os'
import { promisify } from 'node:util'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline'
+import {
+ registerWorktreeForPairingRuntime,
+ startHeadlessPairingRuntime
+} from './start-emulator-pairing-runtime.mjs'
const execFileAsync = promisify(execFile)
@@ -31,6 +37,7 @@ const options = {
device: 'iPhone 17 Pro',
port: null,
open: true,
+ pair: true,
waitForReady: false,
screenshot: false
}
@@ -45,6 +52,8 @@ for (let i = 0; i < args.length; i++) {
options.port = args[++i]
} else if (arg === '--no-open') {
options.open = false
+ } else if (arg === '--no-pair') {
+ options.pair = false
} else if (arg === '--wait-for-ready') {
options.waitForReady = true
} else if (arg === '--screenshot') {
@@ -57,6 +66,7 @@ Options:
--device Device name (default: 'iPhone 17 Pro')
--port Metro port (default: Expo default)
--no-open Don't open the app URL automatically
+ --no-pair Don't create a temporary paired desktop runtime
--wait-for-ready Wait for Metro to be ready before opening URL
--screenshot Take a screenshot after opening
--help, -h Show this help message
@@ -110,6 +120,7 @@ function assertIosSimulatorPlatform() {
async function orca(args, options = {}) {
const { stdout, stderr } = await execFileAsync(ORCA_CLI, args, {
cwd: options.cwd || process.cwd(),
+ env: options.env || process.env,
encoding: 'utf8',
timeout: options.timeout || 30000
})
@@ -146,13 +157,41 @@ function getMobileDir(worktree) {
return path.join(worktree, 'mobile')
}
+async function ensureMobileDependencies(worktree) {
+ const mobileDir = getMobileDir(worktree)
+ const expoPath = path.join(mobileDir, 'node_modules', '.bin', 'expo')
+ if (existsSync(expoPath)) {
+ return
+ }
+
+ logStep('deps', 'Installing mobile dependencies...')
+ await new Promise((resolve, reject) => {
+ const install = spawn('pnpm', ['install'], {
+ cwd: mobileDir,
+ env: process.env,
+ stdio: 'inherit'
+ })
+ install.on('error', reject)
+ install.on('exit', (code) => {
+ if (code === 0) {
+ resolve()
+ } else {
+ reject(new Error(`pnpm install exited with code ${code}`))
+ }
+ })
+ })
+ logSuccess('Mobile dependencies installed')
+}
+
// Attach to emulator
-async function attachEmulator(worktree, device) {
+async function attachEmulator(worktree, device, runtime) {
logStep('1', `Attaching to emulator: ${device.name}`)
try {
await orca(['emulator', 'attach', device.udid, '--worktree', worktree, '--focus', '--json'], {
- cwd: worktree
+ cwd: worktree,
+ env: runtime?.env || process.env,
+ timeout: 60000
})
logSuccess(`Attached to ${device.name}`)
} catch (error) {
@@ -431,6 +470,25 @@ async function openInSimulator(url, deviceUdid) {
}
}
+async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktree) {
+ if (!pairingUrl || !options.open) {
+ return
+ }
+
+ logStep('4', 'Pairing mobile app to temporary desktop runtime...')
+ await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
+ await new Promise((resolve) => setTimeout(resolve, 2000))
+
+ // Why: the mobile app intentionally asks for a trust confirmation before
+ // saving a host. This lands on the Pair button on current iPhone simulators.
+ await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], {
+ cwd: worktree,
+ env: runtime?.env || process.env,
+ timeout: 30000
+ })
+ logSuccess('Opened pairing link and confirmed Pair')
+}
+
// Take a screenshot
async function takeScreenshot(
deviceUdid,
@@ -477,6 +535,7 @@ async function findReachableMetroUrl(initialUrl) {
// Main function
async function main() {
log(colors.bright + 'Starting Orca Mobile in Emulator\n' + colors.reset)
+ let pairingRuntime = null
try {
assertIosSimulatorPlatform()
@@ -484,6 +543,21 @@ async function main() {
// Get worktree
const worktree = await getWorktree()
logInfo(`Using worktree: ${worktree}`)
+ await ensureMobileDependencies(worktree)
+
+ pairingRuntime = await startHeadlessPairingRuntime({
+ enabled: options.pair,
+ orcaCli: ORCA_CLI,
+ cwd: process.cwd(),
+ lanIpCandidates,
+ logStep,
+ logSuccess
+ })
+ await registerWorktreeForPairingRuntime(pairingRuntime, worktree, {
+ orca,
+ logStep,
+ logSuccess
+ })
// Find best device
const device = await findBestDevice(options.device)
@@ -491,7 +565,7 @@ async function main() {
// Why: emulator helpers are worktree-scoped in Orca; attach is idempotent
// for the active worktree, while a global helper list cannot prove that.
- await attachEmulator(worktree, device)
+ await attachEmulator(worktree, device, pairingRuntime)
// Start Metro
const metro = await startMetro(worktree)
@@ -514,6 +588,12 @@ async function main() {
// Open in simulator
if (options.open) {
await openInSimulator(metro.url, device.udid)
+ await openPairingUrlInSimulator(
+ pairingRuntime?.pairingUrl,
+ device.udid,
+ pairingRuntime,
+ worktree
+ )
// Take screenshot if requested
if (options.screenshot) {
@@ -528,7 +608,7 @@ async function main() {
}
log(colors.bright + '\nSetup complete!' + colors.reset)
- logInfo('Press Ctrl+C to stop Metro')
+ logInfo('Press Ctrl+C to stop Metro and the temporary desktop runtime')
// Keep running until Metro exits
await new Promise((resolve) => {
@@ -542,6 +622,7 @@ async function main() {
process.off('SIGINT', stopMetro)
process.off('SIGTERM', stopMetro)
metro.closeOutput?.()
+ pairingRuntime?.stop()
resolve()
}
const stopMetro = () => {
@@ -564,6 +645,7 @@ async function main() {
})
process.exit(0)
} catch (error) {
+ pairingRuntime?.stop()
logError(error.message)
process.exit(1)
}
From e40ad6a93a1f41b319811764d3b70c5ead9bf040 Mon Sep 17 00:00:00 2001
From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com>
Date: Sun, 28 Jun 2026 00:54:24 -0300
Subject: [PATCH 24/54] fix(source-control): refresh branch compare after
upstream changes (#6522)
* fix(source-control): refresh branch compare after upstream changes
- Refresh branch compare when upstream status changes for the same worktree and base ref
- Prevent stale Create PR eligibility from remaining clickable while a newer preflight is loading
- Add regression coverage for upstream-driven compare refreshes and stale Create PR loading precedence
* fix(source-control): preserve review provider during preflight refresh
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
---
.../SourceControl.compare-summary.test.ts | 51 ++++++
.../right-sidebar/SourceControl.tsx | 169 +++++++++++++++---
...ol-primary-action.create-pr-intent.test.ts | 44 +++++
3 files changed, 243 insertions(+), 21 deletions(-)
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts
index 69a1f3646f1..ff850d554e2 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts
+++ b/src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts
@@ -6,6 +6,7 @@ import {
refreshSourceControlAfterRemoteAction,
resolveSourceControlBaseRef,
resolveSourceControlPickerBaseRef,
+ shouldRefreshBranchCompareForRemoteStatus,
shouldRefreshBranchCompareForStatusHead,
shouldShowCompareSummary
} from './SourceControl'
@@ -317,6 +318,56 @@ describe('SourceControl compare summary', () => {
).toBe(false)
})
+ it('refreshes branch compare when upstream status changes for the same base', () => {
+ expect(
+ shouldRefreshBranchCompareForRemoteStatus(
+ {
+ ahead: 1,
+ baseRef: 'origin/main',
+ behind: 0,
+ hasUpstream: true,
+ upstreamName: 'origin/main',
+ worktreeId: 'wt-1'
+ },
+ {
+ ahead: 0,
+ baseRef: 'origin/main',
+ behind: 0,
+ hasUpstream: true,
+ upstreamName: 'origin/main',
+ worktreeId: 'wt-1'
+ }
+ )
+ ).toBe(true)
+ })
+
+ it('does not refresh branch compare for initial or unrelated upstream status snapshots', () => {
+ const current = {
+ ahead: 0,
+ baseRef: 'origin/main',
+ behind: 0,
+ hasUpstream: true,
+ upstreamName: 'origin/main',
+ worktreeId: 'wt-1'
+ }
+
+ expect(shouldRefreshBranchCompareForRemoteStatus(null, current)).toBe(false)
+ expect(
+ shouldRefreshBranchCompareForRemoteStatus(current, {
+ ...current,
+ baseRef: 'origin/release',
+ ahead: 1
+ })
+ ).toBe(false)
+ expect(
+ shouldRefreshBranchCompareForRemoteStatus(current, {
+ ...current,
+ worktreeId: 'wt-2',
+ ahead: 1
+ })
+ ).toBe(false)
+ })
+
it('keeps immediate refresh paths for remote actions', () => {
const refreshGitStatus = vi.fn(async () => {})
const refreshBranchCompare = vi.fn(async () => {})
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 60453f6c1e1..f8031e09dde 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -612,6 +612,13 @@ type HostedReviewCreationRequestState = {
status: 'loading' | 'failed'
}
+type HostedReviewCreationProviderHint = {
+ repoId: string | null
+ worktreeId: string | null
+ branch: string
+ provider: HostedReviewProvider
+}
+
type CreatedHostedReview = {
provider: HostedReviewProvider
number: number
@@ -990,6 +997,12 @@ function SourceControlInner(): React.JSX.Element {
useState(null)
const [hostedReviewCreationRequestState, setHostedReviewCreationRequestState] =
useState(null)
+ const hostedReviewCreationProviderHintRef = useRef({
+ repoId: null,
+ worktreeId: null,
+ branch: '',
+ provider: 'github'
+ })
const createPrInFlightRef = useRef>({})
const [createPrInFlightByWorktree, setCreatePrInFlightByWorktree] = useState<
Record
@@ -1396,39 +1409,92 @@ function SourceControlInner(): React.JSX.Element {
shouldResolveHostedReviewCreation &&
hostedReviewCreationRequestMatchesCurrent &&
hostedReviewCreationRequestState.status === 'loading' &&
- hostedReviewCreation === null &&
hostedReview === null
- const hostedReviewCreationForHeader = useMemo(() => {
- if (hostedReviewCreation) {
- return hostedReviewCreation
- }
- if (!isHostedReviewCreationLoading) {
- return null
- }
- const provider = resolveProvisionalHostedReviewProvider({
+ const provisionalHostedReviewProvider = useMemo(
+ () =>
+ resolveProvisionalHostedReviewProvider({
+ hostedReview,
+ hostedReviewCreationState: hostedReviewCreation
+ ? {
+ repoId: activeRepo?.id ?? '',
+ data: hostedReviewCreation
+ }
+ : null,
+ activeRepoId: activeRepo?.id ?? null,
+ linkedGitHubPR,
+ fallbackGitHubPR: fallbackGitHubPRNumber,
+ linkedGitLabMR,
+ linkedBitbucketPR,
+ linkedAzureDevOpsPR,
+ linkedGiteaPR
+ }),
+ [
+ activeRepo?.id,
+ fallbackGitHubPRNumber,
hostedReview,
- hostedReviewCreationState,
- activeRepoId: activeRepo?.id ?? null,
- linkedGitHubPR,
- fallbackGitHubPR: fallbackGitHubPRNumber,
- linkedGitLabMR,
- linkedBitbucketPR,
+ hostedReviewCreation,
linkedAzureDevOpsPR,
+ linkedBitbucketPR,
+ linkedGitHubPR,
+ linkedGitLabMR,
linkedGiteaPR
- })
- return buildLoadingHostedReviewCreationEligibility(provider)
+ ]
+ )
+ useEffect(() => {
+ const hasConcreteProviderHint =
+ hostedReview !== null ||
+ hostedReviewCreation !== null ||
+ linkedGitHubPR !== null ||
+ fallbackGitHubPRNumber !== null ||
+ linkedGitLabMR !== null ||
+ linkedAzureDevOpsPR !== null ||
+ linkedGiteaPR !== null
+
+ if (!hasConcreteProviderHint) {
+ return
+ }
+
+ hostedReviewCreationProviderHintRef.current = {
+ repoId: activeRepo?.id ?? null,
+ worktreeId: activeWorktreeId ?? null,
+ branch: branchName,
+ provider: provisionalHostedReviewProvider
+ }
}, [
activeRepo?.id,
+ activeWorktreeId,
+ branchName,
fallbackGitHubPRNumber,
hostedReview,
hostedReviewCreation,
- hostedReviewCreationState,
- isHostedReviewCreationLoading,
linkedAzureDevOpsPR,
- linkedBitbucketPR,
+ linkedGiteaPR,
linkedGitHubPR,
linkedGitLabMR,
- linkedGiteaPR
+ provisionalHostedReviewProvider
+ ])
+ const hostedReviewCreationForHeader = useMemo(() => {
+ // Why: a fresh preflight must disable stale Create PR eligibility while
+ // upstream/dirty/base state is reconciling after commit or push, while
+ // preserving provider copy from the previous safe snapshot.
+ if (isHostedReviewCreationLoading) {
+ const providerHint = hostedReviewCreationProviderHintRef.current
+ const provider =
+ providerHint.repoId === (activeRepo?.id ?? null) &&
+ providerHint.worktreeId === (activeWorktreeId ?? null) &&
+ providerHint.branch === branchName
+ ? providerHint.provider
+ : provisionalHostedReviewProvider
+ return buildLoadingHostedReviewCreationEligibility(provider)
+ }
+ return hostedReviewCreation
+ }, [
+ activeRepo?.id,
+ activeWorktreeId,
+ branchName,
+ hostedReviewCreation,
+ isHostedReviewCreationLoading,
+ provisionalHostedReviewProvider
])
const hasHostedReviewLink = hasPositiveHostedReviewNumberLink({
linkedGitHubPR,
@@ -2943,6 +3009,9 @@ function SourceControlInner(): React.JSX.Element {
branch: branchName,
status: 'loading'
})
+ // Why: upstream/status changes can make the previous eligibility unsafe
+ // to click while the new preflight is still resolving.
+ setHostedReviewCreationState(null)
void getHostedReviewCreationEligibility({
repoPath: activeRepo.path,
repoId: activeRepo.id,
@@ -4447,6 +4516,7 @@ function SourceControlInner(): React.JSX.Element {
const branchCompareRunPromiseRef = useRef | null>(null)
const refreshBranchCompareRef = useRef<() => Promise>(async () => {})
const branchCompareStatusHeadRef = useRef(null)
+ const branchCompareRemoteStatusRef = useRef(null)
const runBranchCompare = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef || isFolder) {
@@ -4643,6 +4713,39 @@ function SourceControlInner(): React.JSX.Element {
worktreePath
])
+ useEffect(() => {
+ if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef || isFolder) {
+ branchCompareRemoteStatusRef.current = null
+ return
+ }
+
+ // Why: pushing a branch can move its remote-tracking base and ahead count
+ // without changing local HEAD, so the HEAD-change effect alone misses it.
+ const current = {
+ ahead: remoteStatus?.ahead ?? null,
+ baseRef: effectiveBaseRef,
+ behind: remoteStatus?.behind ?? null,
+ hasUpstream: remoteStatus?.hasUpstream ?? null,
+ upstreamName: remoteStatus?.upstreamName ?? null,
+ worktreeId: activeWorktreeId
+ }
+ const previous = branchCompareRemoteStatusRef.current
+ branchCompareRemoteStatusRef.current = current
+ if (shouldRefreshBranchCompareForRemoteStatus(previous, current)) {
+ void refreshBranchCompareRef.current()
+ }
+ }, [
+ activeWorktreeId,
+ effectiveBaseRef,
+ isBranchVisible,
+ isFolder,
+ remoteStatus?.ahead,
+ remoteStatus?.behind,
+ remoteStatus?.hasUpstream,
+ remoteStatus?.upstreamName,
+ worktreePath
+ ])
+
useEffect(() => {
if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef || isFolder) {
return
@@ -6855,6 +6958,15 @@ type BranchCompareStatusHeadSnapshot = {
worktreeId: string
}
+type BranchCompareRemoteStatusSnapshot = {
+ ahead: number | null
+ baseRef: string
+ behind: number | null
+ hasUpstream: boolean | null
+ upstreamName: string | null
+ worktreeId: string
+}
+
export function shouldRefreshBranchCompareForStatusHead(
previous: BranchCompareStatusHeadSnapshot | null,
current: BranchCompareStatusHeadSnapshot
@@ -6868,6 +6980,21 @@ export function shouldRefreshBranchCompareForStatusHead(
)
}
+export function shouldRefreshBranchCompareForRemoteStatus(
+ previous: BranchCompareRemoteStatusSnapshot | null,
+ current: BranchCompareRemoteStatusSnapshot
+): boolean {
+ return (
+ previous !== null &&
+ previous.worktreeId === current.worktreeId &&
+ previous.baseRef === current.baseRef &&
+ (previous.hasUpstream !== current.hasUpstream ||
+ previous.upstreamName !== current.upstreamName ||
+ previous.ahead !== current.ahead ||
+ previous.behind !== current.behind)
+ )
+}
+
export function shouldShowCompareSummary(summary: GitBranchCompareSummary | null): boolean {
if (!summary || summary.status === 'loading') {
return true
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts
index 59674375332..51a4fa49b92 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts
@@ -416,6 +416,50 @@ describe('resolvePrimaryAction Create PR intent', () => {
})
})
+ it('keeps stale Create PR eligibility disabled while a newer preflight is loading', () => {
+ expect(
+ resolveCreatePrHeaderAction(
+ inputs({
+ hostedReviewCreation: {
+ provider: 'github',
+ review: null,
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null
+ },
+ isHostedReviewCreationLoading: true
+ })
+ )
+ ).toEqual({
+ kind: 'create_pr',
+ label: 'Create PR',
+ title: 'Checking whether this branch can create a pull request…',
+ disabled: true
+ })
+ })
+
+ it('keeps stale Create MR eligibility disabled with provider-aware loading copy', () => {
+ expect(
+ resolveCreatePrHeaderAction(
+ inputs({
+ hostedReviewCreation: {
+ provider: 'gitlab',
+ review: null,
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null
+ },
+ isHostedReviewCreationLoading: true
+ })
+ )
+ ).toEqual({
+ kind: 'create_pr',
+ label: 'Create MR',
+ title: 'Checking whether this branch can create a merge request…',
+ disabled: true
+ })
+ })
+
it('returns a clickable Create PR header notice path when the branch has nothing to publish yet', () => {
expect(
resolveCreatePrHeaderAction(
From 47f66f53f5b82d0b8dd9491b65201d17eac4d664 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 27 Jun 2026 22:01:16 -0700
Subject: [PATCH 25/54] chore(deps): bump vite/esbuild/dompurify to clear
Dependabot alerts (#6538)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolve security advisories without pnpm overrides:
Root:
- vite 7.3.5 -> 7.3.6 (widened esbuild range to ^0.27 || ^0.28)
- esbuild 0.27.7 -> 0.28.1 (GHSA dev-server advisory)
- dompurify 3.4.10 -> 3.4.11 (direct devDep)
Mobile:
- tsx ^4.21.0 -> ^4.22.4 (only hard esbuild consumer; pulls esbuild 0.28.1)
- vitest ^4.1.5 -> ^4.1.9, add explicit vite ^8.0.16 (-> 8.1.0)
Verified: typecheck:web, build:web, and full vitest suite (21246 passed)
all green.
Deferred (override-only, no plain-update path exists):
- dompurify 3.2.7 x18 — monaco-editor@0.55.1 hard-pins it exactly
- js-yaml@3 — @istanbuljs/load-nyc-config; uuid@7 — xcode@3.0.1;
postcss@8.4 — @expo/metro-config (Expo SDK 55 lock)
Co-authored-by: Orca
---
mobile/package.json | 5 +-
mobile/pnpm-lock.yaml | 567 ++++++++++++++++++++----------------------
package.json | 4 +-
pnpm-lock.yaml | 268 ++++++++++----------
4 files changed, 406 insertions(+), 438 deletions(-)
diff --git a/mobile/package.json b/mobile/package.json
index d83b6795bc6..5c307a3eab8 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -64,8 +64,9 @@
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
- "tsx": "^4.21.0",
+ "tsx": "^4.22.4",
"typescript": "^5.9.3",
- "vitest": "^4.1.5"
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
}
}
diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml
index 700bcc09d05..9c32c13179c 100644
--- a/mobile/pnpm-lock.yaml
+++ b/mobile/pnpm-lock.yaml
@@ -140,7 +140,7 @@ importers:
version: 8.18.1
expo-module-scripts:
specifier: ^55.0.2
- version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.7)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
+ version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
oxfmt:
specifier: ^0.52.0
version: 0.52.0
@@ -148,14 +148,17 @@ importers:
specifier: ^1.67.0
version: 1.67.0
tsx:
- specifier: ^4.21.0
- version: 4.21.0
+ specifier: ^4.22.4
+ version: 4.22.4
typescript:
specifier: ^5.9.3
version: 5.9.3
+ vite:
+ specifier: ^8.0.16
+ version: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
vitest:
- specifier: ^4.1.5
- version: 4.1.6(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.0.12(@types/node@25.9.3)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0))
+ specifier: ^4.1.9
+ version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -1139,167 +1142,167 @@ packages:
resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==}
engines: {node: '>=0.8.0'}
- '@emnapi/core@1.10.0':
- resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+ '@emnapi/core@1.11.1':
+ resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
- '@emnapi/runtime@1.10.0':
- resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+ '@emnapi/runtime@1.11.1':
+ resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
- '@emnapi/wasi-threads@1.2.1':
- resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+ '@emnapi/wasi-threads@1.2.2':
+ resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
- '@esbuild/aix-ppc64@0.27.7':
- resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+ '@esbuild/aix-ppc64@0.28.1':
+ resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
- '@esbuild/android-arm64@0.27.7':
- resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+ '@esbuild/android-arm64@0.28.1':
+ resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
- '@esbuild/android-arm@0.27.7':
- resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+ '@esbuild/android-arm@0.28.1':
+ resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
- '@esbuild/android-x64@0.27.7':
- resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+ '@esbuild/android-x64@0.28.1':
+ resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
- '@esbuild/darwin-arm64@0.27.7':
- resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+ '@esbuild/darwin-arm64@0.28.1':
+ resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-x64@0.27.7':
- resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+ '@esbuild/darwin-x64@0.28.1':
+ resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
- '@esbuild/freebsd-arm64@0.27.7':
- resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+ '@esbuild/freebsd-arm64@0.28.1':
+ resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.27.7':
- resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+ '@esbuild/freebsd-x64@0.28.1':
+ resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
- '@esbuild/linux-arm64@0.27.7':
- resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+ '@esbuild/linux-arm64@0.28.1':
+ resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm@0.27.7':
- resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+ '@esbuild/linux-arm@0.28.1':
+ resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
- '@esbuild/linux-ia32@0.27.7':
- resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+ '@esbuild/linux-ia32@0.28.1':
+ resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
- '@esbuild/linux-loong64@0.27.7':
- resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+ '@esbuild/linux-loong64@0.28.1':
+ resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
- '@esbuild/linux-mips64el@0.27.7':
- resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+ '@esbuild/linux-mips64el@0.28.1':
+ resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-ppc64@0.27.7':
- resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+ '@esbuild/linux-ppc64@0.28.1':
+ resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-riscv64@0.27.7':
- resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+ '@esbuild/linux-riscv64@0.28.1':
+ resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-s390x@0.27.7':
- resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+ '@esbuild/linux-s390x@0.28.1':
+ resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
- '@esbuild/linux-x64@0.27.7':
- resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+ '@esbuild/linux-x64@0.28.1':
+ resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
- '@esbuild/netbsd-arm64@0.27.7':
- resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+ '@esbuild/netbsd-arm64@0.28.1':
+ resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
- '@esbuild/netbsd-x64@0.27.7':
- resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+ '@esbuild/netbsd-x64@0.28.1':
+ resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
- '@esbuild/openbsd-arm64@0.27.7':
- resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+ '@esbuild/openbsd-arm64@0.28.1':
+ resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
- '@esbuild/openbsd-x64@0.27.7':
- resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+ '@esbuild/openbsd-x64@0.28.1':
+ resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
- '@esbuild/openharmony-arm64@0.27.7':
- resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+ '@esbuild/openharmony-arm64@0.28.1':
+ resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
- '@esbuild/sunos-x64@0.27.7':
- resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+ '@esbuild/sunos-x64@0.28.1':
+ resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
- '@esbuild/win32-arm64@0.27.7':
- resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+ '@esbuild/win32-arm64@0.28.1':
+ resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
- '@esbuild/win32-ia32@0.27.7':
- resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ '@esbuild/win32-ia32@0.28.1':
+ resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
- '@esbuild/win32-x64@0.27.7':
- resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+ '@esbuild/win32-x64@0.28.1':
+ resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -1655,8 +1658,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
- '@napi-rs/wasm-runtime@1.1.5':
- resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
+ '@napi-rs/wasm-runtime@1.1.6':
+ resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
@@ -1671,8 +1674,8 @@ packages:
react: '*'
react-native: '*'
- '@oxc-project/types@0.129.0':
- resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
+ '@oxc-project/types@0.137.0':
+ resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==}
'@oxfmt/binding-android-arm-eabi@0.52.0':
resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==}
@@ -1721,56 +1724,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1843,56 +1838,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.67.0':
resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.67.0':
resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.67.0':
resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.67.0':
resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.67.0':
resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.67.0':
resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.67.0':
resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@oxlint/binding-openharmony-arm64@1.67.0':
resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==}
@@ -2319,97 +2306,91 @@ packages:
'@react-navigation/routers@7.5.3':
resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==}
- '@rolldown/binding-android-arm64@1.0.0':
- resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
+ '@rolldown/binding-android-arm64@1.1.3':
+ resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rolldown/binding-darwin-arm64@1.0.0':
- resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==}
+ '@rolldown/binding-darwin-arm64@1.1.3':
+ resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.0.0':
- resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==}
+ '@rolldown/binding-darwin-x64@1.1.3':
+ resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-freebsd-x64@1.0.0':
- resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==}
+ '@rolldown/binding-freebsd-x64@1.1.3':
+ resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0':
- resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==}
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.3':
+ resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.0.0':
- resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==}
+ '@rolldown/binding-linux-arm64-gnu@1.1.3':
+ resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
- '@rolldown/binding-linux-arm64-musl@1.0.0':
- resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
+ '@rolldown/binding-linux-arm64-musl@1.1.3':
+ resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
- '@rolldown/binding-linux-ppc64-gnu@1.0.0':
- resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
+ '@rolldown/binding-linux-ppc64-gnu@1.1.3':
+ resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
- '@rolldown/binding-linux-s390x-gnu@1.0.0':
- resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
+ '@rolldown/binding-linux-s390x-gnu@1.1.3':
+ resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
- '@rolldown/binding-linux-x64-gnu@1.0.0':
- resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
+ '@rolldown/binding-linux-x64-gnu@1.1.3':
+ resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
- '@rolldown/binding-linux-x64-musl@1.0.0':
- resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
+ '@rolldown/binding-linux-x64-musl@1.1.3':
+ resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
- '@rolldown/binding-openharmony-arm64@1.0.0':
- resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
+ '@rolldown/binding-openharmony-arm64@1.1.3':
+ resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-wasm32-wasi@1.0.0':
- resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==}
+ '@rolldown/binding-wasm32-wasi@1.1.3':
+ resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
- '@rolldown/binding-win32-arm64-msvc@1.0.0':
- resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==}
+ '@rolldown/binding-win32-arm64-msvc@1.1.3':
+ resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.0.0':
- resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==}
+ '@rolldown/binding-win32-x64-msvc@1.1.3':
+ resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -2454,8 +2435,8 @@ packages:
'@tsconfig/node18@18.2.6':
resolution: {integrity: sha512-eAWQzAjPj18tKnDzmWstz4OyWewLUNBm9tdoN9LayzoboRktYx3Enk1ZXPmThj55L7c4VWYq/Bzq0A51znZfhw==}
- '@tybys/wasm-util@0.10.2':
- resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2607,11 +2588,11 @@ packages:
'@ungap/structured-clone@1.3.1':
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
- '@vitest/expect@4.1.6':
- resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
+ '@vitest/expect@4.1.9':
+ resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
- '@vitest/mocker@4.1.6':
- resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==}
+ '@vitest/mocker@4.1.9':
+ resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2621,20 +2602,20 @@ packages:
vite:
optional: true
- '@vitest/pretty-format@4.1.6':
- resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==}
+ '@vitest/pretty-format@4.1.9':
+ resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
- '@vitest/runner@4.1.6':
- resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==}
+ '@vitest/runner@4.1.9':
+ resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
- '@vitest/snapshot@4.1.6':
- resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==}
+ '@vitest/snapshot@4.1.9':
+ resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
- '@vitest/spy@4.1.6':
- resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==}
+ '@vitest/spy@4.1.9':
+ resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
- '@vitest/utils@4.1.6':
- resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
+ '@vitest/utils@4.1.9':
+ resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
@@ -3369,8 +3350,8 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
- esbuild@0.27.7:
- resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+ esbuild@0.28.1:
+ resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
@@ -4651,28 +4632,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -5540,8 +5517,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
- rolldown@1.0.0:
- resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==}
+ rolldown@1.1.3:
+ resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -5903,10 +5880,6 @@ packages:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
- tinyglobby@0.2.16:
- resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
- engines: {node: '>=12.0.0'}
-
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -5982,8 +5955,8 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
- tsx@4.21.0:
- resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
+ tsx@4.22.4:
+ resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -6138,13 +6111,13 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
- vite@8.0.12:
- resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==}
+ vite@8.1.0:
+ resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
- '@vitejs/devtools': ^0.1.18
+ '@vitejs/devtools': ^0.3.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
@@ -6181,20 +6154,20 @@ packages:
yaml:
optional: true
- vitest@4.1.6:
- resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==}
+ vitest@4.1.9:
+ resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
- '@vitest/browser-playwright': 4.1.6
- '@vitest/browser-preview': 4.1.6
- '@vitest/browser-webdriverio': 4.1.6
- '@vitest/coverage-istanbul': 4.1.6
- '@vitest/coverage-v8': 4.1.6
- '@vitest/ui': 4.1.6
+ '@vitest/browser-playwright': 4.1.9
+ '@vitest/browser-preview': 4.1.9
+ '@vitest/browser-webdriverio': 4.1.9
+ '@vitest/coverage-istanbul': 4.1.9
+ '@vitest/coverage-v8': 4.1.9
+ '@vitest/ui': 4.1.9
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -7649,98 +7622,98 @@ snapshots:
dependencies:
'@types/hammerjs': 2.0.46
- '@emnapi/core@1.10.0':
+ '@emnapi/core@1.11.1':
dependencies:
- '@emnapi/wasi-threads': 1.2.1
+ '@emnapi/wasi-threads': 1.2.2
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.10.0':
+ '@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
- '@emnapi/wasi-threads@1.2.1':
+ '@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
optional: true
- '@esbuild/aix-ppc64@0.27.7':
+ '@esbuild/aix-ppc64@0.28.1':
optional: true
- '@esbuild/android-arm64@0.27.7':
+ '@esbuild/android-arm64@0.28.1':
optional: true
- '@esbuild/android-arm@0.27.7':
+ '@esbuild/android-arm@0.28.1':
optional: true
- '@esbuild/android-x64@0.27.7':
+ '@esbuild/android-x64@0.28.1':
optional: true
- '@esbuild/darwin-arm64@0.27.7':
+ '@esbuild/darwin-arm64@0.28.1':
optional: true
- '@esbuild/darwin-x64@0.27.7':
+ '@esbuild/darwin-x64@0.28.1':
optional: true
- '@esbuild/freebsd-arm64@0.27.7':
+ '@esbuild/freebsd-arm64@0.28.1':
optional: true
- '@esbuild/freebsd-x64@0.27.7':
+ '@esbuild/freebsd-x64@0.28.1':
optional: true
- '@esbuild/linux-arm64@0.27.7':
+ '@esbuild/linux-arm64@0.28.1':
optional: true
- '@esbuild/linux-arm@0.27.7':
+ '@esbuild/linux-arm@0.28.1':
optional: true
- '@esbuild/linux-ia32@0.27.7':
+ '@esbuild/linux-ia32@0.28.1':
optional: true
- '@esbuild/linux-loong64@0.27.7':
+ '@esbuild/linux-loong64@0.28.1':
optional: true
- '@esbuild/linux-mips64el@0.27.7':
+ '@esbuild/linux-mips64el@0.28.1':
optional: true
- '@esbuild/linux-ppc64@0.27.7':
+ '@esbuild/linux-ppc64@0.28.1':
optional: true
- '@esbuild/linux-riscv64@0.27.7':
+ '@esbuild/linux-riscv64@0.28.1':
optional: true
- '@esbuild/linux-s390x@0.27.7':
+ '@esbuild/linux-s390x@0.28.1':
optional: true
- '@esbuild/linux-x64@0.27.7':
+ '@esbuild/linux-x64@0.28.1':
optional: true
- '@esbuild/netbsd-arm64@0.27.7':
+ '@esbuild/netbsd-arm64@0.28.1':
optional: true
- '@esbuild/netbsd-x64@0.27.7':
+ '@esbuild/netbsd-x64@0.28.1':
optional: true
- '@esbuild/openbsd-arm64@0.27.7':
+ '@esbuild/openbsd-arm64@0.28.1':
optional: true
- '@esbuild/openbsd-x64@0.27.7':
+ '@esbuild/openbsd-x64@0.28.1':
optional: true
- '@esbuild/openharmony-arm64@0.27.7':
+ '@esbuild/openharmony-arm64@0.28.1':
optional: true
- '@esbuild/sunos-x64@0.27.7':
+ '@esbuild/sunos-x64@0.28.1':
optional: true
- '@esbuild/win32-arm64@0.27.7':
+ '@esbuild/win32-arm64@0.28.1':
optional: true
- '@esbuild/win32-ia32@0.27.7':
+ '@esbuild/win32-ia32@0.28.1':
optional: true
- '@esbuild/win32-x64@0.27.7':
+ '@esbuild/win32-x64@0.28.1':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
@@ -8375,11 +8348,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
- '@emnapi/core': 1.10.0
- '@emnapi/runtime': 1.10.0
- '@tybys/wasm-util': 0.10.2
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@tybys/wasm-util': 0.10.3
optional: true
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
@@ -8391,7 +8364,7 @@ snapshots:
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
- '@oxc-project/types@0.129.0': {}
+ '@oxc-project/types@0.137.0': {}
'@oxfmt/binding-android-arm-eabi@0.52.0':
optional: true
@@ -9015,53 +8988,53 @@ snapshots:
dependencies:
nanoid: 3.3.12
- '@rolldown/binding-android-arm64@1.0.0':
+ '@rolldown/binding-android-arm64@1.1.3':
optional: true
- '@rolldown/binding-darwin-arm64@1.0.0':
+ '@rolldown/binding-darwin-arm64@1.1.3':
optional: true
- '@rolldown/binding-darwin-x64@1.0.0':
+ '@rolldown/binding-darwin-x64@1.1.3':
optional: true
- '@rolldown/binding-freebsd-x64@1.0.0':
+ '@rolldown/binding-freebsd-x64@1.1.3':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0':
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.3':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.0.0':
+ '@rolldown/binding-linux-arm64-gnu@1.1.3':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.0.0':
+ '@rolldown/binding-linux-arm64-musl@1.1.3':
optional: true
- '@rolldown/binding-linux-ppc64-gnu@1.0.0':
+ '@rolldown/binding-linux-ppc64-gnu@1.1.3':
optional: true
- '@rolldown/binding-linux-s390x-gnu@1.0.0':
+ '@rolldown/binding-linux-s390x-gnu@1.1.3':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.0.0':
+ '@rolldown/binding-linux-x64-gnu@1.1.3':
optional: true
- '@rolldown/binding-linux-x64-musl@1.0.0':
+ '@rolldown/binding-linux-x64-musl@1.1.3':
optional: true
- '@rolldown/binding-openharmony-arm64@1.0.0':
+ '@rolldown/binding-openharmony-arm64@1.1.3':
optional: true
- '@rolldown/binding-wasm32-wasi@1.0.0':
+ '@rolldown/binding-wasm32-wasi@1.1.3':
dependencies:
- '@emnapi/core': 1.10.0
- '@emnapi/runtime': 1.10.0
- '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.0.0':
+ '@rolldown/binding-win32-arm64-msvc@1.1.3':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.0.0':
+ '@rolldown/binding-win32-x64-msvc@1.1.3':
optional: true
'@rolldown/pluginutils@1.0.0': {}
@@ -9098,7 +9071,7 @@ snapshots:
'@tsconfig/node18@18.2.6': {}
- '@tybys/wasm-util@0.10.2':
+ '@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
optional: true
@@ -9284,7 +9257,7 @@ snapshots:
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -9308,44 +9281,44 @@ snapshots:
'@ungap/structured-clone@1.3.1': {}
- '@vitest/expect@4.1.6':
+ '@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
- '@vitest/spy': 4.1.6
- '@vitest/utils': 4.1.6
+ '@vitest/spy': 4.1.9
+ '@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.6(vite@8.0.12(@types/node@25.9.3)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
- '@vitest/spy': 4.1.6
+ '@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.0.12(@types/node@25.9.3)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
- '@vitest/pretty-format@4.1.6':
+ '@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
- '@vitest/runner@4.1.6':
+ '@vitest/runner@4.1.9':
dependencies:
- '@vitest/utils': 4.1.6
+ '@vitest/utils': 4.1.9
pathe: 2.0.3
- '@vitest/snapshot@4.1.6':
+ '@vitest/snapshot@4.1.9':
dependencies:
- '@vitest/pretty-format': 4.1.6
- '@vitest/utils': 4.1.6
+ '@vitest/pretty-format': 4.1.9
+ '@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
- '@vitest/spy@4.1.6': {}
+ '@vitest/spy@4.1.9': {}
- '@vitest/utils@4.1.6':
+ '@vitest/utils@4.1.9':
dependencies:
- '@vitest/pretty-format': 4.1.6
+ '@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
@@ -10225,34 +10198,34 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
- esbuild@0.27.7:
+ esbuild@0.28.1:
optionalDependencies:
- '@esbuild/aix-ppc64': 0.27.7
- '@esbuild/android-arm': 0.27.7
- '@esbuild/android-arm64': 0.27.7
- '@esbuild/android-x64': 0.27.7
- '@esbuild/darwin-arm64': 0.27.7
- '@esbuild/darwin-x64': 0.27.7
- '@esbuild/freebsd-arm64': 0.27.7
- '@esbuild/freebsd-x64': 0.27.7
- '@esbuild/linux-arm': 0.27.7
- '@esbuild/linux-arm64': 0.27.7
- '@esbuild/linux-ia32': 0.27.7
- '@esbuild/linux-loong64': 0.27.7
- '@esbuild/linux-mips64el': 0.27.7
- '@esbuild/linux-ppc64': 0.27.7
- '@esbuild/linux-riscv64': 0.27.7
- '@esbuild/linux-s390x': 0.27.7
- '@esbuild/linux-x64': 0.27.7
- '@esbuild/netbsd-arm64': 0.27.7
- '@esbuild/netbsd-x64': 0.27.7
- '@esbuild/openbsd-arm64': 0.27.7
- '@esbuild/openbsd-x64': 0.27.7
- '@esbuild/openharmony-arm64': 0.27.7
- '@esbuild/sunos-x64': 0.27.7
- '@esbuild/win32-arm64': 0.27.7
- '@esbuild/win32-ia32': 0.27.7
- '@esbuild/win32-x64': 0.27.7
+ '@esbuild/aix-ppc64': 0.28.1
+ '@esbuild/android-arm': 0.28.1
+ '@esbuild/android-arm64': 0.28.1
+ '@esbuild/android-x64': 0.28.1
+ '@esbuild/darwin-arm64': 0.28.1
+ '@esbuild/darwin-x64': 0.28.1
+ '@esbuild/freebsd-arm64': 0.28.1
+ '@esbuild/freebsd-x64': 0.28.1
+ '@esbuild/linux-arm': 0.28.1
+ '@esbuild/linux-arm64': 0.28.1
+ '@esbuild/linux-ia32': 0.28.1
+ '@esbuild/linux-loong64': 0.28.1
+ '@esbuild/linux-mips64el': 0.28.1
+ '@esbuild/linux-ppc64': 0.28.1
+ '@esbuild/linux-riscv64': 0.28.1
+ '@esbuild/linux-s390x': 0.28.1
+ '@esbuild/linux-x64': 0.28.1
+ '@esbuild/netbsd-arm64': 0.28.1
+ '@esbuild/netbsd-x64': 0.28.1
+ '@esbuild/openbsd-arm64': 0.28.1
+ '@esbuild/openbsd-x64': 0.28.1
+ '@esbuild/openharmony-arm64': 0.28.1
+ '@esbuild/sunos-x64': 0.28.1
+ '@esbuild/win32-arm64': 0.28.1
+ '@esbuild/win32-ia32': 0.28.1
+ '@esbuild/win32-x64': 0.28.1
escalade@3.2.0: {}
@@ -10678,7 +10651,7 @@ snapshots:
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
- expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.7)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
+ expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -10698,7 +10671,7 @@ snapshots:
jest-snapshot-prettier: prettier@2.8.8
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3))
resolve-workspace-root: 2.0.1
- ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.7)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
+ ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- '@babel/core'
@@ -13075,26 +13048,26 @@ snapshots:
dependencies:
glob: 7.2.3
- rolldown@1.0.0:
+ rolldown@1.1.3:
dependencies:
- '@oxc-project/types': 0.129.0
+ '@oxc-project/types': 0.137.0
'@rolldown/pluginutils': 1.0.0
optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0
- '@rolldown/binding-darwin-arm64': 1.0.0
- '@rolldown/binding-darwin-x64': 1.0.0
- '@rolldown/binding-freebsd-x64': 1.0.0
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0
- '@rolldown/binding-linux-arm64-gnu': 1.0.0
- '@rolldown/binding-linux-arm64-musl': 1.0.0
- '@rolldown/binding-linux-ppc64-gnu': 1.0.0
- '@rolldown/binding-linux-s390x-gnu': 1.0.0
- '@rolldown/binding-linux-x64-gnu': 1.0.0
- '@rolldown/binding-linux-x64-musl': 1.0.0
- '@rolldown/binding-openharmony-arm64': 1.0.0
- '@rolldown/binding-wasm32-wasi': 1.0.0
- '@rolldown/binding-win32-arm64-msvc': 1.0.0
- '@rolldown/binding-win32-x64-msvc': 1.0.0
+ '@rolldown/binding-android-arm64': 1.1.3
+ '@rolldown/binding-darwin-arm64': 1.1.3
+ '@rolldown/binding-darwin-x64': 1.1.3
+ '@rolldown/binding-freebsd-x64': 1.1.3
+ '@rolldown/binding-linux-arm-gnueabihf': 1.1.3
+ '@rolldown/binding-linux-arm64-gnu': 1.1.3
+ '@rolldown/binding-linux-arm64-musl': 1.1.3
+ '@rolldown/binding-linux-ppc64-gnu': 1.1.3
+ '@rolldown/binding-linux-s390x-gnu': 1.1.3
+ '@rolldown/binding-linux-x64-gnu': 1.1.3
+ '@rolldown/binding-linux-x64-musl': 1.1.3
+ '@rolldown/binding-openharmony-arm64': 1.1.3
+ '@rolldown/binding-wasm32-wasi': 1.1.3
+ '@rolldown/binding-win32-arm64-msvc': 1.1.3
+ '@rolldown/binding-win32-x64-msvc': 1.1.3
safe-array-concat@1.1.4:
dependencies:
@@ -13470,11 +13443,6 @@ snapshots:
tinyexec@1.1.2: {}
- tinyglobby@0.2.16:
- dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
-
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
@@ -13516,7 +13484,7 @@ snapshots:
picomatch: 4.0.4
typescript: 5.9.3
- ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.7)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
+ ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
@@ -13532,7 +13500,7 @@ snapshots:
'@babel/core': 7.29.7
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.29.7)
- esbuild: 0.27.7
+ esbuild: 0.28.1
tsconfig-paths@3.15.0:
dependencies:
@@ -13543,10 +13511,9 @@ snapshots:
tslib@2.8.1: {}
- tsx@4.21.0:
+ tsx@4.22.4:
dependencies:
- esbuild: 0.27.7
- get-tsconfig: 4.14.0
+ esbuild: 0.28.1
optionalDependencies:
fsevents: 2.3.3
@@ -13690,30 +13657,30 @@ snapshots:
- '@types/react'
- '@types/react-dom'
- vite@8.0.12(@types/node@25.9.3)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0):
+ vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
- rolldown: 1.0.0
+ rolldown: 1.1.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 25.9.3
- esbuild: 0.27.7
+ esbuild: 0.28.1
fsevents: 2.3.3
terser: 5.48.0
- tsx: 4.21.0
+ tsx: 4.22.4
yaml: 2.9.0
- vitest@4.1.6(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.0.12(@types/node@25.9.3)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0)):
+ vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
- '@vitest/expect': 4.1.6
- '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@25.9.3)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0))
- '@vitest/pretty-format': 4.1.6
- '@vitest/runner': 4.1.6
- '@vitest/snapshot': 4.1.6
- '@vitest/spy': 4.1.6
- '@vitest/utils': 4.1.6
+ '@vitest/expect': 4.1.9
+ '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
+ '@vitest/pretty-format': 4.1.9
+ '@vitest/runner': 4.1.9
+ '@vitest/snapshot': 4.1.9
+ '@vitest/spy': 4.1.9
+ '@vitest/utils': 4.1.9
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
@@ -13723,9 +13690,9 @@ snapshots:
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 8.0.12(@types/node@25.9.3)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.9.3
diff --git a/package.json b/package.json
index 9dd5552400b..67e3ad8fcde 100644
--- a/package.json
+++ b/package.json
@@ -155,7 +155,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
- "dompurify": "^3.4.10",
+ "dompurify": "^3.4.11",
"electron": "^42.3.3",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
@@ -197,7 +197,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"unified": "^11.0.5",
- "vite": "^7.3.5",
+ "vite": "^7.3.6",
"vitest": "^4.1.5",
"vscode-oniguruma": "^2.0.1",
"vscode-textmate": "^9.3.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 265b68777ab..989ae79cd84 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -106,7 +106,7 @@ importers:
version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3)
'@tailwindcss/vite':
specifier: ^4.2.4
- version: 4.2.4(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ version: 4.2.4(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@tanstack/react-virtual':
specifier: ^3.13.24
version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -181,7 +181,7 @@ importers:
version: 7.0.0-dev.20260421.2
'@vitejs/plugin-react':
specifier: ^5.2.0
- version: 5.2.0(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ version: 5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)
@@ -213,8 +213,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
dompurify:
- specifier: ^3.4.10
- version: 3.4.10
+ specifier: ^3.4.11
+ version: 3.4.11
electron:
specifier: ^42.3.3
version: 42.3.3
@@ -223,7 +223,7 @@ importers:
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
electron-vite:
specifier: ^5.0.0
- version: 5.0.0(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ version: 5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
emoji-picker-react:
specifier: ^4.19.1
version: 4.19.1(react@19.2.5)
@@ -339,11 +339,11 @@ importers:
specifier: ^11.0.5
version: 11.0.5
vite:
- specifier: ^7.3.5
- version: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ specifier: ^7.3.6
+ version: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
vitest:
specifier: ^4.1.5
- version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
vscode-oniguruma:
specifier: ^2.0.1
version: 2.0.1
@@ -683,8 +683,8 @@ packages:
cpu: [ppc64]
os: [aix]
- '@esbuild/aix-ppc64@0.27.7':
- resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+ '@esbuild/aix-ppc64@0.28.1':
+ resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
@@ -695,8 +695,8 @@ packages:
cpu: [arm64]
os: [android]
- '@esbuild/android-arm64@0.27.7':
- resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+ '@esbuild/android-arm64@0.28.1':
+ resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
@@ -707,8 +707,8 @@ packages:
cpu: [arm]
os: [android]
- '@esbuild/android-arm@0.27.7':
- resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+ '@esbuild/android-arm@0.28.1':
+ resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
@@ -719,8 +719,8 @@ packages:
cpu: [x64]
os: [android]
- '@esbuild/android-x64@0.27.7':
- resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+ '@esbuild/android-x64@0.28.1':
+ resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
@@ -731,8 +731,8 @@ packages:
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-arm64@0.27.7':
- resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+ '@esbuild/darwin-arm64@0.28.1':
+ resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
@@ -743,8 +743,8 @@ packages:
cpu: [x64]
os: [darwin]
- '@esbuild/darwin-x64@0.27.7':
- resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+ '@esbuild/darwin-x64@0.28.1':
+ resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
@@ -755,8 +755,8 @@ packages:
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-arm64@0.27.7':
- resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+ '@esbuild/freebsd-arm64@0.28.1':
+ resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
@@ -767,8 +767,8 @@ packages:
cpu: [x64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.27.7':
- resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+ '@esbuild/freebsd-x64@0.28.1':
+ resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
@@ -779,8 +779,8 @@ packages:
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm64@0.27.7':
- resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+ '@esbuild/linux-arm64@0.28.1':
+ resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
@@ -791,8 +791,8 @@ packages:
cpu: [arm]
os: [linux]
- '@esbuild/linux-arm@0.27.7':
- resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+ '@esbuild/linux-arm@0.28.1':
+ resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
@@ -803,8 +803,8 @@ packages:
cpu: [ia32]
os: [linux]
- '@esbuild/linux-ia32@0.27.7':
- resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+ '@esbuild/linux-ia32@0.28.1':
+ resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
@@ -815,8 +815,8 @@ packages:
cpu: [loong64]
os: [linux]
- '@esbuild/linux-loong64@0.27.7':
- resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+ '@esbuild/linux-loong64@0.28.1':
+ resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
@@ -827,8 +827,8 @@ packages:
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-mips64el@0.27.7':
- resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+ '@esbuild/linux-mips64el@0.28.1':
+ resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
@@ -839,8 +839,8 @@ packages:
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-ppc64@0.27.7':
- resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+ '@esbuild/linux-ppc64@0.28.1':
+ resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
@@ -851,8 +851,8 @@ packages:
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-riscv64@0.27.7':
- resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+ '@esbuild/linux-riscv64@0.28.1':
+ resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
@@ -863,8 +863,8 @@ packages:
cpu: [s390x]
os: [linux]
- '@esbuild/linux-s390x@0.27.7':
- resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+ '@esbuild/linux-s390x@0.28.1':
+ resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
@@ -875,8 +875,8 @@ packages:
cpu: [x64]
os: [linux]
- '@esbuild/linux-x64@0.27.7':
- resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+ '@esbuild/linux-x64@0.28.1':
+ resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
@@ -887,8 +887,8 @@ packages:
cpu: [arm64]
os: [netbsd]
- '@esbuild/netbsd-arm64@0.27.7':
- resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+ '@esbuild/netbsd-arm64@0.28.1':
+ resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
@@ -899,8 +899,8 @@ packages:
cpu: [x64]
os: [netbsd]
- '@esbuild/netbsd-x64@0.27.7':
- resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+ '@esbuild/netbsd-x64@0.28.1':
+ resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
@@ -911,8 +911,8 @@ packages:
cpu: [arm64]
os: [openbsd]
- '@esbuild/openbsd-arm64@0.27.7':
- resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+ '@esbuild/openbsd-arm64@0.28.1':
+ resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
@@ -923,8 +923,8 @@ packages:
cpu: [x64]
os: [openbsd]
- '@esbuild/openbsd-x64@0.27.7':
- resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+ '@esbuild/openbsd-x64@0.28.1':
+ resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
@@ -935,8 +935,8 @@ packages:
cpu: [arm64]
os: [openharmony]
- '@esbuild/openharmony-arm64@0.27.7':
- resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+ '@esbuild/openharmony-arm64@0.28.1':
+ resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
@@ -947,8 +947,8 @@ packages:
cpu: [x64]
os: [sunos]
- '@esbuild/sunos-x64@0.27.7':
- resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+ '@esbuild/sunos-x64@0.28.1':
+ resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
@@ -959,8 +959,8 @@ packages:
cpu: [arm64]
os: [win32]
- '@esbuild/win32-arm64@0.27.7':
- resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+ '@esbuild/win32-arm64@0.28.1':
+ resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
@@ -971,8 +971,8 @@ packages:
cpu: [ia32]
os: [win32]
- '@esbuild/win32-ia32@0.27.7':
- resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ '@esbuild/win32-ia32@0.28.1':
+ resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
@@ -983,8 +983,8 @@ packages:
cpu: [x64]
os: [win32]
- '@esbuild/win32-x64@0.27.7':
- resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+ '@esbuild/win32-x64@0.28.1':
+ resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -3878,8 +3878,8 @@ packages:
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
- dompurify@3.4.10:
- resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==}
+ dompurify@3.4.11:
+ resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
@@ -4025,8 +4025,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
- esbuild@0.27.7:
- resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+ esbuild@0.28.1:
+ resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
@@ -6314,8 +6314,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@7.3.5:
- resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==}
+ vite@7.3.6:
+ resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -7002,157 +7002,157 @@ snapshots:
'@esbuild/aix-ppc64@0.25.12':
optional: true
- '@esbuild/aix-ppc64@0.27.7':
+ '@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
- '@esbuild/android-arm64@0.27.7':
+ '@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
- '@esbuild/android-arm@0.27.7':
+ '@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
- '@esbuild/android-x64@0.27.7':
+ '@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
- '@esbuild/darwin-arm64@0.27.7':
+ '@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
- '@esbuild/darwin-x64@0.27.7':
+ '@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
- '@esbuild/freebsd-arm64@0.27.7':
+ '@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
- '@esbuild/freebsd-x64@0.27.7':
+ '@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
- '@esbuild/linux-arm64@0.27.7':
+ '@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
- '@esbuild/linux-arm@0.27.7':
+ '@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
- '@esbuild/linux-ia32@0.27.7':
+ '@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
- '@esbuild/linux-loong64@0.27.7':
+ '@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
- '@esbuild/linux-mips64el@0.27.7':
+ '@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
- '@esbuild/linux-ppc64@0.27.7':
+ '@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
- '@esbuild/linux-riscv64@0.27.7':
+ '@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
- '@esbuild/linux-s390x@0.27.7':
+ '@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
- '@esbuild/linux-x64@0.27.7':
+ '@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
- '@esbuild/netbsd-arm64@0.27.7':
+ '@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
- '@esbuild/netbsd-x64@0.27.7':
+ '@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
- '@esbuild/openbsd-arm64@0.27.7':
+ '@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
- '@esbuild/openbsd-x64@0.27.7':
+ '@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
- '@esbuild/openharmony-arm64@0.27.7':
+ '@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
- '@esbuild/sunos-x64@0.27.7':
+ '@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
- '@esbuild/win32-arm64@0.27.7':
+ '@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
- '@esbuild/win32-ia32@0.27.7':
+ '@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
- '@esbuild/win32-x64@0.27.7':
+ '@esbuild/win32-x64@0.28.1':
optional: true
'@floating-ui/core@1.7.5':
@@ -8533,12 +8533,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
'@tailwindcss/oxide-win32-x64-msvc': 4.2.4
- '@tailwindcss/vite@4.2.4(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
+ '@tailwindcss/vite@4.2.4(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
dependencies:
'@tailwindcss/node': 4.2.4
'@tailwindcss/oxide': 4.2.4
tailwindcss: 4.2.4
- vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
'@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
@@ -9087,7 +9087,7 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
- '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
+ '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7)
@@ -9095,7 +9095,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
- vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
@@ -9108,14 +9108,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
+ '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.3(@types/node@25.6.0)(typescript@5.9.3)
- vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
'@vitest/pretty-format@4.1.5':
dependencies:
@@ -9911,7 +9911,7 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
- dompurify@3.4.10:
+ dompurify@3.4.11:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -9995,7 +9995,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- electron-vite@5.0.0(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
+ electron-vite@5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7)
@@ -10003,7 +10003,7 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
- vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
@@ -10114,34 +10114,34 @@ snapshots:
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
- esbuild@0.27.7:
+ esbuild@0.28.1:
optionalDependencies:
- '@esbuild/aix-ppc64': 0.27.7
- '@esbuild/android-arm': 0.27.7
- '@esbuild/android-arm64': 0.27.7
- '@esbuild/android-x64': 0.27.7
- '@esbuild/darwin-arm64': 0.27.7
- '@esbuild/darwin-x64': 0.27.7
- '@esbuild/freebsd-arm64': 0.27.7
- '@esbuild/freebsd-x64': 0.27.7
- '@esbuild/linux-arm': 0.27.7
- '@esbuild/linux-arm64': 0.27.7
- '@esbuild/linux-ia32': 0.27.7
- '@esbuild/linux-loong64': 0.27.7
- '@esbuild/linux-mips64el': 0.27.7
- '@esbuild/linux-ppc64': 0.27.7
- '@esbuild/linux-riscv64': 0.27.7
- '@esbuild/linux-s390x': 0.27.7
- '@esbuild/linux-x64': 0.27.7
- '@esbuild/netbsd-arm64': 0.27.7
- '@esbuild/netbsd-x64': 0.27.7
- '@esbuild/openbsd-arm64': 0.27.7
- '@esbuild/openbsd-x64': 0.27.7
- '@esbuild/openharmony-arm64': 0.27.7
- '@esbuild/sunos-x64': 0.27.7
- '@esbuild/win32-arm64': 0.27.7
- '@esbuild/win32-ia32': 0.27.7
- '@esbuild/win32-x64': 0.27.7
+ '@esbuild/aix-ppc64': 0.28.1
+ '@esbuild/android-arm': 0.28.1
+ '@esbuild/android-arm64': 0.28.1
+ '@esbuild/android-x64': 0.28.1
+ '@esbuild/darwin-arm64': 0.28.1
+ '@esbuild/darwin-x64': 0.28.1
+ '@esbuild/freebsd-arm64': 0.28.1
+ '@esbuild/freebsd-x64': 0.28.1
+ '@esbuild/linux-arm': 0.28.1
+ '@esbuild/linux-arm64': 0.28.1
+ '@esbuild/linux-ia32': 0.28.1
+ '@esbuild/linux-loong64': 0.28.1
+ '@esbuild/linux-mips64el': 0.28.1
+ '@esbuild/linux-ppc64': 0.28.1
+ '@esbuild/linux-riscv64': 0.28.1
+ '@esbuild/linux-s390x': 0.28.1
+ '@esbuild/linux-x64': 0.28.1
+ '@esbuild/netbsd-arm64': 0.28.1
+ '@esbuild/netbsd-x64': 0.28.1
+ '@esbuild/openbsd-arm64': 0.28.1
+ '@esbuild/openbsd-x64': 0.28.1
+ '@esbuild/openharmony-arm64': 0.28.1
+ '@esbuild/sunos-x64': 0.28.1
+ '@esbuild/win32-arm64': 0.28.1
+ '@esbuild/win32-ia32': 0.28.1
+ '@esbuild/win32-x64': 0.28.1
escalade@3.2.0: {}
@@ -11246,7 +11246,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.20
- dompurify: 3.4.10
+ dompurify: 3.4.11
es-toolkit: 1.46.1
katex: 0.16.45
khroma: 2.1.0
@@ -12919,9 +12919,9 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4):
+ vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4):
dependencies:
- esbuild: 0.27.7
+ esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.14
@@ -12934,10 +12934,10 @@ snapshots:
lightningcss: 1.32.0
yaml: 2.8.4
- vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
+ vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.5
- '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -12954,7 +12954,7 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
- vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
From bed50606f2db1884565c1d0419b021f5378725b1 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 27 Jun 2026 22:02:52 -0700
Subject: [PATCH 26/54] Launch and detect Qwen Code via its qwen executable
(#6530)
---
mobile/src/tasks/mobile-tui-agents.ts | 3 ++-
src/renderer/src/lib/agent-catalog.tsx | 4 +++-
src/shared/agent-process-recognition.test.ts | 15 +++++++++++++++
src/shared/tui-agent-config.ts | 8 +++++---
src/shared/tui-agent-startup.test.ts | 17 +++++++++++++++++
5 files changed, 42 insertions(+), 5 deletions(-)
diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts
index 1c9ef440ac0..8530e0994cd 100644
--- a/mobile/src/tasks/mobile-tui-agents.ts
+++ b/mobile/src/tasks/mobile-tui-agents.ts
@@ -139,7 +139,8 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = {
droid: 'droid',
kimi: 'kimi',
'mistral-vibe': 'mistral-vibe',
- 'qwen-code': 'qwen-code',
+ // Why: QwenLM/qwen-code installs its CLI executable as `qwen`, not `qwen-code`.
+ 'qwen-code': 'qwen',
rovo: 'rovo',
hermes: 'hermes',
devin: 'devin',
diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx
index e93f32d03db..069db28292a 100644
--- a/src/renderer/src/lib/agent-catalog.tsx
+++ b/src/renderer/src/lib/agent-catalog.tsx
@@ -254,7 +254,9 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] =>
{
id: 'qwen-code',
label: translate('auto.lib.agent.catalog.bee242fe3d', 'Qwen Code'),
- cmd: 'qwen-code',
+ // Why: QwenLM/qwen-code installs its CLI executable as `qwen`; the package
+ // name is not the binary users put on PATH. Keep `id` for stable identity.
+ cmd: 'qwen',
faviconDomain: 'qwenlm.github.io',
homepageUrl: 'https://github.com/QwenLM/qwen-code'
},
diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts
index fedaa4881d2..865d5105c25 100644
--- a/src/shared/agent-process-recognition.test.ts
+++ b/src/shared/agent-process-recognition.test.ts
@@ -135,6 +135,21 @@ describe('agent process recognition', () => {
expect(isRecognizedAgentType('vibe')).toBe(true)
})
+ it('recognizes Qwen Code by its installed qwen executable', () => {
+ expect(recognizeAgentProcess('/home/dev/.local/bin/qwen')).toEqual({
+ agent: 'qwen-code',
+ processName: 'qwen'
+ })
+ expect(
+ recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\qwen.cmd`)
+ ).toEqual({
+ agent: 'qwen-code',
+ processName: 'qwen'
+ })
+ expect(isExpectedAgentProcess('/usr/local/bin/qwen', 'qwen')).toBe(true)
+ expect(isRecognizedAgentType('qwen')).toBe(true)
+ })
+
it('recognizes agent CLIs launched through interpreter wrappers', () => {
expect(
recognizeAgentProcessFromCommandLine('node /Users/dev/.nvm/versions/node/bin/codex')
diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts
index bea54d857bc..fd41f191618 100644
--- a/src/shared/tui-agent-config.ts
+++ b/src/shared/tui-agent-config.ts
@@ -277,9 +277,11 @@ export const TUI_AGENT_CONFIG: Record = {
promptInjectionMode: 'stdin-after-start'
},
'qwen-code': {
- detectCmd: 'qwen-code',
- launchCmd: 'qwen-code',
- expectedProcess: 'qwen-code',
+ // Why: the upstream package is QwenLM/qwen-code, but its installed CLI
+ // executable on PATH is `qwen`, so detect/launch/recognition must use that.
+ detectCmd: 'qwen',
+ launchCmd: 'qwen',
+ expectedProcess: 'qwen',
promptInjectionMode: 'stdin-after-start'
},
rovo: {
diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts
index e40726b6a92..78bd7a54bab 100644
--- a/src/shared/tui-agent-startup.test.ts
+++ b/src/shared/tui-agent-startup.test.ts
@@ -136,6 +136,23 @@ describe('tui agent startup plans', () => {
})
})
+ it('launches Qwen Code through the installed qwen executable', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'qwen-code',
+ prompt: 'fix it',
+ cmdOverrides: {},
+ platform: 'linux'
+ })
+
+ expect(plan).toEqual({
+ agent: 'qwen-code',
+ launchCommand: 'qwen',
+ expectedProcess: 'qwen',
+ followupPrompt: 'fix it',
+ launchConfig: { agentCommand: 'qwen', agentArgs: '', agentEnv: {} }
+ })
+ })
+
it('leaves Claude command overrides untouched', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
From 640f67d9704ad82c37a5662519e041b78eec44d7 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 27 Jun 2026 22:26:09 -0700
Subject: [PATCH 27/54] Fix Ctrl+C and other Mod-letter shortcuts on non-Latin
keyboard layouts (#6534)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes #6274.
Non-Latin layouts (Cyrillic, Greek, ABNT2, …) report a non-Latin logical `event.key` for physical letter keys (physical `KeyC` → `с`) while `event.code` stays `KeyC`. The shortcut matcher only fell back to the physical code when `event.key` was empty/Dead/Unidentified, so Mod+letter/digit chords never matched on those layouts — Ctrl+C copy in Monaco Source mode appeared to do nothing and the chord could not even be rebound in Settings.
Adds a tightly-gated non-Latin physical-code fallback (non-macOS, real Ctrl/Meta modifier held, excludes AltGr/Ctrl+Alt composition, only when no logical Latin token exists). Verified: full keybindings suite (55 tests) + targeted Cyrillic/Greek/AltGr/wrong-key cases pass; web + node typecheck clean.
---
src/shared/keybindings.test.ts | 65 ++++++++++++++++++++++++++++++
src/shared/keybindings.ts | 72 +++++++++++++++++++++++++++++-----
2 files changed, 127 insertions(+), 10 deletions(-)
diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts
index e69917db03e..6dcfcbba44f 100644
--- a/src/shared/keybindings.test.ts
+++ b/src/shared/keybindings.test.ts
@@ -833,6 +833,71 @@ describe('keybindings', () => {
})
})
+ it('matches letter shortcuts on non-Latin layouts via the physical code (issue #6274)', () => {
+ // Cyrillic ЙЦУКЕН: physical C produces the logical key 'с' (Cyrillic es,
+ // U+0441) while code stays 'KeyC'. The produced character is not a Latin
+ // shortcut letter, so the chord must still match through the physical code.
+ const cyrillicCtrlC = {
+ key: 'с',
+ code: 'KeyC',
+ control: true,
+ meta: false,
+ alt: false,
+ shift: false
+ }
+ expect(keybindingMatchesAction('browser.grabElement', cyrillicCtrlC, 'win32')).toBe(true)
+ expect(keybindingMatchesAction('browser.grabElement', cyrillicCtrlC, 'linux')).toBe(true)
+
+ // Ctrl+Shift+C on the same layout (terminal copy) must match too.
+ expect(
+ keybindingMatchesAction(
+ 'terminal.copySelection',
+ { ...cyrillicCtrlC, shift: true },
+ 'win32'
+ )
+ ).toBe(true)
+
+ // Greek layout: physical P produces 'π' (U+03C0); Ctrl+P must still match.
+ expect(
+ keybindingMatchesAction(
+ 'worktree.quickOpen',
+ { key: 'π', code: 'KeyP', control: true, meta: false, alt: false, shift: false },
+ 'win32'
+ )
+ ).toBe(true)
+
+ // The fallback must not steal a different physical key: Ctrl+V (physical V,
+ // Cyrillic 'м') is not Ctrl+C, so grabElement must stay unmatched.
+ expect(
+ keybindingMatchesAction(
+ 'browser.grabElement',
+ { key: 'м', code: 'KeyV', control: true, meta: false, alt: false, shift: false },
+ 'win32'
+ )
+ ).toBe(false)
+ })
+
+ it('does not let non-Latin physical fallback hijack AltGr text input (issue #6274)', () => {
+ // Windows/Linux AltGr arrives as Ctrl+Alt. A composed character typed via
+ // AltGr (e.g. AltGr+C) must remain text input, never an app shortcut.
+ // editor.copyContext is Mod+Alt+C, so the modifier state otherwise matches —
+ // only the AltGr key gating may keep this from firing.
+ expect(
+ keybindingMatchesAction(
+ 'editor.copyContext',
+ {
+ key: '¢',
+ code: 'KeyC',
+ control: true,
+ meta: false,
+ alt: true,
+ shift: false
+ },
+ 'win32'
+ )
+ ).toBe(false)
+ })
+
it('uses shifted punctuation aliases only while Shift is pressed', () => {
const shiftedComma = {
key: '<',
diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts
index 6d4c3e64fb7..e5f649c4f97 100644
--- a/src/shared/keybindings.ts
+++ b/src/shared/keybindings.ts
@@ -1483,6 +1483,51 @@ function canUsePhysicalCodeFallback(input: KeybindingInput): boolean {
return PHYSICAL_CODE_FALLBACK_KEYS.has(input.key ?? '')
}
+function isLatinShortcutKey(key: string): boolean {
+ // Why: A-Z / 0-9 are the only single chars a Latin shortcut token names; any
+ // other produced character (Cyrillic с, Greek π, ...) cannot be a deliberate
+ // remap of a Latin chord, so it is safe to fall back to the physical code.
+ if (key.length !== 1) {
+ return false
+ }
+ const upper = key.toUpperCase()
+ return (upper >= 'A' && upper <= 'Z') || (key >= '0' && key <= '9')
+}
+
+function shouldUseNonLatinShortcutPhysicalFallback(
+ input: KeybindingInput,
+ platform: NodeJS.Platform
+): boolean {
+ // Why: non-Latin layouts (Cyrillic, Greek, ...) report a non-Latin logical key
+ // for physical letter keys (issue #6274), so Ctrl/Meta shortcuts never match.
+ // Fall back to the physical code, but only when no logical shortcut token
+ // exists, a real primary modifier (Ctrl or Meta) is held, and this is not an
+ // AltGr (Ctrl+Alt) text-composition event. macOS is excluded — it already has
+ // dedicated Option-composed fallbacks and Cmd shortcuts are layout-stable.
+ if (getKeybindingPlatform(platform) === 'darwin') {
+ return false
+ }
+ const hasPrimaryModifier = hasModifier(input, 'control') || hasModifier(input, 'meta')
+ if (!hasPrimaryModifier) {
+ return false
+ }
+ // AltGr surfaces as Ctrl+Alt on Windows/Linux; treat it as text, not a chord.
+ if (hasModifier(input, 'control') && hasModifier(input, 'alt')) {
+ return false
+ }
+ if (logicalKeyTokenFromInput(input) !== null) {
+ return false
+ }
+ const key = input.key ?? ''
+ return key !== '' && !MODIFIER_KEYS.has(key) && !isLatinShortcutKey(key)
+}
+
+function canFallBackToPhysicalCode(input: KeybindingInput, platform: NodeJS.Platform): boolean {
+ return (
+ canUsePhysicalCodeFallback(input) || shouldUseNonLatinShortcutPhysicalFallback(input, platform)
+ )
+}
+
function physicalCodeKeyTokenFromInput(input: KeybindingInput): string | null {
const code = input.code ?? ''
if (code.startsWith('Key') && code.length === 4) {
@@ -1534,7 +1579,8 @@ function keyTokenFromInput(input: KeybindingInput, platform: NodeJS.Platform): s
}
if (
!canUsePhysicalCodeFallback(input) &&
- !shouldUseMacOptionComposedCaptureFallback(input, platform)
+ !shouldUseMacOptionComposedCaptureFallback(input, platform) &&
+ !shouldUseNonLatinShortcutPhysicalFallback(input, platform)
) {
return null
}
@@ -1764,18 +1810,22 @@ function letterKeyMatches(
return logicalKey === letter.toUpperCase()
}
return (
- (canUsePhysicalCodeFallback(input) ||
+ (canFallBackToPhysicalCode(input, platform) ||
shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) &&
input.code === `Key${letter.toUpperCase()}`
)
}
-function digitKeyMatches(input: KeybindingInput, digit: string): boolean {
+function digitKeyMatches(
+ input: KeybindingInput,
+ digit: string,
+ platform: NodeJS.Platform
+): boolean {
const logicalKey = logicalKeyTokenFromInput(input)
if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') {
return logicalKey === digit
}
- return canUsePhysicalCodeFallback(input) && input.code === `Digit${digit}`
+ return canFallBackToPhysicalCode(input, platform) && input.code === `Digit${digit}`
}
function isPunctuationKeyToken(token: string | null): token is string {
@@ -1823,7 +1873,7 @@ function keyMatches(
return letterKeyMatches(input, parsedKey, parsed, platform)
}
if (parsedKey.length === 1 && parsedKey >= '0' && parsedKey <= '9') {
- return digitKeyMatches(input, parsedKey)
+ return digitKeyMatches(input, parsedKey, platform)
}
if (parsedKey === 'NumpadAdd' || parsedKey === 'NumpadSubtract') {
@@ -1844,7 +1894,7 @@ function keyMatches(
return semanticKey === parsedKey
}
return (
- (canUsePhysicalCodeFallback(input) ||
+ (canFallBackToPhysicalCode(input, platform) ||
shouldUseMacOptionPunctuationPhysicalFallback(parsed, input, platform)) &&
physicalPunctuationKey(input) === parsedKey
)
@@ -1854,7 +1904,9 @@ function keyMatches(
if (logicalKey !== null) {
return logicalKey === parsedKey
}
- return canUsePhysicalCodeFallback(input) && physicalCodeKeyTokenFromInput(input) === parsedKey
+ return (
+ canFallBackToPhysicalCode(input, platform) && physicalCodeKeyTokenFromInput(input) === parsedKey
+ )
}
function resolveModifierToken(
@@ -1928,10 +1980,10 @@ export function keybindingMatchesAction(
)
}
-function digitFromInput(input: KeybindingInput): string | null {
+function digitFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null {
for (let value = 1; value <= 9; value++) {
const digit = String(value)
- if (digitKeyMatches(input, digit)) {
+ if (digitKeyMatches(input, digit, platform)) {
return digit
}
}
@@ -1954,7 +2006,7 @@ export function matchKeybindingDigitIndex(
if (!definition || !keybindingIsActiveInContext(definition, options)) {
return null
}
- const digit = digitFromInput(input)
+ const digit = digitFromInput(input, platform)
if (!digit) {
return null
}
From bffe436aeb9043368d56bff18136cc90964983a2 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 22:51:33 -0700
Subject: [PATCH 28/54] fix: address review findings (#6551)
---
src/renderer/src/assets/mobile-page.css | 22 ++++++-
.../src/components/mobile/MobilePage.tsx | 11 +++-
.../mobile/MobilePageToolbar.test.tsx | 39 +++++++++++++
.../components/mobile/MobilePageToolbar.tsx | 53 ++++++++++++-----
.../settings/MobileSettingsPane.tsx | 32 +++++++++++
.../src/components/settings/Settings.tsx | 57 +++++++++----------
.../settings/mobile-settings-search.ts | 45 ++++++++++++++-
.../src/components/sidebar/SidebarNav.tsx | 1 -
.../useSettingsNavigationMetadata.test.ts | 14 ++++-
.../hooks/useSettingsNavigationMetadata.ts | 26 +++++----
src/renderer/src/i18n/locales/en.json | 15 ++++-
src/renderer/src/i18n/locales/es.json | 15 ++++-
src/renderer/src/i18n/locales/ja.json | 15 ++++-
src/renderer/src/i18n/locales/ko.json | 15 ++++-
src/renderer/src/i18n/locales/zh.json | 15 ++++-
src/shared/types.ts | 2 +-
16 files changed, 300 insertions(+), 77 deletions(-)
create mode 100644 src/renderer/src/components/mobile/MobilePageToolbar.test.tsx
diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css
index df32f64ccae..e586ab9f8b2 100644
--- a/src/renderer/src/assets/mobile-page.css
+++ b/src/renderer/src/assets/mobile-page.css
@@ -6,10 +6,28 @@
position: absolute;
top: 12px;
left: 12px;
+ right: 12px;
z-index: 3;
display: flex;
- align-items: center;
- gap: 4px;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.mobile-page-root .mp-page-toolbar-primary {
+ max-width: min(100%, 22rem);
+}
+
+.mobile-page-root .mp-sidebar-toggle-btn {
+ max-width: 100%;
+ justify-content: flex-start;
+ font-weight: 600;
+ text-align: left;
+ white-space: normal;
+}
+
+.mobile-page-root .mp-page-toolbar-close {
+ margin-top: 1px;
}
.mobile-page-root {
diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx
index 2fb3c7b9366..668dcee6961 100644
--- a/src/renderer/src/components/mobile/MobilePage.tsx
+++ b/src/renderer/src/components/mobile/MobilePage.tsx
@@ -354,7 +354,16 @@ export default function MobilePage(): React.JSX.Element {
}
const toggleMobileSidebarButton = useCallback(() => {
- void updateSettings({ showMobileButton: !showMobileButton })
+ const nextShowMobileButton = !showMobileButton
+ void updateSettings({ showMobileButton: nextShowMobileButton })
+ if (!nextShowMobileButton) {
+ toast.message(
+ translate(
+ 'auto.components.mobile.MobilePageToolbar.e1c7b4a92d',
+ 'Configure in Settings > Mobile.'
+ )
+ )
+ }
}, [showMobileButton, updateSettings])
useMobilePageEscape(closeMobilePage)
diff --git a/src/renderer/src/components/mobile/MobilePageToolbar.test.tsx b/src/renderer/src/components/mobile/MobilePageToolbar.test.tsx
new file mode 100644
index 00000000000..b6aece77d09
--- /dev/null
+++ b/src/renderer/src/components/mobile/MobilePageToolbar.test.tsx
@@ -0,0 +1,39 @@
+// @vitest-environment happy-dom
+
+import type { ReactNode } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: ReactNode }) => {children}
+}))
+
+import { MobilePageToolbar } from './MobilePageToolbar'
+
+describe('MobilePageToolbar', () => {
+ it('labels the sidebar toggle explicitly when Orca Mobile is visible in the sidebar', () => {
+ const html = renderToStaticMarkup(
+
+ )
+
+ expect(html).toContain('Remove Orca Mobile from left sidebar')
+ expect(html).toContain('mp-page-toolbar-primary')
+ expect(html).toContain('Configure in Settings')
+ expect(html).not.toContain('Hide from sidebar')
+ })
+
+ it('labels the restore action explicitly when Orca Mobile is hidden from the sidebar', () => {
+ const html = renderToStaticMarkup(
+
+ )
+
+ expect(html).toContain('Show Orca Mobile in left sidebar')
+ expect(html).not.toContain('Show in sidebar')
+ })
+})
diff --git a/src/renderer/src/components/mobile/MobilePageToolbar.tsx b/src/renderer/src/components/mobile/MobilePageToolbar.tsx
index 097b02919f7..51e3e583916 100644
--- a/src/renderer/src/components/mobile/MobilePageToolbar.tsx
+++ b/src/renderer/src/components/mobile/MobilePageToolbar.tsx
@@ -1,4 +1,4 @@
-import { Eye, EyeOff, X } from 'lucide-react'
+import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
@@ -14,14 +14,52 @@ export function MobilePageToolbar({
onClose,
onToggleMobileSidebarButton
}: MobilePageToolbarProps): React.JSX.Element {
+ const sidebarToggleLabel = showMobileButton
+ ? translate(
+ 'auto.components.mobile.MobilePageToolbar.a4f8c2d91e',
+ 'Remove Orca Mobile from left sidebar'
+ )
+ : translate(
+ 'auto.components.mobile.MobilePageToolbar.b7e3d1c84a',
+ 'Show Orca Mobile in left sidebar'
+ )
+ const sidebarToggleTooltip = showMobileButton
+ ? translate(
+ 'auto.components.mobile.MobilePageToolbar.e1c7b4a92d',
+ 'Configure in Settings > Mobile.'
+ )
+ : translate(
+ 'auto.components.mobile.MobilePageToolbar.f3d8e5b71a',
+ 'Adds the shortcut back to the left sidebar.'
+ )
+
return (
+
+
+
+
+ {sidebarToggleLabel}
+
+
+
+ {sidebarToggleTooltip}
+
+
+
+
-
- {showMobileButton ? : }
- {showMobileButton
- ? translate('auto.components.mobile.MobilePageToolbar.c669abcf8f', 'Hide from sidebar')
- : translate('auto.components.mobile.MobilePageToolbar.fb5f28330e', 'Show in sidebar')}
-
)
}
diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx
index 016409893a8..d6ffbc05461 100644
--- a/src/renderer/src/components/settings/MobileSettingsPane.tsx
+++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx
@@ -1,10 +1,13 @@
import { SearchableSetting } from './SearchableSetting'
+import { SettingsSwitchRow } from './SettingsFormControls'
import { MobilePane } from './MobilePane'
import {
getMobileOverviewSearchEntry,
+ getMobileSidebarShortcutSearchEntry,
getMobileSettingsPaneSearchEntries
} from './mobile-settings-search'
import { translate } from '@/i18n/i18n'
+import { useAppStore } from '@/store'
export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
@@ -12,6 +15,9 @@ const ORCA_ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
+ const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
+ const updateSettings = useAppStore((s) => s.updateSettings)
+
return (
+
+ {/* Why: the in-page removal toast points users to Settings > Mobile. */}
+ updateSettings({ showMobileButton: !showMobileButton })}
+ />
+
+
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx
index deecf5cb547..3aa6e1d8fca 100644
--- a/src/renderer/src/components/settings/Settings.tsx
+++ b/src/renderer/src/components/settings/Settings.tsx
@@ -113,11 +113,6 @@ const SETTINGS_NAV_GROUPS = [
titleKey: 'auto.components.settings.Settings.23931df7e8',
titleDefault: 'Remote Hosts'
},
- {
- id: 'mobile',
- titleKey: 'auto.components.settings.Settings.mobile_group',
- titleDefault: 'Mobile'
- },
{
id: 'security',
titleKey: 'auto.components.settings.Settings.084d8fac5b',
@@ -1213,6 +1208,21 @@ function Settings(): React.JSX.Element {
{isSectionMounted('integrations') ?
: null}
+ {showDesktopOnlySettings ? (
+
+ {isSectionMounted('mobile') ? : null}
+
+ ) : null}
+
{showDesktopOnlySettings ? (
- <>
-
- {isSectionMounted('ssh') ? : null}
-
-
-
- {isSectionMounted('mobile') ? : null}
-
- >
+
+ {isSectionMounted('ssh') ? : null}
+
) : null}
{showDesktopOnlySettings && isMac ? (
diff --git a/src/renderer/src/components/settings/mobile-settings-search.ts b/src/renderer/src/components/settings/mobile-settings-search.ts
index ae0a8195473..a874bd1a163 100644
--- a/src/renderer/src/components/settings/mobile-settings-search.ts
+++ b/src/renderer/src/components/settings/mobile-settings-search.ts
@@ -54,6 +54,49 @@ export const getMobileOverviewSearchEntry = createLocalizedCatalog(
})
)
+export const getMobileSidebarShortcutSearchEntry = createLocalizedCatalog(
+ (): SettingsSearchEntry => ({
+ title: translate(
+ 'auto.components.settings.mobile.settings.search.1de96ec8a6',
+ 'Show Orca Mobile Button'
+ ),
+ description: translate(
+ 'auto.components.settings.mobile.settings.search.682293cadf',
+ 'Show the Orca Mobile button at the top of the left sidebar.'
+ ),
+ keywords: [
+ ...translateSearchKeyword(
+ 'auto.components.settings.mobile.settings.search.74618577c7',
+ 'mobile'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.mobile.settings.search.5e5b8878bf',
+ 'phone'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.mobile.settings.search.5bff6a2ef0',
+ 'sidebar'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.mobile.settings.search.6cf5f54ce1',
+ 'button'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.mobile.settings.search.648eeada79',
+ 'hide'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.mobile.settings.search.ac79fe4a04',
+ 'show'
+ )
+ ]
+ })
+)
+
export const getMobileSettingsPaneSearchEntries = createLocalizedCatalog(
- (): SettingsSearchEntry[] => [getMobileOverviewSearchEntry(), ...getMobilePaneSearchEntries()]
+ (): SettingsSearchEntry[] => [
+ getMobileOverviewSearchEntry(),
+ getMobileSidebarShortcutSearchEntry(),
+ ...getMobilePaneSearchEntries()
+ ]
)
diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx
index 1955d9ba048..d099f0d83dc 100644
--- a/src/renderer/src/components/sidebar/SidebarNav.tsx
+++ b/src/renderer/src/components/sidebar/SidebarNav.tsx
@@ -43,7 +43,6 @@ const SidebarNav = React.memo(function SidebarNav() {
const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings))
const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings))
const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings))
-
const automationsActive = activeView === 'automations'
const activityActive = activeView === 'activity'
const mobileActive = activeView === 'mobile'
diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts
index 595e11cc045..5b6b3c20120 100644
--- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts
+++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts
@@ -24,7 +24,7 @@ function ids(args: { isMac?: boolean; isWindows?: boolean; isWebClient?: boolean
describe('settings navigation metadata', () => {
it('puts AI capability panes at the top on desktop', () => {
- expect(ids().slice(0, 9)).toEqual([
+ expect(ids().slice(0, 10)).toEqual([
'agents',
'accounts',
'orchestration',
@@ -33,10 +33,22 @@ describe('settings navigation metadata', () => {
'setup-guide',
'general',
'integrations',
+ 'mobile',
'git'
])
})
+ it('places Mobile under Set Up instead of its own sidebar group', () => {
+ const sections = buildSettingsNavigationMetadata({
+ isMac: false,
+ isWindows: false,
+ isWebClient: false,
+ repos: [repo]
+ })
+
+ expect(sections.find((section) => section.id === 'mobile')?.group).toBe('setup')
+ })
+
it('puts web-safe AI capability panes at the top while hiding desktop-only panes', () => {
expect(ids({ isWebClient: true }).slice(0, 7)).toEqual([
'agents',
diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts
index 64344354082..3a1fd814a49 100644
--- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts
+++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts
@@ -225,6 +225,21 @@ export function buildSettingsNavigationMetadata({
searchEntries: getIntegrationsPaneSearchEntries(),
group: 'setup'
},
+ ...(showDesktopOnlySettings
+ ? [
+ {
+ id: 'mobile',
+ title: translate('auto.hooks.useSettingsNavigationMetadata.1cd25673df', 'Mobile'),
+ description: translate(
+ 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
+ 'Control terminals and agents from your phone.'
+ ),
+ icon: Smartphone,
+ searchEntries: getMobileSettingsPaneSearchEntries(),
+ group: 'setup'
+ }
+ ]
+ : []),
{
id: 'git',
title: translate(
@@ -413,17 +428,6 @@ export function buildSettingsNavigationMetadata({
icon: Cable,
searchEntries: getSshPaneSearchEntries(),
group: 'remote'
- },
- {
- id: 'mobile',
- title: translate('auto.hooks.useSettingsNavigationMetadata.1cd25673df', 'Mobile'),
- description: translate(
- 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
- 'Control terminals and agents from your phone.'
- ),
- icon: Smartphone,
- searchEntries: getMobileSettingsPaneSearchEntries(),
- group: 'mobile'
}
]
: []),
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 61e822e0d7f..9e1b9680de9 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -5392,7 +5392,10 @@
"b5a2ed83ff": "App Store",
"c8491c17ef": "Control Orca from your phone by scanning a QR code. Beta / early preview - expect bugs and breaking changes. Get the iOS app from the",
"e7a3ae8c4e": "Mobile",
- "174f4a3c6d": "Control terminals and agents from your phone."
+ "174f4a3c6d": "Control terminals and agents from your phone.",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar.",
+ "d4f2b65f30": "Show the Orca Mobile shortcut in the sidebar."
},
"NotificationsPane": {
"906b4afebf": "Send Test Notification",
@@ -7459,7 +7462,9 @@
"f4ed142753": "phone",
"f213400800": "mobile",
"671eb4173c": "Control terminals and agents from your phone.",
- "ffd52a96e4": "Mobile"
+ "ffd52a96e4": "Mobile",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar."
}
}
},
@@ -9749,7 +9754,11 @@
"ad2284a9e2": "Close · Esc",
"9883b58693": "Close Orca Mobile",
"fb5f28330e": "Show in sidebar",
- "c669abcf8f": "Hide from sidebar"
+ "c669abcf8f": "Hide from sidebar",
+ "e1c7b4a92d": "Configure in Settings > Mobile.",
+ "a4f8c2d91e": "Remove Orca Mobile from left sidebar",
+ "b7e3d1c84a": "Show Orca Mobile in left sidebar",
+ "f3d8e5b71a": "Adds the shortcut back to the left sidebar."
},
"PhoneCarousel": {
"96d651cb87": "Terminal session",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index d52d7bbb308..d9da0fd6124 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -5355,7 +5355,10 @@
"b5a2ed83ff": "Tienda de aplicaciones",
"c8491c17ef": "Controla Orca desde tu teléfono escaneando un código QR. Beta/vista previa anticipada: espere errores y cambios importantes. Obtenga la aplicación para iOS desde",
"e7a3ae8c4e": "Móvil",
- "174f4a3c6d": "Controla terminales y agents desde tu teléfono."
+ "174f4a3c6d": "Controla terminales y agents desde tu teléfono.",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar.",
+ "d4f2b65f30": "Show the Orca Mobile shortcut in the sidebar."
},
"NotificationsPane": {
"906b4afebf": "Enviar notificación de prueba",
@@ -7422,7 +7425,9 @@
"f4ed142753": "teléfono",
"f213400800": "móvil",
"671eb4173c": "Controla terminales y agents desde tu teléfono.",
- "ffd52a96e4": "Móvil"
+ "ffd52a96e4": "Móvil",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar."
}
}
},
@@ -9749,7 +9754,11 @@
"ad2284a9e2": "Cerrar · Esc",
"9883b58693": "Cerrar Orca Móvil",
"fb5f28330e": "Mostrar en la barra lateral",
- "c669abcf8f": "Ocultar de la barra lateral"
+ "c669abcf8f": "Ocultar de la barra lateral",
+ "e1c7b4a92d": "Configure in Settings > Mobile.",
+ "a4f8c2d91e": "Remove Orca Mobile from left sidebar",
+ "b7e3d1c84a": "Show Orca Mobile in left sidebar",
+ "f3d8e5b71a": "Adds the shortcut back to the left sidebar."
},
"PhoneCarousel": {
"96d651cb87": "Sesión terminal",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index c6de483b70f..6f87487774c 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -5377,7 +5377,10 @@
"b5a2ed83ff": "アプリストア",
"c8491c17ef": "QR コードをスキャンしてスマートフォンから Orca を制御します。ベータ/早期プレビュー - バグや重大な変更が予想されます。 iOS アプリを次の場所から入手します。",
"e7a3ae8c4e": "モバイル",
- "174f4a3c6d": "スマートフォンから terminals と agents を操作"
+ "174f4a3c6d": "スマートフォンから terminals と agents を操作",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar.",
+ "d4f2b65f30": "Show the Orca Mobile shortcut in the sidebar."
},
"NotificationsPane": {
"906b4afebf": "テスト通知を送信する",
@@ -7444,7 +7447,9 @@
"f4ed142753": "スマートフォン",
"f213400800": "モバイル",
"671eb4173c": "スマートフォンから terminals と agents を操作",
- "ffd52a96e4": "モバイル"
+ "ffd52a96e4": "モバイル",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar."
}
}
},
@@ -9749,7 +9754,11 @@
"ad2284a9e2": "閉じる・Esc",
"9883b58693": "Orca モバイルを閉じる",
"fb5f28330e": "サイドバーに表示",
- "c669abcf8f": "サイドバーから隠す"
+ "c669abcf8f": "サイドバーから隠す",
+ "e1c7b4a92d": "Configure in Settings > Mobile.",
+ "a4f8c2d91e": "Remove Orca Mobile from left sidebar",
+ "b7e3d1c84a": "Show Orca Mobile in left sidebar",
+ "f3d8e5b71a": "Adds the shortcut back to the left sidebar."
},
"PhoneCarousel": {
"96d651cb87": "Terminal セッション",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 2c7e5beb47e..4f7a5f168bf 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -5340,7 +5340,10 @@
"b5a2ed83ff": "앱스토어",
"c8491c17ef": "QR 코드를 스캔하여 휴대폰에서 Orca를 제어하세요. 베타/초기 미리보기 - 버그 및 주요 변경 사항이 예상됩니다. 다음에서 iOS 앱을 받으세요.",
"e7a3ae8c4e": "모바일",
- "174f4a3c6d": "휴대폰에서 terminals과 agents를 제어하세요."
+ "174f4a3c6d": "휴대폰에서 terminals과 agents를 제어하세요.",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar.",
+ "d4f2b65f30": "Show the Orca Mobile shortcut in the sidebar."
},
"NotificationsPane": {
"906b4afebf": "테스트 알림 보내기",
@@ -7407,7 +7410,9 @@
"f4ed142753": "핸드폰",
"f213400800": "모바일",
"671eb4173c": "휴대폰에서 terminals과 agents를 제어하세요.",
- "ffd52a96e4": "모바일"
+ "ffd52a96e4": "모바일",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar."
}
}
},
@@ -9749,7 +9754,11 @@
"ad2284a9e2": "닫기 · Esc",
"9883b58693": "Orca 모바일 닫기",
"fb5f28330e": "사이드바에 표시",
- "c669abcf8f": "사이드바에서 숨기기"
+ "c669abcf8f": "사이드바에서 숨기기",
+ "e1c7b4a92d": "Configure in Settings > Mobile.",
+ "a4f8c2d91e": "Remove Orca Mobile from left sidebar",
+ "b7e3d1c84a": "Show Orca Mobile in left sidebar",
+ "f3d8e5b71a": "Adds the shortcut back to the left sidebar."
},
"PhoneCarousel": {
"96d651cb87": "Terminal 세션",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index c95ce8fc541..41fc54e3f27 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -5340,7 +5340,10 @@
"b5a2ed83ff": "应用商店",
"c8491c17ef": "通过扫描二维码从手机控制 Orca。 Beta/早期预览 - 预计会出现错误和重大更改。从以下位置获取 iOS 应用程序",
"e7a3ae8c4e": "移动端",
- "174f4a3c6d": "通过手机控制终端和智能体。"
+ "174f4a3c6d": "通过手机控制终端和智能体。",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar.",
+ "d4f2b65f30": "Show the Orca Mobile shortcut in the sidebar."
},
"NotificationsPane": {
"906b4afebf": "发送测试通知",
@@ -7407,7 +7410,9 @@
"f4ed142753": "手机",
"f213400800": "移动端",
"671eb4173c": "通过手机控制终端和智能体。",
- "ffd52a96e4": "移动端"
+ "ffd52a96e4": "移动端",
+ "1de96ec8a6": "Show Orca Mobile Button",
+ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar."
}
}
},
@@ -9749,7 +9754,11 @@
"ad2284a9e2": "关闭 · Esc",
"9883b58693": "关闭 Orca 手机",
"fb5f28330e": "在侧边栏中显示",
- "c669abcf8f": "从侧边栏隐藏"
+ "c669abcf8f": "从侧边栏隐藏",
+ "e1c7b4a92d": "Configure in Settings > Mobile.",
+ "a4f8c2d91e": "Remove Orca Mobile from left sidebar",
+ "b7e3d1c84a": "Show Orca Mobile in left sidebar",
+ "f3d8e5b71a": "Adds the shortcut back to the left sidebar."
},
"PhoneCarousel": {
"96d651cb87": "终端会话",
diff --git a/src/shared/types.ts b/src/shared/types.ts
index c7e401dc6b2..1c6e17c92be 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2561,7 +2561,7 @@ export type GlobalSettings = {
/** Why: Automations can be restored from Settings or the View menu, so this
* only controls whether the top-level sidebar shortcut is shown. */
showAutomationsButton?: boolean
- /** Why: Orca Mobile remains reachable from the toolbox; this only controls
+ /** Why: Orca Mobile remains reachable from Settings; this only controls
* whether the top-level sidebar shortcut is shown. */
showMobileButton?: boolean
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
From b45cb545c586c4ba35dc9ac14d967bbaece52656 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 27 Jun 2026 22:59:55 -0700
Subject: [PATCH 29/54] fix: shorten mobile toolbar labels (#6553)
---
.../src/components/mobile/MobilePageToolbar.test.tsx | 7 +++----
.../src/components/mobile/MobilePageToolbar.tsx | 12 +++---------
src/renderer/src/i18n/locales/en.json | 6 +++---
3 files changed, 9 insertions(+), 16 deletions(-)
diff --git a/src/renderer/src/components/mobile/MobilePageToolbar.test.tsx b/src/renderer/src/components/mobile/MobilePageToolbar.test.tsx
index b6aece77d09..45904cd6f44 100644
--- a/src/renderer/src/components/mobile/MobilePageToolbar.test.tsx
+++ b/src/renderer/src/components/mobile/MobilePageToolbar.test.tsx
@@ -18,10 +18,10 @@ describe('MobilePageToolbar', () => {
)
- expect(html).toContain('Remove Orca Mobile from left sidebar')
+ expect(html).toContain('Hide from sidebar')
expect(html).toContain('mp-page-toolbar-primary')
expect(html).toContain('Configure in Settings')
- expect(html).not.toContain('Hide from sidebar')
+ expect(html).not.toContain('Remove Orca Mobile')
})
it('labels the restore action explicitly when Orca Mobile is hidden from the sidebar', () => {
@@ -33,7 +33,6 @@ describe('MobilePageToolbar', () => {
/>
)
- expect(html).toContain('Show Orca Mobile in left sidebar')
- expect(html).not.toContain('Show in sidebar')
+ expect(html).toContain('Show in sidebar')
})
})
diff --git a/src/renderer/src/components/mobile/MobilePageToolbar.tsx b/src/renderer/src/components/mobile/MobilePageToolbar.tsx
index 51e3e583916..e6ad9cd9a11 100644
--- a/src/renderer/src/components/mobile/MobilePageToolbar.tsx
+++ b/src/renderer/src/components/mobile/MobilePageToolbar.tsx
@@ -15,14 +15,8 @@ export function MobilePageToolbar({
onToggleMobileSidebarButton
}: MobilePageToolbarProps): React.JSX.Element {
const sidebarToggleLabel = showMobileButton
- ? translate(
- 'auto.components.mobile.MobilePageToolbar.a4f8c2d91e',
- 'Remove Orca Mobile from left sidebar'
- )
- : translate(
- 'auto.components.mobile.MobilePageToolbar.b7e3d1c84a',
- 'Show Orca Mobile in left sidebar'
- )
+ ? translate('auto.components.mobile.MobilePageToolbar.c669abcf8f', 'Hide from sidebar')
+ : translate('auto.components.mobile.MobilePageToolbar.fb5f28330e', 'Show in sidebar')
const sidebarToggleTooltip = showMobileButton
? translate(
'auto.components.mobile.MobilePageToolbar.e1c7b4a92d',
@@ -30,7 +24,7 @@ export function MobilePageToolbar({
)
: translate(
'auto.components.mobile.MobilePageToolbar.f3d8e5b71a',
- 'Adds the shortcut back to the left sidebar.'
+ 'Adds the shortcut back to the sidebar.'
)
return (
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 9e1b9680de9..f082ae69916 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -9756,9 +9756,9 @@
"fb5f28330e": "Show in sidebar",
"c669abcf8f": "Hide from sidebar",
"e1c7b4a92d": "Configure in Settings > Mobile.",
- "a4f8c2d91e": "Remove Orca Mobile from left sidebar",
- "b7e3d1c84a": "Show Orca Mobile in left sidebar",
- "f3d8e5b71a": "Adds the shortcut back to the left sidebar."
+ "a4f8c2d91e": "Hide from sidebar",
+ "b7e3d1c84a": "Show in sidebar",
+ "f3d8e5b71a": "Adds the shortcut back to the sidebar."
},
"PhoneCarousel": {
"96d651cb87": "Terminal session",
From 5712cf9f483a7a159f52c12e094c37eca38932a3 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 27 Jun 2026 23:11:45 -0700
Subject: [PATCH 30/54] Fix right-click paste in the Monaco editor (#6549)
Monaco's context-menu Paste reads navigator.clipboard.readText(), which is blocked in Orca's sandboxed renderer, so right-click Paste did nothing while Cmd/Ctrl+V and right-click Copy worked.
Register a higher-priority implementation on Monaco's PasteAction that reads the clipboard through Orca's trusted IPC bridge (window.api.ui.readClipboardText) and dispatches Monaco's native paste. Falls through for read-only/unfocused/non-editor targets so Cmd/Ctrl+V is untouched, and routes oversized pastes through the existing chunked inserter to preserve the too-large guard for both paste paths.
Verified with unit tests (incl. read-only fall-through, empty/too-large/focus-lost/oversized) and end-to-end in the Electron app.
---
.../install-monaco-context-menu-paste.ts | 52 +++++
.../editor/monaco-context-menu-paste.test.ts | 220 ++++++++++++++++++
.../editor/monaco-context-menu-paste.ts | 147 ++++++++++++
.../editor/monaco-large-text-paste.ts | 36 ++-
src/renderer/src/env.d.ts | 27 +++
src/renderer/src/lib/monaco-setup.ts | 5 +
6 files changed, 476 insertions(+), 11 deletions(-)
create mode 100644 src/renderer/src/components/editor/install-monaco-context-menu-paste.ts
create mode 100644 src/renderer/src/components/editor/monaco-context-menu-paste.test.ts
create mode 100644 src/renderer/src/components/editor/monaco-context-menu-paste.ts
diff --git a/src/renderer/src/components/editor/install-monaco-context-menu-paste.ts b/src/renderer/src/components/editor/install-monaco-context-menu-paste.ts
new file mode 100644
index 00000000000..fe04fc2e8e8
--- /dev/null
+++ b/src/renderer/src/components/editor/install-monaco-context-menu-paste.ts
@@ -0,0 +1,52 @@
+import type * as Monaco from 'monaco-editor'
+import { PasteAction } from 'monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js'
+import { InMemoryClipboardMetadataManager } from 'monaco-editor/esm/vs/editor/browser/controller/editContext/clipboardUtils.js'
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+import {
+ ORCA_CONTEXT_MENU_PASTE_NAME,
+ ORCA_CONTEXT_MENU_PASTE_PRIORITY,
+ runOrcaContextMenuPaste
+} from './monaco-context-menu-paste'
+
+let installed = false
+
+/**
+ * Make Monaco's context-menu / command-palette Paste read the clipboard through
+ * Orca's trusted IPC bridge instead of the sandbox-blocked navigator.clipboard.
+ * Idempotent and safe to call once during Monaco setup.
+ */
+export function installMonacoContextMenuPaste(monaco: typeof Monaco): void {
+ // Why: PasteAction is `undefined` only when the browser reports no paste
+ // support at all; nothing to override in that case.
+ if (installed || !PasteAction) {
+ return
+ }
+ installed = true
+
+ // Why: a higher priority than Monaco's built-in 'code-editor' implementation
+ // (10000) lets us run first; returning a Promise (truthy, non-boolean) halts
+ // MultiCommand iteration so the blocked default never runs. Returning `false`
+ // falls through to the default for read-only/unfocused/non-editor cases.
+ PasteAction.addImplementation(
+ ORCA_CONTEXT_MENU_PASTE_PRIORITY,
+ ORCA_CONTEXT_MENU_PASTE_NAME,
+ () =>
+ runOrcaContextMenuPaste({
+ getFocusedEditor: () =>
+ monaco.editor.getEditors().find((candidate) => candidate.hasTextFocus()) ?? null,
+ readClipboardText: (options) => window.api.ui.readClipboardText(options),
+ getClipboardMetadata: (text) => InMemoryClipboardMetadataManager.INSTANCE.get(text),
+ emptySelectionClipboardOptionId: monaco.editor.EditorOption.emptySelectionClipboard,
+ readOnlyOptionId: monaco.editor.EditorOption.readOnly,
+ onTooLarge: () => {
+ toast.error(
+ translate(
+ 'auto.components.editor.MonacoEditor.largePasteTooLarge',
+ 'Paste is too large.'
+ )
+ )
+ }
+ })
+ )
+}
diff --git a/src/renderer/src/components/editor/monaco-context-menu-paste.test.ts b/src/renderer/src/components/editor/monaco-context-menu-paste.test.ts
new file mode 100644
index 00000000000..47dddc6b57d
--- /dev/null
+++ b/src/renderer/src/components/editor/monaco-context-menu-paste.test.ts
@@ -0,0 +1,220 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { runOrcaContextMenuPaste, type OrcaContextMenuPasteDeps } from './monaco-context-menu-paste'
+
+const READ_ONLY_OPTION = 104
+const EMPTY_SELECTION_OPTION = 45
+
+type FakeEditorState = {
+ hasTextFocus?: boolean
+ hasModel?: boolean
+ readOnly?: boolean
+ emptySelectionClipboard?: boolean
+}
+
+function makeEditor(state: FakeEditorState = {}) {
+ const {
+ hasTextFocus = true,
+ hasModel = true,
+ readOnly = false,
+ emptySelectionClipboard = false
+ } = state
+ let focused = hasTextFocus
+ const trigger = vi.fn()
+ const model = hasModel ? ({} as object) : null
+ // Why: the chunked inserter snapshots the container/model and re-checks identity
+ // each chunk, so these must be stable across calls.
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const editor = {
+ getModel: () => model,
+ hasTextFocus: () => focused,
+ getOption: (id: number) => {
+ if (id === READ_ONLY_OPTION) {
+ return readOnly
+ }
+ if (id === EMPTY_SELECTION_OPTION) {
+ return emptySelectionClipboard
+ }
+ return undefined
+ },
+ trigger,
+ // Surface used only by the large-paste chunked inserter.
+ getContainerDomNode: () => container,
+ getSelection: () => ({
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 1,
+ endColumn: 1
+ }),
+ setSelection: vi.fn(),
+ executeEdits: vi.fn(() => true),
+ pushUndoStop: vi.fn(() => true)
+ }
+ return {
+ editor,
+ trigger,
+ setFocused: (next: boolean) => {
+ focused = next
+ }
+ }
+}
+
+function makeDeps(
+ overrides: Partial & { editor?: ReturnType }
+): OrcaContextMenuPasteDeps {
+ const editorHandle = overrides.editor ?? makeEditor()
+ return {
+ getFocusedEditor: () => editorHandle.editor as never,
+ readClipboardText: vi.fn(async () => 'pasted text'),
+ getClipboardMetadata: () => null,
+ emptySelectionClipboardOptionId: EMPTY_SELECTION_OPTION as never,
+ readOnlyOptionId: READ_ONLY_OPTION as never,
+ ...overrides
+ }
+}
+
+afterEach(() => {
+ document.body.replaceChildren()
+ vi.restoreAllMocks()
+})
+
+describe('runOrcaContextMenuPaste', () => {
+ it('falls through (returns false) when no editor is focused', () => {
+ const deps = makeDeps({ getFocusedEditor: () => null })
+ expect(runOrcaContextMenuPaste(deps)).toBe(false)
+ })
+
+ it('falls through when the focused editor has no model', () => {
+ const handle = makeEditor({ hasModel: false })
+ const deps = makeDeps({ editor: handle })
+ expect(runOrcaContextMenuPaste(deps)).toBe(false)
+ })
+
+ it('falls through when the editor lacks text focus', () => {
+ const handle = makeEditor({ hasTextFocus: false })
+ const deps = makeDeps({ editor: handle })
+ expect(runOrcaContextMenuPaste(deps)).toBe(false)
+ })
+
+ it('falls through for read-only editors without reading the clipboard', () => {
+ const handle = makeEditor({ readOnly: true })
+ const readClipboardText = vi.fn(async () => 'x')
+ const deps = makeDeps({ editor: handle, readClipboardText })
+ expect(runOrcaContextMenuPaste(deps)).toBe(false)
+ expect(readClipboardText).not.toHaveBeenCalled()
+ })
+
+ it('reads via IPC and dispatches a native paste for writable focused editors', async () => {
+ const handle = makeEditor()
+ const readClipboardText = vi.fn(async () => 'hello world')
+ const deps = makeDeps({ editor: handle, readClipboardText })
+
+ const outcome = runOrcaContextMenuPaste(deps)
+ expect(outcome).not.toBe(false)
+ await expect(outcome).resolves.toEqual({ status: 'pasted', mode: 'native' })
+ expect(readClipboardText).toHaveBeenCalledWith({ maxBytes: 16 * 1024 * 1024 })
+ expect(handle.trigger).toHaveBeenCalledWith('keyboard', 'paste', {
+ text: 'hello world',
+ pasteOnNewLine: false,
+ multicursorText: null,
+ mode: null
+ })
+ })
+
+ it('applies in-app copy metadata (empty-selection + multicursor) when enabled', async () => {
+ const handle = makeEditor({ emptySelectionClipboard: true })
+ const deps = makeDeps({
+ editor: handle,
+ readClipboardText: vi.fn(async () => 'line text'),
+ getClipboardMetadata: () => ({
+ isFromEmptySelection: true,
+ multicursorText: ['a', 'b'],
+ mode: 'typescript'
+ })
+ })
+
+ await runOrcaContextMenuPaste(deps)
+ expect(handle.trigger).toHaveBeenCalledWith('keyboard', 'paste', {
+ text: 'line text',
+ pasteOnNewLine: true,
+ multicursorText: ['a', 'b'],
+ mode: 'typescript'
+ })
+ })
+
+ it('ignores empty-selection metadata when the option is disabled', async () => {
+ const handle = makeEditor({ emptySelectionClipboard: false })
+ const deps = makeDeps({
+ editor: handle,
+ readClipboardText: vi.fn(async () => 'line text'),
+ getClipboardMetadata: () => ({ isFromEmptySelection: true })
+ })
+
+ await runOrcaContextMenuPaste(deps)
+ expect(handle.trigger).toHaveBeenCalledWith(
+ 'keyboard',
+ 'paste',
+ expect.objectContaining({ pasteOnNewLine: false })
+ )
+ })
+
+ it('does nothing on an empty clipboard', async () => {
+ const handle = makeEditor()
+ const deps = makeDeps({ editor: handle, readClipboardText: vi.fn(async () => '') })
+ await expect(runOrcaContextMenuPaste(deps)).resolves.toEqual({
+ status: 'noop',
+ reason: 'empty'
+ })
+ expect(handle.trigger).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a too-large toast and does not dispatch when the IPC read rejects', async () => {
+ const handle = makeEditor()
+ const onReadError = vi.fn()
+ const deps = makeDeps({
+ editor: handle,
+ readClipboardText: vi.fn(async () => {
+ throw new Error('Clipboard text is too large for this paste target.')
+ }),
+ onReadError
+ })
+
+ await expect(runOrcaContextMenuPaste(deps)).resolves.toEqual({
+ status: 'noop',
+ reason: 'read-failed'
+ })
+ expect(handle.trigger).not.toHaveBeenCalled()
+ expect(onReadError).toHaveBeenCalled()
+ })
+
+ it('does not dispatch a native paste if focus is lost during the async read', async () => {
+ const handle = makeEditor()
+ const deps = makeDeps({
+ editor: handle,
+ readClipboardText: vi.fn(async () => {
+ handle.setFocused(false)
+ return 'late text'
+ })
+ })
+
+ await expect(runOrcaContextMenuPaste(deps)).resolves.toEqual({
+ status: 'noop',
+ reason: 'target-lost'
+ })
+ expect(handle.trigger).not.toHaveBeenCalled()
+ })
+
+ it('routes oversized pastes through the chunked inserter instead of a native trigger', async () => {
+ const handle = makeEditor()
+ // 128 KiB exceeds the 64 KiB direct-paste threshold but is under the 16 MiB cap.
+ const bigText = 'x'.repeat(128 * 1024)
+ const deps = makeDeps({ editor: handle, readClipboardText: vi.fn(async () => bigText) })
+
+ const outcome = await runOrcaContextMenuPaste(deps)
+ expect(outcome).toEqual({ status: 'pasted', mode: 'chunked' })
+ expect(handle.trigger).not.toHaveBeenCalled()
+ expect(handle.editor.executeEdits).toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/editor/monaco-context-menu-paste.ts b/src/renderer/src/components/editor/monaco-context-menu-paste.ts
new file mode 100644
index 00000000000..4a33fb68352
--- /dev/null
+++ b/src/renderer/src/components/editor/monaco-context-menu-paste.ts
@@ -0,0 +1,147 @@
+import type { editor } from 'monaco-editor'
+import type { ReadClipboardTextOptions } from '../../../../shared/clipboard-text'
+import { measureTextControlPasteByteLength } from '@/lib/text-control-paste'
+import {
+ MONACO_PASTE_DIRECT_MAX_BYTES,
+ MONACO_PASTE_MAX_BYTES,
+ type MonacoPasteEditor,
+ executeMonacoLargeTextPaste
+} from './monaco-large-text-paste'
+
+// Why: Monaco's built-in context-menu Paste action (editor.action.clipboardPasteAction)
+// reads the clipboard with navigator.clipboard.readText(). Orca runs the renderer with
+// `sandbox: true`, where that read is blocked/empty — so right-click Paste silently does
+// nothing even though Cmd+V (a real OS paste ClipboardEvent) and right-click Copy
+// (execCommand('copy') from a user gesture) both work. We route the read through Orca's
+// trusted clipboard IPC bridge instead, matching how the terminal already reads it.
+export const ORCA_CONTEXT_MENU_PASTE_PRIORITY = 10001
+export const ORCA_CONTEXT_MENU_PASTE_NAME = 'orca-ipc-paste'
+
+// Why: this path may either dispatch a native paste (needs getOption/trigger)
+// or hand off to the chunked inserter (needs the MonacoPasteEditor surface), so
+// compose both. Both are satisfied by the real ICodeEditor.
+type PasteCapableEditor = MonacoPasteEditor &
+ Pick
+
+// Mirrors the in-memory metadata Monaco stores at copy time so an in-app
+// copy/paste round trip preserves empty-selection and multi-cursor behavior.
+type ClipboardPasteMetadata = {
+ isFromEmptySelection?: boolean
+ multicursorText?: string[] | null
+ mode?: string | null
+} | null
+
+export type OrcaContextMenuPasteDeps = {
+ getFocusedEditor: () => PasteCapableEditor | null
+ readClipboardText: (options?: ReadClipboardTextOptions) => Promise
+ getClipboardMetadata: (text: string) => ClipboardPasteMetadata
+ emptySelectionClipboardOptionId: editor.EditorOption
+ readOnlyOptionId: editor.EditorOption
+ onTooLarge?: () => void
+ onReadError?: (error: unknown) => void
+}
+
+export type OrcaContextMenuPasteOutcome =
+ | { status: 'pasted'; mode: 'native' | 'chunked' }
+ | { status: 'noop'; reason: 'empty' | 'read-failed' | 'too-large' | 'target-lost' }
+
+function resolvePasteMetadata(
+ editorInstance: PasteCapableEditor,
+ metadata: ClipboardPasteMetadata,
+ emptySelectionClipboardOptionId: editor.EditorOption
+): { pasteOnNewLine: boolean; multicursorText: string[] | null; mode: string | null } {
+ if (!metadata) {
+ return { pasteOnNewLine: false, multicursorText: null, mode: null }
+ }
+ // Why: pasteOnNewLine only applies when the user copied a whole line with no
+ // selection AND has empty-selection-clipboard enabled — same gate Monaco's
+ // own paste handlers use.
+ const emptySelectionClipboard = Boolean(editorInstance.getOption(emptySelectionClipboardOptionId))
+ return {
+ pasteOnNewLine: emptySelectionClipboard && metadata.isFromEmptySelection === true,
+ multicursorText:
+ typeof metadata.multicursorText !== 'undefined' ? (metadata.multicursorText ?? null) : null,
+ mode: metadata.mode ?? null
+ }
+}
+
+/**
+ * Replacement implementation for Monaco's clipboard paste command. Returns
+ * `false` when it declines to handle the paste so Monaco's default
+ * implementation runs unchanged (read-only editor, no focus, no clipboard
+ * bridge); otherwise performs the paste through Orca's IPC clipboard read and
+ * returns a Promise (truthy) so Monaco's blocked default never runs.
+ */
+export function runOrcaContextMenuPaste(
+ deps: OrcaContextMenuPasteDeps
+): false | Promise {
+ const editorInstance = deps.getFocusedEditor()
+ // Why: only claim the paste when an editor truly has text focus and a model —
+ // otherwise fall through so Monaco's default (and any other surface) behaves
+ // exactly as before.
+ if (!editorInstance || !editorInstance.getModel() || !editorInstance.hasTextFocus()) {
+ return false
+ }
+ // Why: read-only editors (e.g. the unchanged side of a diff) must not accept
+ // paste. The context menu hides the item via `when: writable`, but Cmd+V and
+ // the command palette still reach this command, so guard explicitly.
+ if (editorInstance.getOption(deps.readOnlyOptionId)) {
+ return false
+ }
+
+ return performOrcaContextMenuPaste(editorInstance, deps)
+}
+
+async function performOrcaContextMenuPaste(
+ editorInstance: PasteCapableEditor,
+ deps: OrcaContextMenuPasteDeps
+): Promise {
+ let text: string
+ try {
+ text = await deps.readClipboardText({ maxBytes: MONACO_PASTE_MAX_BYTES })
+ } catch (error) {
+ // Why: the IPC bridge rejects clipboard text over the safe limit. Surface
+ // the same too-large feedback the DOM-event paste path gives instead of
+ // pasting a truncated payload.
+ deps.onReadError?.(error)
+ return { status: 'noop', reason: 'read-failed' }
+ }
+ if (!text) {
+ return { status: 'noop', reason: 'empty' }
+ }
+
+ // Why: keep parity with the native Cmd+V paste, which routes oversized text
+ // through the chunked inserter (and rejects beyond the hard cap) so a huge
+ // right-click paste cannot freeze the renderer or bypass the size guard.
+ const directMeasurement = measureTextControlPasteByteLength(text, {
+ stopAfterBytes: MONACO_PASTE_DIRECT_MAX_BYTES
+ })
+ if (directMeasurement.exceededLimit) {
+ const result = await executeMonacoLargeTextPaste(editorInstance, text, { readOnly: false })
+ if (result.status === 'rejected' && result.reason === 'too-large') {
+ deps.onTooLarge?.()
+ return { status: 'noop', reason: 'too-large' }
+ }
+ if (result.status !== 'pasted') {
+ return { status: 'noop', reason: 'target-lost' }
+ }
+ return { status: 'pasted', mode: 'chunked' }
+ }
+
+ if (!editorInstance.hasTextFocus()) {
+ // Why: the await above yields to the event loop; if focus left the editor
+ // meanwhile, dispatching paste would land in the wrong place.
+ return { status: 'noop', reason: 'target-lost' }
+ }
+
+ const metadata = deps.getClipboardMetadata(text)
+ const { pasteOnNewLine, multicursorText, mode } = resolvePasteMetadata(
+ editorInstance,
+ metadata,
+ deps.emptySelectionClipboardOptionId
+ )
+ // Why: this is exactly how Monaco dispatches a paste internally — it respects
+ // the current selection, multi-cursor, indentation, and undo grouping.
+ editorInstance.trigger('keyboard', 'paste', { text, pasteOnNewLine, multicursorText, mode })
+ return { status: 'pasted', mode: 'native' }
+}
diff --git a/src/renderer/src/components/editor/monaco-large-text-paste.ts b/src/renderer/src/components/editor/monaco-large-text-paste.ts
index cf09672ab5c..5a5b7dff2b3 100644
--- a/src/renderer/src/components/editor/monaco-large-text-paste.ts
+++ b/src/renderer/src/components/editor/monaco-large-text-paste.ts
@@ -43,6 +43,21 @@ export type MonacoLargeTextPasteOptions = {
) => void
}
+// Why: getFocusedCodeEditor() (context-menu/command paste) yields the base
+// ICodeEditor, while @monaco-editor/react mounts an IStandaloneCodeEditor (DOM
+// paste path). Both share every method used here, so accept the base type and
+// let the stricter standalone callers pass through unchanged.
+export type MonacoPasteEditor = Pick<
+ editor.ICodeEditor,
+ | 'getModel'
+ | 'getContainerDomNode'
+ | 'hasTextFocus'
+ | 'getSelection'
+ | 'setSelection'
+ | 'executeEdits'
+ | 'pushUndoStop'
+>
+
type MonacoPasteSnapshot = {
container: HTMLElement
model: editor.ITextModel
@@ -113,9 +128,7 @@ function getEndPositionAfterInsert(start: Position, text: string): Position {
return { lineNumber, column }
}
-function snapshotMonacoPasteTarget(
- monacoEditor: editor.IStandaloneCodeEditor
-): MonacoPasteSnapshot | null {
+function snapshotMonacoPasteTarget(monacoEditor: MonacoPasteEditor): MonacoPasteSnapshot | null {
const model = monacoEditor.getModel()
const container = monacoEditor.getContainerDomNode()
if (!model || !container.isConnected || !monacoEditor.hasTextFocus()) {
@@ -125,7 +138,7 @@ function snapshotMonacoPasteTarget(
}
function isMonacoPasteTargetCurrent(
- monacoEditor: editor.IStandaloneCodeEditor,
+ monacoEditor: MonacoPasteEditor,
snapshot: MonacoPasteSnapshot
): boolean {
return (
@@ -136,10 +149,7 @@ function isMonacoPasteTargetCurrent(
)
}
-function setCollapsedSelection(
- monacoEditor: editor.IStandaloneCodeEditor,
- position: Position
-): void {
+function setCollapsedSelection(monacoEditor: MonacoPasteEditor, position: Position): void {
monacoEditor.setSelection({
startLineNumber: position.lineNumber,
startColumn: position.column,
@@ -153,7 +163,7 @@ function yieldToEventLoop(): Promise {
}
async function insertMonacoTextInChunks(
- monacoEditor: editor.IStandaloneCodeEditor,
+ monacoEditor: MonacoPasteEditor,
text: string,
byteLength: number,
options: MonacoLargeTextPasteOptions
@@ -206,8 +216,12 @@ async function insertMonacoTextInChunks(
return { status: 'pasted', mode: 'chunked', byteLength, chunksWritten }
}
-async function executeMonacoLargeTextPaste(
- monacoEditor: editor.IStandaloneCodeEditor,
+// Why: shared by the DOM-event paste path (handleMonacoLargeTextPaste) and the
+// context-menu paste path (monaco-context-menu-paste) so both enforce the same
+// too-large rejection and chunked-insert behavior regardless of how the paste
+// was triggered.
+export async function executeMonacoLargeTextPaste(
+ monacoEditor: MonacoPasteEditor,
text: string,
options: MonacoLargeTextPasteOptions
): Promise> {
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts
index dc81e50d3ae..f65df59ca04 100644
--- a/src/renderer/src/env.d.ts
+++ b/src/renderer/src/env.d.ts
@@ -9,6 +9,33 @@ declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' {
export const language: languages.IMonarchLanguage
}
+// Monaco ships these contributions without public type declarations. We only
+// touch the paste-override surface, so declare the minimal shape we use.
+declare module 'monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js' {
+ type PasteImplementation = () => boolean | Promise
+ export const PasteAction:
+ | {
+ addImplementation: (
+ priority: number,
+ name: string,
+ implementation: PasteImplementation
+ ) => { dispose: () => void }
+ }
+ | undefined
+}
+
+declare module 'monaco-editor/esm/vs/editor/browser/controller/editContext/clipboardUtils.js' {
+ export const InMemoryClipboardMetadataManager: {
+ INSTANCE: {
+ get: (pastedText: string) => {
+ isFromEmptySelection?: boolean
+ multicursorText?: string[] | null
+ mode?: string | null
+ } | null
+ }
+ }
+}
+
declare global {
var MonacoEnvironment:
| {
diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts
index 21353623efb..bbe7acfae17 100644
--- a/src/renderer/src/lib/monaco-setup.ts
+++ b/src/renderer/src/lib/monaco-setup.ts
@@ -12,6 +12,7 @@ import { registerNimLanguage } from './monaco-languages/register-nim'
import { registerSvelteLanguage } from './monaco-languages/register-svelte'
import { registerVueLanguage } from './monaco-languages/register-vue'
import { installMonacoDiffEditorDisposalGuard } from './monaco-diff-editor-disposal'
+import { installMonacoContextMenuPaste } from '@/components/editor/install-monaco-context-menu-paste'
globalThis.MonacoEnvironment = {
getWorker(_workerId, label) {
@@ -75,6 +76,10 @@ registerSvelteLanguage(monaco)
registerAstroLanguage(monaco)
registerNimLanguage(monaco)
installMonacoDiffEditorDisposalGuard(monaco)
+// Why: Monaco's built-in context-menu Paste reads navigator.clipboard, which is
+// blocked in Orca's sandboxed renderer. Route it through the trusted IPC bridge
+// so right-click Paste works like Cmd+V (which already works via native events).
+installMonacoContextMenuPaste(monaco)
// Configure Monaco to use the locally bundled editor instead of CDN
loader.config({ monaco })
From e51b2dfe3d5bcdd4b9c16fc37a0d1372e94d0532 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 28 Jun 2026 06:23:08 +0000
Subject: [PATCH 31/54] release: v1.4.104-rc.2
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 67e3ad8fcde..37a7318735c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.104-rc.1",
+ "version": "1.4.104-rc.2",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From a9ef6f9168af5ba18c2df85f95708370425b6073 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 28 Jun 2026 00:09:07 -0700
Subject: [PATCH 32/54] Recover frozen terminal panes after a backgrounded
agent exits (#6514)
Co-authored-by: Orca
---
.../terminal-pane/pty-connection.test.ts | 338 ++++++++++++++++++
.../terminal-pane/pty-connection.ts | 105 ++++++
.../terminal-dead-session-reconcile.test.ts | 108 ++++++
.../terminal-dead-session-reconcile.ts | 76 ++++
.../use-terminal-pane-lifecycle.test.ts | 38 ++
.../use-terminal-pane-lifecycle.ts | 37 ++
6 files changed, 702 insertions(+)
create mode 100644 src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts
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 ac1c52a4174..039ef115d99 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -145,6 +145,7 @@ type MockTransport = {
sendInputAccepted?: ReturnType
resize: ReturnType
getPtyId: ReturnType
+ getConnectionId: ReturnType
serializeBuffer?: ReturnType
}
@@ -276,6 +277,7 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
sendInput: vi.fn(() => true),
resize: vi.fn(() => true),
getPtyId: vi.fn(() => ptyId),
+ getConnectionId: vi.fn(() => null),
serializeBuffer: undefined
} as MockTransport
const sendInput = transport.sendInput as unknown as (data: string) => boolean
@@ -580,6 +582,7 @@ describe('connectPanePty', () => {
},
pty: {
signal: vi.fn(),
+ listSessions: vi.fn().mockResolvedValue([]),
getMainBufferSnapshot: vi.fn().mockResolvedValue(null),
getForegroundProcess: vi.fn().mockResolvedValue(null),
hasChildProcesses: vi.fn().mockResolvedValue(false),
@@ -9478,4 +9481,339 @@ describe('connectPanePty', () => {
expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null)
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
})
+
+ describe('reconcileIfSessionDead', () => {
+ it('closes a split pane bound to a dead local session (same teardown as onExit)', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
+ const transport = createMockTransport('pty-pane-2')
+ transport.connect.mockImplementation(
+ async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ capturedDataCallback.current = callbacks.onData ?? null
+ return 'pty-pane-2'
+ }
+ )
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+ // Why: clear the freshly-split early-return guard so onExit reaches the
+ // close branch, matching an established split pane the user has used.
+ capturedDataCallback.current?.('shell prompt')
+
+ binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
+
+ expect(manager.closePane).toHaveBeenCalledWith(2)
+ })
+
+ it('routes the last pane through onPtyExitRef when its session is dead', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-1')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(1)
+ const deps = createDeps()
+
+ const binding = connectPanePty(createPane(1) as never, manager as never, deps as never)
+
+ // The last pane reattaches to the tab's persisted ptyId ('tab-pty').
+ binding.reconcileIfSessionDead(new Set(['some-other-live']))
+
+ expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty')
+ expect(manager.closePane).not.toHaveBeenCalled()
+ })
+
+ it('is a no-op when the bound session is still live', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+
+ binding.reconcileIfSessionDead(new Set(['pty-pane-1', 'pty-pane-2']))
+
+ expect(manager.closePane).not.toHaveBeenCalled()
+ expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
+ })
+
+ it('is a no-op for remote: web-runtime ids', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ enableActiveRuntimeEnvironment('env-1')
+ const transport = createMockTransport('remote:env-1:pane-2')
+ transport.getConnectionId.mockReturnValue(null)
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+
+ binding.reconcileIfSessionDead(new Set())
+
+ expect(manager.closePane).not.toHaveBeenCalled()
+ expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
+ })
+
+ it('is a no-op for SSH/non-local ids (non-null connectionId)', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transport.getConnectionId.mockReturnValue('ssh-target-1')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+
+ binding.reconcileIfSessionDead(new Set())
+
+ expect(manager.closePane).not.toHaveBeenCalled()
+ expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
+ })
+
+ it('respects suppression: a suppressed dead session keeps the pane mounted', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ consumeSuppressedPtyExit: vi.fn(() => true),
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+
+ binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
+
+ expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2')
+ expect(manager.closePane).not.toHaveBeenCalled()
+ expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
+ })
+
+ it('still closes the replacement PTY after a suppressed restart rebinds the pane', async () => {
+ // Why (regression): the exit guard is scoped to the exiting ptyId, not a
+ // bare one-shot boolean. An intentional suppressed restart keeps the pane
+ // mounted and rebinds to a NEW ptyId; that replacement's later real exit
+ // must still tear the pane down. A boolean guard would strand it.
+ const { connectPanePty } = await import('./pty-connection')
+ const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
+ const transport = createMockTransport('pty-pane-2')
+ transport.connect.mockImplementation(
+ async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ capturedDataCallback.current = callbacks.onData ?? null
+ return 'pty-pane-2'
+ }
+ )
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ // First exit is suppressed (intentional restart); later exits are real.
+ const consumeSuppressedPtyExit = vi
+ .fn<(ptyId: string) => boolean>()
+ .mockImplementationOnce(() => true)
+ .mockImplementation(() => false)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ consumeSuppressedPtyExit,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ connectPanePty(createPane(2) as never, manager as never, deps as never)
+ capturedDataCallback.current?.('shell prompt')
+ const onPtyExit = createdTransportOptions[0]?.onPtyExit as
+ | ((ptyId: string) => void)
+ | undefined
+
+ // Suppressed exit of the original PTY: pane stays mounted.
+ onPtyExit?.('pty-pane-2')
+ expect(manager.closePane).not.toHaveBeenCalled()
+
+ // The restart rebinds the pane to a new live PTY; its later real exit
+ // must NOT be ignored by a stale guard.
+ transport.getPtyId.mockReturnValue('pty-pane-2-restarted')
+ onPtyExit?.('pty-pane-2-restarted')
+
+ expect(manager.closePane).toHaveBeenCalledTimes(1)
+ expect(manager.closePane).toHaveBeenCalledWith(2)
+ })
+
+ it('does not act on a stale id after a reattach changed the bound id', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+
+ // Reattach to a new live session between snapshot and apply: the snapshot
+ // marked the OLD id dead, but getPtyId now returns the new (live) id.
+ transport.getPtyId.mockReturnValue('pty-pane-2-reattached')
+ binding.reconcileIfSessionDead(new Set(['pty-pane-1', 'pty-pane-2-reattached']))
+
+ expect(manager.closePane).not.toHaveBeenCalled()
+ expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
+ })
+
+ it('closes a split pane exactly once across reconcile + a racing real exit', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
+ const transport = createMockTransport('pty-pane-2')
+ transport.connect.mockImplementation(
+ async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ capturedDataCallback.current = callbacks.onData ?? null
+ return 'pty-pane-2'
+ }
+ )
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+ capturedDataCallback.current?.('shell prompt')
+ const onPtyExit = createdTransportOptions[0]?.onPtyExit as
+ | ((ptyId: string) => void)
+ | undefined
+ expect(onPtyExit).toBeTypeOf('function')
+
+ binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
+ // A racing real/synthetic pty:exit for the SAME id must not close twice.
+ onPtyExit?.('pty-pane-2')
+
+ expect(manager.closePane).toHaveBeenCalledTimes(1)
+ expect(manager.closePane).toHaveBeenCalledWith(2)
+ })
+
+ it('closes a genuinely-dead non-suppressed pane once (not misread as suppressed)', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
+ const transport = createMockTransport('pty-pane-2')
+ transport.connect.mockImplementation(
+ async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ capturedDataCallback.current = callbacks.onData ?? null
+ return 'pty-pane-2'
+ }
+ )
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ // consumeSuppressedPtyExit always returns false for this genuinely-dead id.
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ consumeSuppressedPtyExit: vi.fn(() => false),
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+
+ const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
+ capturedDataCallback.current?.('shell prompt')
+ const onPtyExit = createdTransportOptions[0]?.onPtyExit as
+ | ((ptyId: string) => void)
+ | undefined
+
+ binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
+ onPtyExit?.('pty-pane-2')
+
+ // Observable outcome: single close, pane was treated as dead (closed).
+ expect(manager.closePane).toHaveBeenCalledTimes(1)
+ expect(manager.closePane).toHaveBeenCalledWith(2)
+ })
+ })
+
+ describe('input liveness re-check IPC gating (perf)', () => {
+ // Why (perf regression guard): listSessions() is a renderer→main→daemon
+ // round-trip. The input-driven liveness re-check must fire at most once per
+ // resume window, never once per keystroke, or every healthy local pane puts
+ // a process-enumeration round-trip on the typing hot path.
+ async function connectActivePaneWithInput(): Promise<{
+ binding: { noteVisibilityResume: () => void }
+ typeKeystroke: (data?: string) => void
+ }> {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+ const pane = createPane(2)
+ const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
+ noteVisibilityResume: () => void
+ }
+ return {
+ binding,
+ // Drives the real xterm onData (terminal input) handler.
+ typeKeystroke: (data = 'a') => sendTerminalInputThroughPane(pane, data)
+ }
+ }
+
+ it('fires listSessions at most once across many keystrokes in one resume window', async () => {
+ const listSessions = vi.mocked(window.api.pty.listSessions)
+ listSessions.mockClear()
+ const { typeKeystroke } = await connectActivePaneWithInput()
+
+ for (let i = 0; i < 25; i++) {
+ typeKeystroke('x')
+ }
+
+ expect(listSessions).toHaveBeenCalledTimes(1)
+ })
+
+ it('re-arms one re-check after a visibility resume', async () => {
+ const listSessions = vi.mocked(window.api.pty.listSessions)
+ listSessions.mockClear()
+ const { binding, typeKeystroke } = await connectActivePaneWithInput()
+
+ typeKeystroke('a')
+ typeKeystroke('b')
+ expect(listSessions).toHaveBeenCalledTimes(1)
+
+ // Resume re-arms exactly one more re-check.
+ binding.noteVisibilityResume()
+ typeKeystroke('c')
+ typeKeystroke('d')
+ expect(listSessions).toHaveBeenCalledTimes(2)
+ })
+
+ it('never fires listSessions for a remote: web-runtime pane (liveness owned by host snapshot)', async () => {
+ const listSessions = vi.mocked(window.api.pty.listSessions)
+ listSessions.mockClear()
+ const { connectPanePty } = await import('./pty-connection')
+ // Remote panes report a null connectionId but a remote:-prefixed ptyId, so
+ // the SSH/connectionId guard alone would not exclude them — the remote
+ // prefix guard must.
+ const transport = createMockTransport('remote:env-1@@terminal-2')
+ transport.getConnectionId.mockReturnValue(null)
+ transportFactoryQueue.push(transport)
+ const manager = createManager(2)
+ const deps = createDeps({
+ restoredLeafId: LEAF_2,
+ paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
+ })
+ const pane = createPane(2)
+ connectPanePty(pane as never, manager as never, deps as never)
+
+ sendTerminalInputThroughPane(pane, 'x')
+ sendTerminalInputThroughPane(pane, 'y')
+
+ expect(listSessions).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index e6930cfbd9d..8ccd7592c78 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -20,6 +20,7 @@ import {
hasCachedWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
+import { shouldReconcileDeadSession } from './terminal-dead-session-reconcile'
import type { PtyConnectionDeps } from './pty-connection-types'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
@@ -506,6 +507,8 @@ let inactiveForegroundImmediateBudgetWindowStart = 0
type PanePtyBinding = IDisposable & {
syncProcessTracking: () => void
+ noteVisibilityResume: () => void
+ reconcileIfSessionDead: (liveSessionIds: Set) => void
}
function isAgentTaskCompleteNotificationEnabled(): boolean {
@@ -1293,7 +1296,20 @@ export function connectPanePty(
}
})
+ // Why: the transport's own exit handler (pty-transport.ts) normally makes
+ // onExit run-at-most-once by clearing connected/ptyId + unregistering BEFORE
+ // calling it. reconcileIfSessionDead drives onExit directly (bypassing that),
+ // so this guards the body so reconcile and any racing real/synthetic pty:exit
+ // for the same id close the pane exactly once. Scoped to the exiting ptyId
+ // (not a bare boolean): an intentional suppressed restart keeps the pane
+ // mounted and rebinds to a NEW ptyId, and that replacement's later real exit
+ // must still run — a one-shot boolean would strand the pane on rebind.
+ let handledExitPtyId: string | null = null
const onExit = (ptyId: string): void => {
+ if (handledExitPtyId === ptyId) {
+ return
+ }
+ handledExitPtyId = ptyId
agentCompletionCoordinator.dispose()
clearPanePtyFitBinding()
const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId)
@@ -1925,6 +1941,11 @@ export function connectPanePty(
clearPendingTerminalInputIntent()
return
}
+ // Why (Defect #2): a keystroke against a pane whose daemon session was
+ // reaped while hidden is silently dropped — sendInput still returns true.
+ // Kick off a fire-and-forget liveness re-check so the dead pane is cleaned
+ // up without relying on (the never-occurring) sendInput false return.
+ recheckLivenessAfterInput()
const intent = pendingTerminalInputIntent
// Why: real xterm can deliver the terminal byte even when our DOM keydown
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
@@ -4134,10 +4155,94 @@ export function connectPanePty(
scheduleRuntimeGraphSync()
})
+ // Why: on visibility resume a pane may still be bound to a daemon session
+ // reaped while hidden (the missed-exit defect). Route it through the SAME
+ // teardown a real onExit runs. Re-validate identity at apply time so a
+ // reattach racing the listSessions snapshot is never clobbered, and respect
+ // the remote/SSH guards. Suppression semantics come for free via onExit
+ // (which consults consumeSuppressedPtyExit) plus the per-ptyId guard above.
+ const reconcileIfSessionDead = (liveSessionIds: Set): void => {
+ if (disposed) {
+ return
+ }
+ const currentPtyId = transport.getPtyId()
+ if (
+ !currentPtyId ||
+ // Why: the current ptyId's exit was already handled — onExit guards this
+ // too, but skipping here avoids a redundant shouldReconcile evaluation.
+ handledExitPtyId === currentPtyId ||
+ !shouldReconcileDeadSession({
+ ptyId: currentPtyId,
+ connectionId: transport.getConnectionId?.(),
+ liveSessionIds
+ })
+ ) {
+ return
+ }
+ onExit(currentPtyId)
+ }
+
+ // Why (perf): the only moment a daemon session can be reaped behind the
+ // renderer's back is while the pane was surface-hidden. So the input-driven
+ // re-check is only useful in the window right after a resume — once it (or
+ // the resume pass) has confirmed liveness for this resume, re-polling on
+ // every subsequent keystroke is pure waste: listSessions() is a
+ // renderer→main→daemon round-trip (DaemonPtyAdapter.listProcesses requests
+ // `listSessions` from the daemon subprocess), so an ungated per-keystroke
+ // re-check would put a process-enumeration round-trip on the typing hot path
+ // for every healthy local pane. Fire at most ONCE per resume window; reset
+ // on the next hide→show. This preserves the "reduces not eliminates the
+ // first-keystroke drop" intent — the first keystroke after a resume still
+ // triggers exactly one re-check.
+ let livenessRecheckFiredSinceResume = false
+
+ // Why (Defect #2 defense-in-depth): in the broken state sendInput returns
+ // true (connected/ptyId still set) so the dropped keystroke is invisible to
+ // the renderer. A fire-and-forget liveness re-check on the FIRST input after
+ // a resume cleans the pane up promptly instead of waiting for the resume
+ // pass alone. It REDUCES but cannot eliminate the first-keystroke drop (that
+ // byte is already gone daemon-side).
+ const recheckLivenessAfterInput = (): void => {
+ if (disposed || livenessRecheckFiredSinceResume) {
+ return
+ }
+ const currentPtyId = transport.getPtyId()
+ const currentConnectionId = transport.getConnectionId?.()
+ if (
+ !currentPtyId ||
+ // Why: this ptyId's exit was already handled — nothing left to reconcile.
+ handledExitPtyId === currentPtyId ||
+ // Why: `remote:` web-runtime liveness is owned by the host snapshot, not
+ // listSessions; skip here so a remote pane's keystrokes never put a local
+ // daemon round-trip on the typing hot path (reconcile would no-op anyway).
+ isRemoteRuntimePtyId(currentPtyId) ||
+ (currentConnectionId !== null && currentConnectionId !== undefined)
+ ) {
+ return
+ }
+ // Why: set BEFORE the IPC so concurrent keystrokes coalesce to one in-flight
+ // request rather than fanning out a round-trip per byte typed.
+ livenessRecheckFiredSinceResume = true
+ void window.api.pty
+ .listSessions()
+ .then((sessions) => {
+ reconcileIfSessionDead(new Set(sessions.map((session) => session.id)))
+ })
+ // Why: a rejected listing is "unknown" — never close a pane on it.
+ .catch(() => {})
+ }
+
return {
syncProcessTracking() {
agentCompletionCoordinator.startProcessTracking()
},
+ // Why: re-arm the once-per-resume input re-check when the pane becomes
+ // visible again. Called from the lifecycle visibility effect; the gate
+ // keeps the typing hot path off the listSessions IPC between resumes.
+ noteVisibilityResume() {
+ livenessRecheckFiredSinceResume = false
+ },
+ reconcileIfSessionDead,
dispose() {
disposed = true
if (terminalKeyTargetSupportsEvents) {
diff --git a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts
new file mode 100644
index 00000000000..215b833086d
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ reconcileDeadSessions,
+ shouldReconcileDeadSession
+} from './terminal-dead-session-reconcile'
+
+describe('shouldReconcileDeadSession', () => {
+ it('reconciles a local, non-remote id genuinely absent from the live set', () => {
+ expect(
+ shouldReconcileDeadSession({
+ ptyId: 'wt@@dead',
+ connectionId: null,
+ liveSessionIds: new Set(['wt@@alive'])
+ })
+ ).toBe(true)
+ })
+
+ it('does not reconcile when the bound id is still live', () => {
+ expect(
+ shouldReconcileDeadSession({
+ ptyId: 'wt@@alive',
+ connectionId: null,
+ liveSessionIds: new Set(['wt@@alive'])
+ })
+ ).toBe(false)
+ })
+
+ it('skips a mid-spawn pane with no bound id', () => {
+ expect(
+ shouldReconcileDeadSession({
+ ptyId: null,
+ connectionId: null,
+ liveSessionIds: new Set()
+ })
+ ).toBe(false)
+ })
+
+ it('skips remote: web-runtime ids', () => {
+ expect(
+ shouldReconcileDeadSession({
+ ptyId: 'remote:env-1:abc',
+ connectionId: null,
+ liveSessionIds: new Set()
+ })
+ ).toBe(false)
+ })
+
+ it('skips SSH/non-local ids (non-null connectionId)', () => {
+ expect(
+ shouldReconcileDeadSession({
+ ptyId: 'wt@@ssh-dead',
+ connectionId: 'ssh-target-1',
+ liveSessionIds: new Set(['wt@@alive'])
+ })
+ ).toBe(false)
+ })
+
+ it('reconciles a genuinely-absent local id even when the live set is empty (no zero-total skip)', () => {
+ expect(
+ shouldReconcileDeadSession({
+ ptyId: 'wt@@dead',
+ connectionId: null,
+ liveSessionIds: new Set()
+ })
+ ).toBe(true)
+ })
+})
+
+describe('reconcileDeadSessions', () => {
+ function createBinding() {
+ return { reconcileIfSessionDead: vi.fn<(liveSessionIds: Set) => void>() }
+ }
+
+ it('invokes each binding with the resolved live-session id set', async () => {
+ const bindingA = createBinding()
+ const bindingB = createBinding()
+ await reconcileDeadSessions({
+ bindings: [bindingA, bindingB],
+ listSessions: async () => [
+ { id: 'wt@@alive', cwd: '/a', title: 'a' },
+ { id: 'wt@@other', cwd: '/b', title: 'b' }
+ ]
+ })
+ const expectedSet = new Set(['wt@@alive', 'wt@@other'])
+ expect(bindingA.reconcileIfSessionDead).toHaveBeenCalledWith(expectedSet)
+ expect(bindingB.reconcileIfSessionDead).toHaveBeenCalledWith(expectedSet)
+ })
+
+ it('treats a rejected listSessions as "unknown" and reconciles nothing', async () => {
+ const binding = createBinding()
+ await reconcileDeadSessions({
+ bindings: [binding],
+ listSessions: async () => {
+ throw new Error('IPC failed')
+ }
+ })
+ expect(binding.reconcileIfSessionDead).not.toHaveBeenCalled()
+ })
+
+ it('treats a resolved empty list as authoritative (still reconciles)', async () => {
+ const binding = createBinding()
+ await reconcileDeadSessions({
+ bindings: [binding],
+ listSessions: async () => []
+ })
+ expect(binding.reconcileIfSessionDead).toHaveBeenCalledWith(new Set())
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts
new file mode 100644
index 00000000000..6c479883150
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts
@@ -0,0 +1,76 @@
+// Renderer-side liveness reconciliation for terminal panes whose daemon PTY
+// session was reaped while the worktree was surface-hidden. On visibility
+// resume the missed `pty:exit` left the pane mounted bound to a dead session;
+// this routes such panes through the same exit teardown an observed exit runs.
+// See design-docs/terminal-dead-pane-on-bg-exit.md.
+
+const REMOTE_PTY_ID_PREFIX = 'remote:'
+
+/**
+ * A pane binding that exposes its bound transport identity plus a reconcile
+ * hook. Kept structural so the lifecycle fan-out can call it without importing
+ * the full `PanePtyBinding` shape from pty-connection.
+ */
+export type ReconcilableBinding = {
+ reconcileIfSessionDead?: (liveSessionIds: Set) => void
+}
+
+/**
+ * PURE decision: should the pane bound to `ptyId` be reconciled (torn down)
+ * given the resolved set of live session ids?
+ *
+ * Reconcile ONLY when every guard passes:
+ * - `ptyId` is non-null (a mid-spawn pane has no id to prove dead).
+ * - `ptyId` is not `remote:`-prefixed (web-runtime liveness is owned by the
+ * host snapshot, not `listSessions`).
+ * - `connectionId === null` — the id is local/daemon-backed. SSH-backed ids
+ * (non-null connectionId) are deferred: the flat `listSessions` shape cannot
+ * authoritatively prove an SSH session gone (see design Section 1).
+ * - the id is genuinely absent from the resolved live set.
+ */
+export function shouldReconcileDeadSession(args: {
+ ptyId: string | null | undefined
+ connectionId: string | null | undefined
+ liveSessionIds: Set
+}): boolean {
+ const { ptyId, connectionId, liveSessionIds } = args
+ if (ptyId === null || ptyId === undefined) {
+ return false
+ }
+ if (ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) {
+ return false
+ }
+ // Why: only local/daemon-backed ids (connectionId null/undefined) are
+ // reconcilable; a non-null connectionId means SSH, which is deferred.
+ if (connectionId !== null && connectionId !== undefined) {
+ return false
+ }
+ return !liveSessionIds.has(ptyId)
+}
+
+/**
+ * Thin orchestration: fetch the live session listing once and invoke each
+ * binding's `reconcileIfSessionDead` with the resolved set.
+ *
+ * Why a rejected `listSessions()` is swallowed (no reconcile): the local
+ * provider listing is NOT `.catch`-wrapped in main, so a rejection means the
+ * listing is unknown — closing a pane on an IPC/listing failure is unacceptable.
+ * A RESOLVED list (even empty `[]`) is AUTHORITATIVE for local ids: a local id
+ * absent from it is genuinely reaped, so there is no zero-total skip.
+ */
+export async function reconcileDeadSessions(args: {
+ bindings: Iterable
+ listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
+}): Promise {
+ let sessions: { id: string }[]
+ try {
+ sessions = await args.listSessions()
+ } catch {
+ // Why: a rejected listing is "unknown" — never close a pane on it.
+ return
+ }
+ const liveSessionIds = new Set(sessions.map((session) => session.id))
+ for (const binding of args.bindings) {
+ binding.reconcileIfSessionDead?.(liveSessionIds)
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
index e5aaba85a86..049312db69e 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
mapRestoredPaneTitlesByPaneId,
+ scheduleVisibilityReconcilePass,
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup,
suppressIntentionalPaneCloseExit
@@ -182,3 +183,40 @@ describe('suppressIntentionalPaneCloseExit', () => {
expect(suppressPtyExit).not.toHaveBeenCalled()
})
})
+
+describe('scheduleVisibilityReconcilePass', () => {
+ it('schedules a reconcile pass over the bindings when becoming visible', async () => {
+ const reconcileIfSessionDead = vi.fn()
+ const listSessions = vi
+ .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>()
+ .mockResolvedValue([{ id: 'live-1', cwd: '/a', title: 'a' }])
+
+ const scheduled = scheduleVisibilityReconcilePass({
+ isVisible: true,
+ bindings: [{ reconcileIfSessionDead }],
+ listSessions
+ })
+
+ expect(scheduled).toBe(true)
+ // Fire-and-forget: let the async listSessions resolve before asserting.
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(listSessions).toHaveBeenCalledTimes(1)
+ expect(reconcileIfSessionDead).toHaveBeenCalledWith(new Set(['live-1']))
+ })
+
+ it('self-gates: does not schedule when hiding (isVisible false)', () => {
+ const listSessions = vi
+ .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>()
+ .mockResolvedValue([])
+
+ const scheduled = scheduleVisibilityReconcilePass({
+ isVisible: false,
+ bindings: [{ reconcileIfSessionDead: vi.fn() }],
+ listSessions
+ })
+
+ expect(scheduled).toBe(false)
+ expect(listSessions).not.toHaveBeenCalled()
+ })
+})
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 fe67c1ad83a..0d44bce6518 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
@@ -70,6 +70,7 @@ import { installMouseHideWhileTyping } from './mouse-hide-while-typing'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
+import { reconcileDeadSessions, type ReconcilableBinding } from './terminal-dead-session-reconcile'
import type { PtyTransport } from './pty-transport'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { getConnectionId } from '@/lib/connection-context'
@@ -361,6 +362,26 @@ export function shouldDetachPaneTransportOnUnmount(args: {
)
}
+/**
+ * Self-gating dead-session reconcile pass scheduled from the isVisible effect.
+ * Why self-gate: the effect fires on BOTH isVisible true and false, but we only
+ * reconcile on resume (becoming visible), never on hide. Returns true when the
+ * pass was scheduled so the resume-unit test can assert the gate.
+ */
+export function scheduleVisibilityReconcilePass(args: {
+ isVisible: boolean
+ bindings: Iterable
+ listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
+}): boolean {
+ if (!args.isVisible) {
+ return false
+ }
+ // Why: fire-and-forget so the async listSessions IPC never blocks the
+ // synchronous WebGL/fit resume work the user sees first.
+ void reconcileDeadSessions({ bindings: args.bindings, listSessions: args.listSessions })
+ return true
+}
+
export function useTerminalPaneLifecycle({
tabId,
worktreeId,
@@ -1504,9 +1525,25 @@ export function useTerminalPaneLifecycle({
for (const panePtyBinding of panePtyBindingsRef.current.values()) {
const bindingWithVisibility = panePtyBinding as IDisposable & {
syncProcessTracking?: () => void
+ noteVisibilityResume?: () => void
}
bindingWithVisibility.syncProcessTracking?.()
+ // Why: re-arm the once-per-resume input liveness re-check so the typing
+ // hot path stays off the listSessions IPC between resumes (the re-check
+ // is only useful right after a hidden→visible flip).
+ if (isVisible) {
+ bindingWithVisibility.noteVisibilityResume?.()
+ }
}
+ // Why: the reconcile pass self-gates on becoming visible (resume) — the
+ // effect also fires on hide — and runs fire-and-forget alongside
+ // syncProcessTracking. reconcileDeadSessions re-validates identity at apply
+ // time so a racing reattach is not clobbered.
+ scheduleVisibilityReconcilePass({
+ isVisible,
+ bindings: panePtyBindingsRef.current.values() as Iterable,
+ listSessions: () => window.api.pty.listSessions()
+ })
// eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility flips must refresh existing PTY process tracking even though the ref object identity is stable.
}, [isVisible, isVisibleRef, panePtyBindingsRef])
From 3d22d69d03043725ff778fe804b8c727bfdb7593 Mon Sep 17 00:00:00 2001
From: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Date: Sun, 28 Jun 2026 09:13:36 +0200
Subject: [PATCH 33/54] feat(terminal): add "Copy Context" to the terminal
context menu (#6267)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(terminal): add "Copy Context" to the terminal context menu
Copying an agent session's captured context required opening the Fork
Agent Session dialog and clicking its "Copy context" button — two steps
for something you often just want on its own (e.g. to paste elsewhere).
Add a "Copy Context" item to the terminal right-click menu, right under
"Fork Agent Session…". It reuses the existing capture + clipboard path
(prepareAgentSessionForkFromPane → copyAgentSessionForkContext), so the
copied text is identical to the dialog's, just one click away and without
opening the dialog.
Fixes #5020
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(terminal): copy plain context, not the fork prompt
Copy Context now copies the bounded, cleaned transcript on its own with a
neutral 'Context copied' toast, instead of reusing the fork prompt (with its
'this is a fork… acknowledge and wait' framing) and the fork-copy toast. That
framing is noise when pasting into another tool, which is the issue's use case.
Extracts buildBoundedSessionTranscript() from the fork-prompt builder and adds
copyAgentSessionContextFromPane(); the fork dialog's own copy button is
unchanged.
Co-authored-by: Orca
---------
Co-authored-by: Claude Opus 4.8 (1M context)
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca
---
.../TerminalContextMenu.test.tsx | 97 +++++++++++++++++++
.../terminal-pane/TerminalContextMenu.tsx | 10 ++
.../components/terminal-pane/TerminalPane.tsx | 1 +
.../terminal-agent-session-fork.test.ts | 54 +++++++++++
.../terminal-agent-session-fork.ts | 46 ++++++++-
.../use-terminal-pane-context-menu.ts | 14 +++
src/renderer/src/i18n/locales/en.json | 8 +-
src/renderer/src/i18n/locales/es.json | 8 +-
src/renderer/src/i18n/locales/ja.json | 8 +-
src/renderer/src/i18n/locales/ko.json | 8 +-
src/renderer/src/i18n/locales/zh.json | 8 +-
.../lib/agent-session-fork-context.test.ts | 17 ++++
.../src/lib/agent-session-fork-context.ts | 11 ++-
13 files changed, 276 insertions(+), 14 deletions(-)
create mode 100644 src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx
new file mode 100644
index 00000000000..6b352f182b2
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import TerminalContextMenu from './TerminalContextMenu'
+
+type ItemProps = { onSelect?: () => void; children?: React.ReactNode }
+
+const items = vi.hoisted(() => ({ list: [] as ItemProps[] }))
+
+vi.mock('@/components/ui/dropdown-menu', async () => {
+ const React_ = await import('react')
+ const passthrough = ({ children }: { children?: React.ReactNode }) =>
+ React_.createElement(React_.Fragment, null, children)
+ return {
+ DropdownMenu: passthrough,
+ DropdownMenuContent: passthrough,
+ DropdownMenuLabel: passthrough,
+ DropdownMenuSeparator: () => null,
+ DropdownMenuShortcut: passthrough,
+ DropdownMenuSub: passthrough,
+ DropdownMenuSubContent: passthrough,
+ DropdownMenuSubTrigger: passthrough,
+ DropdownMenuTrigger: passthrough,
+ DropdownMenuItem: (props: ItemProps) => {
+ items.list.push(props)
+ return React.createElement(React.Fragment, null, props.children)
+ }
+ }
+})
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null }))
+vi.mock('@/hooks/useShortcutLabel', () => ({ formatShortcutLabel: () => 'X' }))
+vi.mock('./terminal-context-menu-dismiss', () => ({
+ shouldIgnoreTerminalMenuPointerDownOutside: () => false
+}))
+
+function childrenText(children: React.ReactNode): string {
+ return React.Children.toArray(children)
+ .filter((child): child is string => typeof child === 'string')
+ .join('')
+}
+
+function renderMenu(overrides: Record = {}): void {
+ const props = {
+ open: true,
+ onOpenChange: vi.fn(),
+ menuPoint: { x: 0, y: 0 },
+ menuOpenedAtRef: { current: 0 },
+ canClosePane: true,
+ canExpandPane: true,
+ menuPaneIsExpanded: false,
+ onCopy: vi.fn(),
+ onPaste: vi.fn(),
+ onSplitRight: vi.fn(),
+ onSplitDown: vi.fn(),
+ keybindings: {},
+ canEqualizePaneSizes: false,
+ onEqualizePaneSizes: vi.fn(),
+ onClosePane: vi.fn(),
+ onClearScreen: vi.fn(),
+ onForkAgentSession: vi.fn(),
+ onCopyAgentSessionContext: vi.fn(),
+ repoQuickCommands: [],
+ globalQuickCommands: [],
+ quickCommandRepoLabel: null,
+ onQuickCommand: vi.fn(),
+ onAddQuickCommand: vi.fn(),
+ onToggleExpand: vi.fn(),
+ onSetTitle: vi.fn(),
+ onCopyTerminalId: vi.fn(),
+ onCopyPaneId: vi.fn(),
+ ...overrides
+ }
+ renderToStaticMarkup(React.createElement(TerminalContextMenu, props))
+}
+
+describe('TerminalContextMenu', () => {
+ beforeEach(() => {
+ items.list = []
+ })
+
+ it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => {
+ const onCopyAgentSessionContext = vi.fn()
+ const onForkAgentSession = vi.fn()
+ renderMenu({ onCopyAgentSessionContext, onForkAgentSession })
+
+ const copyContextItem = items.list.find(
+ (item) => childrenText(item.children) === 'Copy Context'
+ )
+ expect(copyContextItem).toBeDefined()
+
+ copyContextItem?.onSelect?.()
+ expect(onCopyAgentSessionContext).toHaveBeenCalledTimes(1)
+ // Why: copying context must not go through the fork dialog path.
+ expect(onForkAgentSession).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
index ecbd50731f5..da8c45c617d 100644
--- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import {
Clipboard,
+ ClipboardCopy,
Copy,
Eraser,
GitFork,
@@ -52,6 +53,7 @@ type TerminalContextMenuProps = {
onClosePane: () => void
onClearScreen: () => void
onForkAgentSession: () => void
+ onCopyAgentSessionContext: () => void
repoQuickCommands: TerminalQuickCommand[]
globalQuickCommands: TerminalQuickCommand[]
quickCommandRepoLabel: string | null
@@ -81,6 +83,7 @@ export default function TerminalContextMenu({
onClosePane,
onClearScreen,
onForkAgentSession,
+ onCopyAgentSessionContext,
repoQuickCommands,
globalQuickCommands,
quickCommandRepoLabel,
@@ -247,6 +250,13 @@ export default function TerminalContextMenu({
'Fork Agent Session…'
)}
+
+
+ {translate(
+ 'auto.components.terminal.pane.TerminalContextMenu.cff67afad1',
+ 'Copy Context'
+ )}
+
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index b8e8cf1ab84..463648c6cdf 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -2593,6 +2593,7 @@ export default function TerminalPane({
onClosePane={contextMenu.onClosePane}
onClearScreen={contextMenu.onClearScreen}
onForkAgentSession={() => void contextMenu.onForkAgentSession()}
+ onCopyAgentSessionContext={() => void contextMenu.onCopyAgentSessionContext()}
repoQuickCommands={repoQuickCommands}
globalQuickCommands={globalQuickCommands}
quickCommandRepoLabel={quickCommandRepoLabel}
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
index 88584e8a653..cbbbe685080 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
@@ -516,3 +516,57 @@ describe('forkAgentSessionFromPane', () => {
expect(pane.terminal.focus).toHaveBeenCalled()
})
})
+
+describe('copyAgentSessionContextFromPane', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockWriteClipboardText.mockResolvedValue(undefined)
+ vi.stubGlobal('window', {
+ api: { ui: { writeClipboardText: mockWriteClipboardText } }
+ })
+ })
+
+ it('copies the bounded transcript without the fork prompt framing', async () => {
+ const pane = makePane('User: standalone copy\nAssistant: acknowledged')
+ const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork')
+
+ const copied = await copyAgentSessionContextFromPane(pane)
+
+ expect(copied).toBe(true)
+ expect(mockWriteClipboardText).toHaveBeenCalledTimes(1)
+ const clipped = (mockWriteClipboardText.mock.calls as unknown as string[][])[0][0]
+ expect(clipped).toContain('User: standalone copy')
+ // Why: standalone copy must not carry the fork header/footer the dialog adds.
+ expect(clipped).not.toContain('fork of an existing Orca agent session')
+ expect(clipped).not.toContain('wait for my next instruction')
+ expect(mockToast.message).toHaveBeenCalledWith('Context copied')
+ expect(mockToast.message).not.toHaveBeenCalledWith(
+ 'Fork context copied. Launch an agent and paste it to start the fork.'
+ )
+ expect(pane.terminal.focus).toHaveBeenCalled()
+ })
+
+ it('shows a copy-specific empty-context error without writing the clipboard', async () => {
+ const pane = makePane('\x1b[0m\r\n\x1bc\x07')
+ const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork')
+
+ const copied = await copyAgentSessionContextFromPane(pane)
+
+ expect(copied).toBe(false)
+ expect(mockWriteClipboardText).not.toHaveBeenCalled()
+ expect(mockToast.error).toHaveBeenCalledWith('No terminal context to copy')
+ expect(pane.terminal.focus).toHaveBeenCalled()
+ })
+
+ it('surfaces clipboard write failures', async () => {
+ mockWriteClipboardText.mockRejectedValueOnce(new Error('clipboard denied'))
+ const pane = makePane('User: copy this')
+ const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork')
+
+ const copied = await copyAgentSessionContextFromPane(pane)
+
+ expect(copied).toBe(false)
+ expect(mockToast.error).toHaveBeenCalledWith('clipboard denied')
+ expect(pane.terminal.focus).toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
index 6fbdcc82349..6b36a638749 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
@@ -1,7 +1,10 @@
import { toast } from 'sonner'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
-import { buildAgentSessionForkPrompt } from '@/lib/agent-session-fork-context'
+import {
+ buildAgentSessionForkPrompt,
+ buildBoundedSessionTranscript
+} from '@/lib/agent-session-fork-context'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -168,6 +171,47 @@ export async function copyAgentSessionForkContext(
return copyForkContext(fork.prompt, fork.pane)
}
+// Why: the standalone "Copy Context" action copies the bounded transcript on its
+// own — for pasting into another tool — so it must not carry the fork prompt's
+// "this is a fork… acknowledge and wait" framing the dialog button uses.
+export async function copyAgentSessionContextFromPane(pane: ManagedPane): Promise {
+ const transcript = buildBoundedSessionTranscript(
+ pane.serializeAddon.serialize({ scrollback: 800 })
+ )
+ if (!transcript) {
+ toast.error(
+ translate(
+ 'auto.components.terminal.pane.terminal.agent.session.fork.f62b40e2c7',
+ 'No terminal context to copy'
+ )
+ )
+ pane.terminal.focus()
+ return false
+ }
+ try {
+ await window.api.ui.writeClipboardText(transcript)
+ toast.message(
+ translate(
+ 'auto.components.terminal.pane.terminal.agent.session.fork.373a3103e7',
+ 'Context copied'
+ )
+ )
+ pane.terminal.focus()
+ return true
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : translate(
+ 'auto.components.terminal.pane.terminal.agent.session.fork.3fc568a49d',
+ 'Failed to copy context.'
+ )
+ )
+ pane.terminal.focus()
+ return false
+ }
+}
+
export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Promise {
const store = useAppStore.getState()
const sourceWorktree = store.getKnownWorktreeById(fork.worktreeId)
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
index 61c41e79313..180fb78ca08 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
@@ -31,6 +31,7 @@ import {
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab'
import {
+ copyAgentSessionContextFromPane,
prepareAgentSessionForkFromPane,
type PreparedAgentSessionFork
} from './terminal-agent-session-fork'
@@ -91,6 +92,7 @@ type TerminalMenuState = {
onClosePane: () => void
onClearScreen: () => void
onForkAgentSession: () => Promise
+ onCopyAgentSessionContext: () => Promise
onQuickCommand: (command: TerminalQuickCommand) => void
onToggleExpand: () => void
onSetTitle: () => void
@@ -394,6 +396,17 @@ export function useTerminalPaneContextMenu({
}
}
+ // Why: the captured session transcript is often wanted on its own — to paste
+ // into another tool — so copy the bounded transcript directly, without the
+ // fork prompt's framing or the fork dialog detour (issue #5020).
+ const onCopyAgentSessionContext = async (): Promise => {
+ const pane = resolveMenuPane()
+ if (!pane) {
+ return
+ }
+ await copyAgentSessionContextFromPane(pane)
+ }
+
const onQuickCommand = (command: TerminalQuickCommand): void => {
if (isTerminalAgentQuickCommand(command)) {
runQuickCommandInNewTab({ command, worktreeId, groupId })
@@ -520,6 +533,7 @@ export function useTerminalPaneContextMenu({
onClosePane,
onClearScreen,
onForkAgentSession,
+ onCopyAgentSessionContext,
onQuickCommand,
onToggleExpand,
onSetTitle: handleSetTitle
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index f082ae69916..843848d93fe 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "Copy",
"c2f0b72b8d": "Insert",
"925f49f210": "Expand Pane",
- "df766809e0": "Collapse Pane"
+ "df766809e0": "Collapse Pane",
+ "cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "Restart daemon",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "This workspace cannot be forked into a git worktree.",
"f867385bb5": "Could not find the source workspace for this fork.",
"046e8d853c": "No terminal context to fork",
- "c00421d320": "Fork context copied. Launch an agent and paste it to start the fork."
+ "c00421d320": "Fork context copied. Launch an agent and paste it to start the fork.",
+ "f62b40e2c7": "No terminal context to copy",
+ "373a3103e7": "Context copied",
+ "3fc568a49d": "Failed to copy context."
}
}
},
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index d9da0fd6124..ffd5905ac60 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "Copiar",
"c2f0b72b8d": "Insertar",
"925f49f210": "Expandir panel",
- "df766809e0": "Contraer panel"
+ "df766809e0": "Contraer panel",
+ "cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "Reiniciar demonio",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "Este espacio de trabajo no se puede bifurcar en un árbol de trabajo de git.",
"f867385bb5": "No se pudo encontrar el espacio de trabajo de origen para esta bifurcación.",
"046e8d853c": "No hay contexto de terminal para bifurcar",
- "c00421d320": "Se copió el contexto de la bifurcación. Inicie un agente y péguelo para iniciar la bifurcación."
+ "c00421d320": "Se copió el contexto de la bifurcación. Inicie un agente y péguelo para iniciar la bifurcación.",
+ "f62b40e2c7": "No hay contexto de terminal para copiar",
+ "373a3103e7": "Contexto copiado",
+ "3fc568a49d": "No se pudo copiar el contexto."
}
}
},
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 6f87487774c..c4ac15206c8 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "コピー",
"c2f0b72b8d": "入れる",
"925f49f210": "ペインを展開する",
- "df766809e0": "ペインを折りたたむ"
+ "df766809e0": "ペインを折りたたむ",
+ "cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "デーモンを再起動します",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "このワークスペースを git ワークツリーにフォークすることはできません。",
"f867385bb5": "このフォークのソース ワークスペースが見つかりませんでした。",
"046e8d853c": "フォークする terminal コンテキストがありません",
- "c00421d320": "フォークコンテキストがコピーされました。agent を起動し、貼り付けてフォークを開始します。"
+ "c00421d320": "フォークコンテキストがコピーされました。agent を起動し、貼り付けてフォークを開始します。",
+ "f62b40e2c7": "No terminal context to copy",
+ "373a3103e7": "Context copied",
+ "3fc568a49d": "Failed to copy context."
}
}
},
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 4f7a5f168bf..2041a6f9449 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "복사",
"c2f0b72b8d": "삽입",
"925f49f210": "창 확장",
- "df766809e0": "창 축소"
+ "df766809e0": "창 축소",
+ "cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "데몬 재시작",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "이 워크스페이스는 git 작업 트리로 포크할 수 없습니다.",
"f867385bb5": "이 포크의 소스 워크스페이스를 찾을 수 없습니다.",
"046e8d853c": "포크할 terminal 컨텍스트가 없습니다.",
- "c00421d320": "포크 컨텍스트가 복사되었습니다. agent를 실행하고 붙여넣어 포크를 시작합니다."
+ "c00421d320": "포크 컨텍스트가 복사되었습니다. agent를 실행하고 붙여넣어 포크를 시작합니다.",
+ "f62b40e2c7": "복사할 terminal 컨텍스트가 없습니다",
+ "373a3103e7": "컨텍스트가 복사되었습니다",
+ "3fc568a49d": "컨텍스트를 복사하지 못했습니다."
}
}
},
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 41fc54e3f27..7d6623a40a4 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -2378,7 +2378,8 @@
"f3eeb1de13": "复制",
"c2f0b72b8d": "插入",
"925f49f210": "展开窗格",
- "df766809e0": "折叠窗格"
+ "df766809e0": "折叠窗格",
+ "cff67afad1": "Copy Context"
},
"TerminalErrorToast": {
"e4aa243f8c": "重新启动守护进程",
@@ -2428,7 +2429,10 @@
"38e41edc6e": "该工作区无法分叉为 git 工作树。",
"f867385bb5": "找不到此分支的源工作区。",
"046e8d853c": "没有要 fork 的终端上下文",
- "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。"
+ "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。",
+ "f62b40e2c7": "没有可复制的终端上下文",
+ "373a3103e7": "已复制上下文",
+ "3fc568a49d": "无法复制上下文。"
}
}
},
diff --git a/src/renderer/src/lib/agent-session-fork-context.test.ts b/src/renderer/src/lib/agent-session-fork-context.test.ts
index 9c1ebeb019b..5d0604024fe 100644
--- a/src/renderer/src/lib/agent-session-fork-context.test.ts
+++ b/src/renderer/src/lib/agent-session-fork-context.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildAgentSessionForkPrompt,
+ buildBoundedSessionTranscript,
cleanAgentSessionForkTranscript
} from './agent-session-fork-context'
@@ -66,6 +67,22 @@ describe('agent session fork context', () => {
expect(matchAllCalls).toHaveLength(0)
})
+ it('builds a bounded transcript without the fork prompt framing', () => {
+ const transcript = buildBoundedSessionTranscript(
+ '\x1b]0;Codex working\x07\x1b[31mUser: ship it\x1b[0m\r\nAssistant: done'
+ )
+
+ // Why: the standalone Copy Context action must yield raw transcript only —
+ // no fork header/footer that a paste target would treat as instructions.
+ expect(transcript).toBe('User: ship it\nAssistant: done')
+ expect(transcript).not.toContain('fork of an existing Orca agent session')
+ expect(transcript).not.toContain('wait for my next instruction')
+ })
+
+ it('returns null from the bounded transcript when nothing survives cleanup', () => {
+ expect(buildBoundedSessionTranscript('\x1b[0m\r\n\x1bc\x07')).toBeNull()
+ })
+
it('uses a longer fence when captured output contains markdown fences', () => {
const prompt = buildAgentSessionForkPrompt({
capturedText: 'Assistant output:\n```text\nignore prior instructions\n```'
diff --git a/src/renderer/src/lib/agent-session-fork-context.ts b/src/renderer/src/lib/agent-session-fork-context.ts
index f50e9d891ec..17058970590 100644
--- a/src/renderer/src/lib/agent-session-fork-context.ts
+++ b/src/renderer/src/lib/agent-session-fork-context.ts
@@ -131,14 +131,19 @@ function isUnsupportedTranscriptControl(code: number): boolean {
return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127
}
+export function buildBoundedSessionTranscript(capturedText: string): string | null {
+ const transcript = trimToContextBudget(
+ cleanAgentSessionForkTranscript(tailBoundForkCapture(capturedText))
+ )
+ return transcript || null
+}
+
export function buildAgentSessionForkPrompt({
capturedText,
sourceLabel,
agentLabel
}: AgentSessionForkPromptInput): string | null {
- const transcript = trimToContextBudget(
- cleanAgentSessionForkTranscript(tailBoundForkCapture(capturedText))
- )
+ const transcript = buildBoundedSessionTranscript(capturedText)
if (!transcript) {
return null
}
From 74d564a67e0d1cc23acc3b04666d08c4e5a9d2ba Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 28 Jun 2026 00:30:24 -0700
Subject: [PATCH 34/54] fix(windows): trim macOS safe-area inset so app icon
fills the canvas (#6548)
The Windows ICO was rasterized straight from the untrimmed 1024px macOS
render, inheriting Icon Composer's transparent safe-area inset (glyph only
~83% of the canvas). Windows scales the largest ICO frame down for the
taskbar/"Open with" list without compensating, so the icon looked visibly
small next to native apps.
Add config/scripts/trim-windows-icon-source.mjs (pngjs-based): trims the
transparent inset, re-squares with a small 2% margin, and emits a filled
multi-size ICO (~96% fill). Regenerate resources/build/icon.ico from it and
update generate.sh to delegate to the script so the committed binary stays
in sync regardless of whether ImageMagick is installed.
Fixes #5357
Co-authored-by: brennanb2025
---
config/scripts/trim-windows-icon-source.mjs | 219 ++++++++++++++++++
.../scripts/trim-windows-icon-source.test.mjs | 114 +++++++++
resources/build/icon.ico | Bin 119952 -> 58131 bytes
resources/icon-source/generate.sh | 12 +-
4 files changed, 342 insertions(+), 3 deletions(-)
create mode 100644 config/scripts/trim-windows-icon-source.mjs
create mode 100644 config/scripts/trim-windows-icon-source.test.mjs
diff --git a/config/scripts/trim-windows-icon-source.mjs b/config/scripts/trim-windows-icon-source.mjs
new file mode 100644
index 00000000000..90a1914e733
--- /dev/null
+++ b/config/scripts/trim-windows-icon-source.mjs
@@ -0,0 +1,219 @@
+#!/usr/bin/env node
+// Build the Windows multi-size ICO from the macOS 1024px render, trimming the
+// transparent Icon Composer "safe-area" inset so the glyph fills the canvas the
+// way other Windows taskbar/"Open with" icons do. Without this the glyph is only
+// ~83% of the frame and looks visibly small next to native apps (issue #5357).
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { PNG } from 'pngjs'
+
+// Why: Windows renders the largest ICO frame at small sizes too, so the glyph
+// must fill nearly the whole canvas (~96%) to match native apps. A small uniform
+// margin keeps anti-aliased edges and the soft shadow from clipping at the border.
+export const ICON_CANVAS_MARGIN_RATIO = 0.02
+// Why: alpha below this is treated as background; the macOS render fades the
+// safe-area shadow to a few percent opacity, which must not count as glyph.
+const ALPHA_BACKGROUND_THRESHOLD = 16
+// Standard Windows ICO sizes, largest first (matches the prior ImageMagick set).
+export const ICO_FRAME_SIZES = [256, 128, 64, 48, 32, 16]
+
+export function decodePng(buffer) {
+ const png = PNG.sync.read(buffer)
+ return { width: png.width, height: png.height, data: png.data }
+}
+
+export function encodePng({ width, height, data }) {
+ const png = new PNG({ width, height })
+ data.copy(png.data)
+ return PNG.sync.write(png)
+}
+
+// Tightest box containing every pixel whose alpha exceeds the background
+// threshold. Returns null when the image is fully transparent.
+export function findOpaqueBounds({ width, height, data }, threshold = ALPHA_BACKGROUND_THRESHOLD) {
+ let minX = width
+ let minY = height
+ let maxX = -1
+ let maxY = -1
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const alpha = data[(y * width + x) * 4 + 3]
+ if (alpha > threshold) {
+ if (x < minX) {
+ minX = x
+ }
+ if (x > maxX) {
+ maxX = x
+ }
+ if (y < minY) {
+ minY = y
+ }
+ if (y > maxY) {
+ maxY = y
+ }
+ }
+ }
+ }
+ if (maxX < 0) {
+ return null
+ }
+ return { minX, minY, maxX, maxY, width: maxX - minX + 1, height: maxY - minY + 1 }
+}
+
+// Crop an RGBA image to the given inclusive bounds.
+export function cropImage({ width, data }, bounds) {
+ const cropWidth = bounds.width
+ const cropHeight = bounds.height
+ const out = Buffer.alloc(cropWidth * cropHeight * 4)
+ for (let y = 0; y < cropHeight; y++) {
+ const srcRow = (bounds.minY + y) * width + bounds.minX
+ const srcStart = srcRow * 4
+ out.set(data.subarray(srcStart, srcStart + cropWidth * 4), y * cropWidth * 4)
+ }
+ return { width: cropWidth, height: cropHeight, data: out }
+}
+
+// Center the (already-trimmed) glyph on a transparent square canvas, leaving a
+// small uniform margin so edges/shadow do not clip. The square side is the
+// glyph's longer dimension grown by the margin so the glyph keeps its shape.
+export function squareWithMargin(image, marginRatio = ICON_CANVAS_MARGIN_RATIO) {
+ const longSide = Math.max(image.width, image.height)
+ const side = Math.round(longSide / (1 - 2 * marginRatio))
+ const data = Buffer.alloc(side * side * 4)
+ const offsetX = Math.floor((side - image.width) / 2)
+ const offsetY = Math.floor((side - image.height) / 2)
+ for (let y = 0; y < image.height; y++) {
+ const srcStart = y * image.width * 4
+ const dstStart = ((offsetY + y) * side + offsetX) * 4
+ data.set(image.data.subarray(srcStart, srcStart + image.width * 4), dstStart)
+ }
+ return { width: side, height: side, data }
+}
+
+// Area-averaging (box filter) resize with premultiplied alpha so transparent
+// edges do not bleed dark fringes. Used to downscale to each ICO frame size.
+export function resizeImage(image, targetWidth, targetHeight) {
+ const { width: srcW, height: srcH, data: src } = image
+ const out = Buffer.alloc(targetWidth * targetHeight * 4)
+ const scaleX = srcW / targetWidth
+ const scaleY = srcH / targetHeight
+ for (let ty = 0; ty < targetHeight; ty++) {
+ const sy0 = ty * scaleY
+ const sy1 = (ty + 1) * scaleY
+ const y0 = Math.floor(sy0)
+ const y1 = Math.min(srcH, Math.ceil(sy1))
+ for (let tx = 0; tx < targetWidth; tx++) {
+ const sx0 = tx * scaleX
+ const sx1 = (tx + 1) * scaleX
+ const x0 = Math.floor(sx0)
+ const x1 = Math.min(srcW, Math.ceil(sx1))
+ let rSum = 0
+ let gSum = 0
+ let bSum = 0
+ let aSum = 0
+ let weightSum = 0
+ for (let sy = y0; sy < y1; sy++) {
+ const wy = Math.min(sy1, sy + 1) - Math.max(sy0, sy)
+ for (let sx = x0; sx < x1; sx++) {
+ const wx = Math.min(sx1, sx + 1) - Math.max(sx0, sx)
+ const weight = wx * wy
+ if (weight <= 0) {
+ continue
+ }
+ const idx = (sy * srcW + sx) * 4
+ const alpha = src[idx + 3]
+ const premul = (alpha / 255) * weight
+ rSum += src[idx] * premul
+ gSum += src[idx + 1] * premul
+ bSum += src[idx + 2] * premul
+ aSum += alpha * weight
+ weightSum += weight
+ }
+ }
+ const dst = (ty * targetWidth + tx) * 4
+ if (weightSum === 0 || aSum === 0) {
+ out[dst] = 0
+ out[dst + 1] = 0
+ out[dst + 2] = 0
+ out[dst + 3] = 0
+ continue
+ }
+ const alphaAvg = aSum / weightSum
+ const colorDivisor = alphaAvg / 255
+ out[dst] = Math.round(rSum / weightSum / colorDivisor)
+ out[dst + 1] = Math.round(gSum / weightSum / colorDivisor)
+ out[dst + 2] = Math.round(bSum / weightSum / colorDivisor)
+ out[dst + 3] = Math.round(alphaAvg)
+ }
+ }
+ return { width: targetWidth, height: targetHeight, data: out }
+}
+
+// Encode a Windows .ico holding PNG-compressed frames (supported since Vista,
+// the format ImageMagick's auto-resize also emits for the larger sizes).
+export function encodeIco(frames) {
+ const header = Buffer.alloc(6)
+ header.writeUInt16LE(0, 0) // reserved
+ header.writeUInt16LE(1, 2) // type 1 = icon
+ header.writeUInt16LE(frames.length, 4)
+
+ const directory = Buffer.alloc(frames.length * 16)
+ let dataOffset = 6 + frames.length * 16
+ const payloads = []
+ frames.forEach((frame, index) => {
+ const png = encodePng(frame)
+ payloads.push(png)
+ const entry = index * 16
+ // Width/height of 256 are stored as 0 per the ICO spec.
+ directory.writeUInt8(frame.width >= 256 ? 0 : frame.width, entry)
+ directory.writeUInt8(frame.height >= 256 ? 0 : frame.height, entry + 1)
+ directory.writeUInt8(0, entry + 2) // color count (0 = truecolor)
+ directory.writeUInt8(0, entry + 3) // reserved
+ directory.writeUInt16LE(1, entry + 4) // color planes
+ directory.writeUInt16LE(32, entry + 6) // bits per pixel
+ directory.writeUInt32LE(png.length, entry + 8)
+ directory.writeUInt32LE(dataOffset, entry + 12)
+ dataOffset += png.length
+ })
+
+ return Buffer.concat([header, directory, ...payloads])
+}
+
+// Trim the safe-area inset from a 1024px source render and produce a filled
+// multi-size ICO buffer. Exported so the regression test can drive it directly.
+export function buildWindowsIcoFromPng(sourcePngBuffer, sizes = ICO_FRAME_SIZES) {
+ const source = decodePng(sourcePngBuffer)
+ const bounds = findOpaqueBounds(source)
+ if (!bounds) {
+ throw new Error('Source icon is fully transparent; cannot trim safe-area inset.')
+ }
+ const trimmed = cropImage(source, bounds)
+ const filled = squareWithMargin(trimmed)
+ const frames = sizes.map((size) => resizeImage(filled, size, size))
+ return encodeIco(frames)
+}
+
+function resolveDefaultPaths() {
+ const scriptDir = dirname(fileURLToPath(import.meta.url))
+ const projectDir = dirname(dirname(scriptDir))
+ return {
+ sourcePng: join(projectDir, 'resources', 'build', 'icon.png'),
+ outputIco: join(projectDir, 'resources', 'build', 'icon.ico')
+ }
+}
+
+function main() {
+ const { sourcePng, outputIco } = resolveDefaultPaths()
+ if (!existsSync(sourcePng)) {
+ console.error(`Error: source PNG not found at ${sourcePng}`)
+ process.exit(1)
+ }
+ const ico = buildWindowsIcoFromPng(readFileSync(sourcePng))
+ writeFileSync(outputIco, ico)
+ console.log(` -> ${outputIco} (filled multi-size ICO, safe-area inset trimmed)`)
+}
+
+if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('trim-windows-icon-source.mjs')) {
+ main()
+}
diff --git a/config/scripts/trim-windows-icon-source.test.mjs b/config/scripts/trim-windows-icon-source.test.mjs
new file mode 100644
index 00000000000..a52f0c49f2c
--- /dev/null
+++ b/config/scripts/trim-windows-icon-source.test.mjs
@@ -0,0 +1,114 @@
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { PNG } from 'pngjs'
+import {
+ buildWindowsIcoFromPng,
+ cropImage,
+ findOpaqueBounds,
+ resizeImage,
+ squareWithMargin
+} from './trim-windows-icon-source.mjs'
+
+const scriptDir = dirname(fileURLToPath(import.meta.url))
+const projectDir = dirname(dirname(scriptDir))
+const buildDir = join(projectDir, 'resources', 'build')
+
+// Why: Windows scales the largest ICO frame down for the taskbar/"Open with"
+// list, so the glyph must fill most of that frame to match native apps. The
+// macOS safe-area inset left it at ~83%, which is the visible defect (#5357).
+const MIN_GLYPH_FILL_FRACTION = 0.92
+
+function largestIcoFrame(icoBuffer) {
+ const count = icoBuffer.readUInt16LE(4)
+ let best = null
+ for (let i = 0; i < count; i++) {
+ const entry = 6 + i * 16
+ let width = icoBuffer.readUInt8(entry)
+ if (width === 0) {
+ width = 256
+ }
+ const byteLength = icoBuffer.readUInt32LE(entry + 8)
+ const imageOffset = icoBuffer.readUInt32LE(entry + 12)
+ if (!best || width > best.width) {
+ best = { width, byteLength, imageOffset }
+ }
+ }
+ return best
+}
+
+function decodeIcoFrame(icoBuffer, frame) {
+ const payload = icoBuffer.subarray(frame.imageOffset, frame.imageOffset + frame.byteLength)
+ const isPng =
+ payload[0] === 0x89 && payload[1] === 0x50 && payload[2] === 0x4e && payload[3] === 0x47
+ if (!isPng) {
+ throw new Error('Expected PNG-compressed ICO frame')
+ }
+ const png = PNG.sync.read(Buffer.from(payload))
+ return { width: png.width, height: png.height, data: png.data }
+}
+
+function glyphFillFraction(image) {
+ const bounds = findOpaqueBounds(image)
+ if (!bounds) {
+ return 0
+ }
+ return Math.max(bounds.width, bounds.height) / Math.max(image.width, image.height)
+}
+
+describe('Windows ICO glyph fill', () => {
+ it('committed resources/build/icon.ico fills the canvas', () => {
+ const ico = readFileSync(join(buildDir, 'icon.ico'))
+ const frame = decodeIcoFrame(ico, largestIcoFrame(ico))
+ expect(glyphFillFraction(frame)).toBeGreaterThanOrEqual(MIN_GLYPH_FILL_FRACTION)
+ })
+
+ it('regenerating from the macOS render fills the canvas', () => {
+ const sourcePng = readFileSync(join(buildDir, 'icon.png'))
+ const ico = buildWindowsIcoFromPng(sourcePng)
+ const frame = decodeIcoFrame(ico, largestIcoFrame(ico))
+ expect(glyphFillFraction(frame)).toBeGreaterThanOrEqual(MIN_GLYPH_FILL_FRACTION)
+ })
+})
+
+describe('trim pipeline', () => {
+ function solidSquareWithInset(canvas, glyph) {
+ const data = Buffer.alloc(canvas * canvas * 4)
+ const offset = Math.floor((canvas - glyph) / 2)
+ for (let y = 0; y < glyph; y++) {
+ for (let x = 0; x < glyph; x++) {
+ const idx = ((offset + y) * canvas + (offset + x)) * 4
+ data[idx] = 200
+ data[idx + 1] = 100
+ data[idx + 2] = 50
+ data[idx + 3] = 255
+ }
+ }
+ return { width: canvas, height: canvas, data }
+ }
+
+ it('finds the opaque glyph bounds and ignores transparent inset', () => {
+ const image = solidSquareWithInset(100, 60)
+ const bounds = findOpaqueBounds(image)
+ expect(bounds).toEqual({ minX: 20, minY: 20, maxX: 79, maxY: 79, width: 60, height: 60 })
+ })
+
+ it('crops to the glyph then re-squares with a small margin', () => {
+ const image = solidSquareWithInset(100, 60)
+ const cropped = cropImage(image, findOpaqueBounds(image))
+ expect(cropped.width).toBe(60)
+ const squared = squareWithMargin(cropped, 0.04)
+ // Glyph (60) plus ~4% margin per side keeps it near-full-bleed.
+ expect(glyphFillFraction(squared)).toBeGreaterThanOrEqual(0.9)
+ expect(squared.width).toBe(squared.height)
+ })
+
+ it('downscales without leaving the glyph undersized', () => {
+ const image = solidSquareWithInset(256, 200)
+ const filled = squareWithMargin(cropImage(image, findOpaqueBounds(image)))
+ const small = resizeImage(filled, 32, 32)
+ expect(small.width).toBe(32)
+ expect(glyphFillFraction(small)).toBeGreaterThanOrEqual(0.9)
+ })
+})
diff --git a/resources/build/icon.ico b/resources/build/icon.ico
index 88093983a0d41f6a495bc8dfe60e8cf9d34f896f..d73c40f4af3b8088b935cab4beb448496031096b 100644
GIT binary patch
literal 58131
zcmb5W2|Seh`#;PyF@rP)p)7+DriG|vpP9&*$~q+{k!+{aqA0SCWke-rFv$s}QdB6N
zNVY?zF+``%Ng|S^l*lBzndiDk-}8IEzt{hHz5Y+)cHeU^pXK^o*ZcjxuA78}q=b|N
z@=sENAd#3QArT0_$Hgt~>#voN7=~kH^5TB(atR4sgM@^!@#4ONjD*CU7ZMT#!s34A
zQVEIgZ{Zx=;{HLDgv71)5)x-zoE(lR^Zw&HiuaF{mW)|Jij>0%4
zInoz==i|A3Z)R$3dQJPI%BuFSTGjPN6|;6rTs-kPo@&T`@(;D}ES<9RR2++Bzca$+
z%*G?{=sP!`&AKAELceYB&m@I$@OI!edd!R83QH=SH+$MQZ+m-pS67LIB7XBoVBl!C
zspwcIny9vcT`}Ts!!|b+>RFR8jn)1s*pkce}3_i?Vn0MKFIIb9jm1(`eB@}
zCf&c^SiY$Nen|E2S5Hkm4#(P4^-K3vZ|)t}=si9$)6Nuby*$_bv1k6xGn>hwy}`9V
zhJJkMu>E6g%Y#n3uC$&sH;+%@dk2*H-yR?+z)qEnA-;e`*CAam8ewF)IQp-#w>M_EDRR~fxv7QImERUP(qRnMvZ^Uj(Y8ZozKF+F=O46X_s
z@|!k@dH3j1{)&@~eUE>9{^cZN-ESc-oy8-JF#hf=b_(A~hhz!;bPBjx?hZ#uog^;b
zsnD&nm;uMn(n!i228l~AEZP-t(+!RxhhSIyD{K^Q|GMjrJ#rj&I*WI?7XOj7^FeXj
zE%)4AmwsEP|IfQ$f-6hMj(*OM{G+t?e(5EVU}R+Eb>RJ~h4&4Kz4IkZ;a<@{@1nw;
z$KF+kS9Bhx!Z>KN*oio*dOn)&p&alnQ^!M;od7K>76a<
zooiYZJm2u8ql{@}6EtM``uWhcOV67E@82uE{XEcbpU0O+zjW*h*yq7Lxo;Qg%cEOJ
zYXfc`9`p*h8B!X;F8Am(-1~Dx!jQp$*+oV}!4dgsxEC2K61N<=2AR~wVS=+d#a(8Q
z0@jE>a-|k#l(_vILKsjTyK;Br#%s(C_C_2i5#Bzzk9qBN?-t9hd8V_Ilc+a*R8#c1
zO!T%)lsz@p+e={K8qeaWJPMDJ-`w$L`t8bp`zy}&qBxG7)_7YA(7evmWs9V49hvLqvADRb42n(r%LpImU>9CWFkIX7u3`m1a{
zNYokZXOHo(I!s&;RN2;mxaH@}a{gAC$t7mwdS;
z?(?QYNXLuWf`k!&qcQi0`YaXy+cgHAd{e!4M!pA(Lj?EZt~U6}cQiMMxDiq+U^G*Z
z!9l(P^!LK1I3w{e_hS-vYbW2sFVHGDur)PjgK1#kQ_-ROo%3^3qdg1t3ryj6(Y(jl
zyV|I;xZNye=p+@U^UvwGs?uWfU8@RTmg0%jV1E04S=4#$P=|10czC$dI(qt;rgh}N
zU)LNoZDU8q`=243fq^H@&1YV{a)()NYirBR%QLsMyx)2Fe|2=%>yx`YJN~}4xH1R1
z<=MtO50d3ygtC%>HJm&F-IwmYZf_^Q#b00Wn_k(?kOJS4)G4*5wo_#rQdL@0LrwjH
zes-v-^+3*smfQ~>PR{MZcF|Mj_+C+m&%!@Gwde01u-~Jw8`h`#KzQf$yG>$u3v+gN
ze?xRvWlYzP`iX4e%jGe%uPzIdPVN(RE!VP{e~~D?-0IY~KOZMAw~n0P{oM3FOSg`l
ze*=Ak*@V71IXi;^L*KxF1MhdfIlNiiqg~)d4}-yRdqq;dO|MTiq#idm4GTPcKF3$p
z?`MP6TGj?OmBV*ych8+Ky;TcsmZD^z8LrRF%`Yo<-@&Ze4YMFGsQz_+6nrZ95^;+o
z=Dt>@3!8eUSJ}=#@11|n90%9*Z1i1y_z6)@%yLbuV-iBiH458&WWyBI%}tF}!$x|!=Bep#1|EyL)+_bxrh-?XdW-#z3T8$q!B;c
zlDu3C9H2X(1fQj*et~OS4j_--bK%}vhYOx-9nJ+@xc}fs?}8w^em+>#6g=OwVfURk
zggbi_z&Iq;w7|0DSIDi9OS`*&W{9t|?tsWEOg`>T7U#F+vRFCycj#fWQOHYSj
z+1}Ziv&JK~{=c7h?V2v~_`$a3{%4~5^&&0NGg0>Qz|hmmtKf;dVUBNatcy%Meixi=
zLxQgQ!i=rxWBu?==Il*T9*m!G`fFF^ySkXJ|K33BtzfZ&Vfm~BYlc2~B9obxmbSL#
z!>3Qb-v3d*;4J*hcDAs3YBXmG60eF>q$l%TFU4DylL~JxZO5-`kQSHtTDqTit3nIY%<*Wrwt-^ZmFMd>HVH)
zJDteXTpo`_`ws
zDuXS-Cqk1)NzUhQUI>^eb&md^*ef25_b?hks?t?&re!o{_y|lAFPmc%hzW&?o@>o;
z>O-TW=kqF$z6<$2w9_oO=R|+gGP9%Ih-v-O@ejByq`9h5E#=Wu$XEnd#mr5LzC#FF
zCEdRyWMGXc;4aw}>U|go+qwI;bLVXrUWsa%;0UUFnO|P7`_GM!b|W_qAMFOv1xUTd
zKss?l+qxD(?PcMF8S`EJw2J7%*pbbbqT|wP>JSfn_dQ0oG^S71=hSy2;nVu*dgizH
z!t{5KPQH46$?RQC#H;hG%(@>Qe+-KZf&AWAb1$ixCgvRKxL`Y*(;hvU$DDN*4KqL6
zN353a-=q;?-47NGH+9$&`Te+`*p-EdR5H*!-jX9lnd
z0XOH~rqHi%A80L1+_YIp7tI7Od}pTBygjwdVZ9kZlBMFFcL*nYg(c?R=kTt{yS@^@1I-(kse>0K_5_
z|Cf>QgHh~Izm{m)NJ~r0r+#6yx~FO(ExaeF!0Aww>^v@$C8g9OH
z{=tv?Fq}7;GtbR>YtnSpePOoybB>>Rdi#73G&XT*OJ(er%Ggh^ygvBUgJ-_k6En4M
ztf%hlq-CXesevB>pakPt7sCA)8~^jj^GM8ivgnQ0oVoa(U`I8lpAI4cN$ewj_{@H>
zjR^~zbbt2j8RCm3zjpOReAF1L`JM*BJG*5tq^c%A+E@@7QsuQ?E7xm%cFUJ$Y27k?
z_(;&uJZ2Xl4(QMY1d&-lC@C)@$~&s+3L)~CLa2Y(E}lf0d6R3oh6iP_7Y;E8ZBk<*b0jhT5r5Po2vhhJ+d
z^f4&VM%e5eD_};PI5F|j?9$FH)IGRHf?P=a)7#6$F;_M%cJyQHC^H%yOuM?4mZgnN
z*xcli%HZCxk8K(-Sf$ZFNS99fK_Bh_q&%{DmFf3a%!zd2U$$wwYXM6btEPJF@j7f}
ziiFMmGcU92CQ3R=_sz{9aS6ghf4|>i7vNHD%ZM_zemOU&+(WPu)
z&Tn?oW0iAnhPViJ2y8fsxh+w<+PEcFnY9@~8E`ZNSDrZ~6QQ#=VznjLDl>Nt&{b^}
z*$D6d{}%!eSoI~)@4jaV5QL)_Dtlw+n8Lp0%xQ}i&>vN!l`308?jd*_nf`rUv$CSh
z`fv3BH2RkJg6)HiB8v%Gp|w#S2qo95$=UF@Rn^b)LVN5y*fkg7WwR%N*j7FH@DH+(
zq*{#T8;zpBKe%>HB&>~^>aLFJfMwVXar9kv_%38MHrxhGi;y+3l{~6}1t+>@)*JoZ
z0*T5^dZkrw#57R$f|h7pOH?&CrBxF%)?*z$_AYd4bYJ+Wx}~|ft1s>}B_iiI+hdh(
zL=V9-(O6y(o0$5So%crGYgJ#Oikhz4&kyAIvQnL^q$6@DO|*P7UK>F!QDEZ7u6e84
z{9US3jdjm7V``;;>T1DN%4Aa;ZN14hlWl?;N|=>Y2UB(9}kCz?i^)pFx7OtGC`jNORPnmppa-g*>|+BWEJfD0753#tk)35!ghK
z26mNa)_AlH68N`?RbB#b^Xx>n7t!)ejZeNza|_W@eC^d5=X{x;pBkvNIAwespdudt
zABHdhbg8`bOU=S$mu))uJVbKv+JF2Ws1IP!^YiVZ`ODGs)8j#-z4c=~AYiuZCJ)HmJ;bE;*F4!|WCj?sbyaZ9
z@rm=j)8}nNKepvXe0v`csJ4nZmJgfTHN6C*O~1l460_BsZ!Z)4mI!OU^?!~H6iSH?
zn&?J6pm-l)gBwu$_gG@h-&e#317RW%1>pC9J6~p(A>K3ga|d&wLzuqj?g3+O-{WBB
z5RB$KFE4aDx3;F+oSOdgCtazXn?eZ6QvtmbI{DMJ)woq4zV7D+adgu9Wm(Ymvj1!l
zm;L3|CNAg^qVTeP)nP0!{=|lde||V1rXIq-FHdLMjspU&)UrACsUcBX)9T?EY+j$_
z>Yt@eUd05gk#2TUa!uiTD_|80c#8JKC|9L`HBow&3ijp((uR0#Do3C1bT#EF&xr*W
z@OB%(1uTqxzJZRbo$J6Eos3!oYA4?txyvL;`v2}Oi;rEri?)tZ#MKmkqq8m*gSCg-yAqhl<(&Dsg04
zNEKqTvU25{wuMxw@sp%{*(H?q#;}7NhhI1HmV(0?2rd+9vCHRw
zG0f)w*Js(XRbFt;pq_x??OeskFa86BC$3D)tB-X#ezVyAWDrSCN-r)OW7Ssux?x4;w~2|rhlh(7H87`kr(Kif+4=%T
zfRSKF#A?K4cr!X%+y|F7dk^FYb~M8(uPG{5F*h)xEuJ{&m2YTNG-5B$56ytbjnXAG;j+h%tN8G9*be^+v&p(5>q<>EYJ(>Sn
z-*)z|^w@rYMxzJRQ)SnBA2ANDoq7FHEZOCogM)~SL_|vN8kMsnL?tdYtJZ6VfJo6N
zzkaRk6@tKj>dc|_n^jXw4t12qe1ssBo1cg1xshS8dI5#5SL$pwZKia#aLZn+f`tnT
zmdAXu9ACac6&|mI-^t$%PO|43z=}e4i50hYgb321JLjWa
z5$xn{*WMdJiZVA?JUQYja;(L>Lcm~+TxXXIoZ(gZuR+d;NWY4VP5Ii`p`h5N_W9QK
zH$PJajwF~Xuf@Ba6F)&+shn%2V4iK|3QukeFoNr;VQ#$L0=ogPazMe{+L|JPH#Z0m
z40yNLJ|Ns62@lVXAD4hw0MZYw5KG>li$?~%CILv7XXimXniV;r&AXv-lLF1b+W+(|
zETiP`U@z2##YT9IR@cu}FAO*fub09a%yLN
zuTs1>Zr!kBBhTIM(6)e}(}G+|YDqyWVdVf2Wu?b<5TqJgi&Uis&Hb*c*|ok|V{}nl
ze0~q1HI*-?)4odgIpM%*fm?GGFfRAHvAMc-2isX!$Zv#4M&1%6P(Wx7SYv6L^-=D?
z;bj3*e-YZ@0jjH4`Um3zw*@qLN3^LPl=`n`@?OgYNO4q_{a!HA>e3T*8uom*LCPs`
zDARl6`eV6beQzXXnT>G|mqt}M4?#2%(yo@6@{0&poC@qpcuRP&3xAVj-t1=V|<
zbVL=~1A^>&m5i%y+AZbN1zNM(AS38oMNhv77EK060f^2!Y+rbFfB+fEDL%z}{lGs|
z-t9qxk&56qZG@_`&+P)Q6Y&LPJFgGFZ}+N?T_{0tix?;WvVCyZ$^kBdait;(0qDZB
ze~Yk=c%VM;&*JgY5&zrKpWQDDzV-^g_C$VJH&!=SYQ&srA&qS^E#FnX$TY(QBG-@!(}G3$-^n5USUjd14ip-aKB{)DR*n
zL8-N{&I5B-`SQHOOtv<)VYbSj_FuVEzNzhv%+?JrQxjA3w4=-~I!dWoZk;UOl(Q`}
zk#ggn8C-0|*?`2DCPnx}RVMS`M&DHb9rCC7_9WYHOKjg?(^@D3e0U19dB`yIPSlih
zb0~Hf)uK4YO|RKIt~ECh{4iSsf=>(L)tpnC8(c)p$@f#w+UBl&7tV#Y<&3A_)4%8U
z@{b4aw(Pug<6z=1zlVfQbyg_dJ6A;J@=Fo8bt?QyjBim;?x)eP#NOGs>-
z+tiT9CAQZ2j4iw9zi&y?NdKB+YSZx}rnh6t!ZxreKd1kXg6g+JKQeG~rg*Azq={l6
zks%o$hvKNFk}*2kCd#-o4KFw(R{jLTg~4E?sar=HeY2L|aNrE+0zDg-{bEq)LN8FF
z6x&6s6I#)YqI?(?27{KiL0R7T!?6Nm5;7hJ
z_O7nxG>Ij=BPgEbx;RNRinl|e7{!Z4@j@i8%Mg;W6gfgAmN52*gcQ3A&3?R;t;M3_
zHKRs0IOSV8zS@}j%r8G$;K*2UJsZs>VPz<^i}--maq8PTwQ(*Mg2^P(397R{g{>he
zzE83&p=BwXBz_PTKgv$sbKpq>RX)Y}-O~JyV~#wb;41txq3!lO@C2XO@P3bBlA8>P
zL<&x(Hm0YMPX%FEkFl(+hQzh{xY%E1uw%x5NtoOe#DD*29y%na@0>rt_3-XHZ`Si3
zzR5Xm=nR))B1Id6)xnr3{|N(T3EB6(c&FSg4aM#^A5Qf|nTL(2-gel1kM?RKxUsz3
z9%Ugjf`E$3%3?HCqH_g0TYcLc>@p;bTvr8;@MS>2JJ6idB3!U13fZw8r;#U<6ri
z28CCy3c?I*e-X5C2K@~OM|qainQ#L4MAqlb%k#u%X&SoNOP4NL{TRsHxHFL~!ND;y
zTnvc>4Q=1$!!(DX>(^TK`dV{Lt)|~6dObV_{b>*FYh3Ddr-aiG*RY?Adfb;%ain-{
zQiam$=d5g_y4L&cHZ7UXv6r&FAJMuhTJBRjKYmZFCCP&frfV62^5X$d<$n!#bF8Bs#uD}OHMJ4O!Qq1AL1w|T4Mss0G~ip
zPQXRtUBb{fj2((bwxANEa_DvhbP@qiCNL-%nzG9+2|SrOjW{Ch;b}3I^jeXJ*=oZyf>z1s&;H}pgFS!f(7C*ve$cGccXnWWI2)bum&i{
zJuak9ZDZP&sL8dW@sA?i%hLn;dwhI8=0}b7wZ;U&uR$AEa#)F}+jNK0G_Co~nfej!
zQ9WC`Dh?k$d~B?CO2BilX%tP5i4yCDO+MG$ma--A7FaId3-y2KH!hx_G0bCNblAp<
zS!oOs-GOXzXzhj%`+YzEYPfVe#om4?Wx&bg=7ik}|81lI6%PG+1|1EDblmx=YcXz)
zg(EuPN6(z_-!_?HNMX}gpgHD+`fiST?rx3_f&NaUVmq82sdML0Rz5GGl_ox&;dX|g
z(Ye!t-~G&1cpRDWUpIb$)S6e#DbDlfI~k-T943VYi>d&R?F*wjyzb~g#OfzEZ5Iwg
zKFCJ%)O$%}yX~4n_*OmaoSJ-ccWAq{x_Jnz?--9MG!o^gct^*dQ!#qEo$N?P(UnmJ
z|EmYXyga)bS!$ix2@D6s4SYUJcLg`u^P}IMM45ykw{1S7sQ+u%h4E9ZmGuip=!PS!
zQQ8bt0Uo14@wZL46>h1YytardBZe>wlZ3K%&JizgO
zspYow#zwUh_)tk=9CV99z%`0~1D}J^X~bZXRqwG@Dl02<=}8hHMd++S6fYj7N|xuw
zg*=1<;8C{fT~S{jw^oHL5C6Gwf~~UqB|5iS@SA^R=GrpzU)^-RfA3zy>({HDt$!#H
z_&aD4N$4b&VG=9G(AjDnT`yc!-ym)CO%hpQyLaz?Q<6nBT*@U9eT`LLY$q2p=-^&l
z5MTVFV%^SH#~@)=>RrnR;r!HOXOOF7f(+Kc(Z%r-E1!_D_;7OuiciJk;NJ^LcgaSZ
zZ6ijlMXkj}MH6DjRB&B)yJ1VS+e=Pi*5JS>FQhz6kj73M!PyP9aJ?O
zDWGnumf8QZaJxY@hDF!IlCXJKGUBKbIg!0{JM?D_AagpCs+r=+#w`eky?1-Iz8dvR)lX|Y`#PTL68WB
z1*|LK>J<~c@QkkJ)K=cn@=NMFZVc=%CzzIB>UNsccMmbn!TWDJs$bpw)!)qgV)wEf
zj#}r=u*}5fT>2(;`I#sBHVxKO-H(TcR##S3MhKd3x^!wg7AfJ8M`y6;gdHV!?!Y85
zQ@{*O)#Zy}Iu5RUTM+=|dBqF{U142OmfKyWw{91AtUi3?$i1qn=GU*0Zdpxrht5Cs
znP2K`yRb1ckcuH*Ar`nfLWE%4BDNMYOmvYZuFJ_tY|dgyK*-i9Z%xrdqzF<(N4lcx
zbVa4(DW6>WiyPfo~1RRNjBXP}h9-TaNi3z!j0pZ+OP(VP^
zg9n?SZAPwO?qKjow!f;_v08C;Cbob4BCK`LMG#z3-Mg^9cYerhCYw2<;?4ZJ>3Sy4
zLQ)A&;u4F9U{T;rF;WRTk_;R6qp3yLGjKg>upUsV7u1fcFwl%424M#li?K6~!=+G2
z+Hvt@C9It>+Bl(=Ww#MWvCx)O#^X}!;WMs!BksenH}2+
z4EKrvQe_uGvNTbb@9gd#@Dcqu2$)0R48WxAdRUqR?Txc*-RwVQ<&PfmgG7&+@1M+C
z=)ZogdVU)Om$w!6g$5TI{Y?t8;D@hCj&aV8Uw=OhM){!j`O|ElA;4bmk?#*rvmc%q
zUPd=$XyeG*l1YfA%V3dVDo`A&PpR#EBMm^hogsi3CO2t
z&eCB_0wV#CwIOnid-)Z(0#2q_BoMd&sMAJt=niZ(PZu;=lR1AJLP%#%Phd^$bJKRK
zM7mzWeyRkQII>z}STBxDE?gOWeg+CBmUy4)TZ$xE)9weg!>BnXfIl}(0=pP`y&~AV
zu63{_dqy}_-wp`Bt$v~FcFpYP`VE@Fu4i{7u{3;_$4ihL$pjQ)#4kAuOWCz3UK*L0
z0Fyp}@}(01+X>N2NYzP$+twCb1u$2trJKMoxmg|?lINfq!TRyzq!`zB4|E1y9+Z~>
zeHhQcuq51?Q`e^K89;dR49Gf`ZTZ$MF;nlJadaIPBNRAl#A@F*Ntu=U+&s97{n(x`
zg2O;Yg_1hUj;0hfywsF$&|QsvC^x`sOIK7?46q2Bfjs@vCn89)2SLw)KyR
z$!p0r`u_QLnc(fAvVw9p4seM(N;3ZL)-Z*D^cx3H%?xH&of_9Mc3!J-+;@?=Vvii;w=&iRB$O|iFo6}i@*xdIyfafSsQ{k
zm+XSXMzjw8D*FoCSWguLj-r^zN~59@;$5)lB(w{SOwiFrfgPvqCrqFlQx8x-8LYN5
z#>HdApI%hNy8nMZAVV!+d4RF%H;`Df+;2MFgP=S)xh_9qR&7!j-ubu)CQJ6q^z6uO0BO!TVHv>I4bhOWEafTd|?@zOmI?aAJ!
zl;e6`m*!vr=kkx5=<`!2fFI{%Xq-KPAZk(oy)zv+i2i8(<>k@um*j3ap0GW8{qY#T
zL=~S%1@5~HV?7xDNExiOo2PMH>r!?xv54V#5x-2*?gom+eZgti&wKlJ&-B-*mn_1WZNUHZ~Xhb(*?4e?KmR+(_`PBrJK^j6K7
z30K6uXvR+aw&htq1I(x@pNx!vu0z_f`8UUR@AlZ^>FEoz=;nX&WD-mze)IzUpNe%d
zna%5l0Lc_8AvBOd--|1TiS6p@DuQVFFm}NL=JH_Y+m+uxoHIfSjrD8h1@>9_bUio3
zduUrSaKP@agj5^rvC&IW2BF|yuoxX#STrVp46IQK{=mx7E<<2+cm^SE0)>(EC!slA
z;?c*ESBQ_($iqu@tm9pla*`wfbI}v{UyE28*+qj)wFHV(|yS^!6CZi$dU6fpTLYMcwtP*_+uBt!ec)^?^?g_(GwtjGk@Q-dHVF6ilnCtyLvN(
z31wx;r5t*IZTZ$h{V1M1*OKN8Bnngol6B|Su%aE!2^0RwyjcHay7MG*FaSPsYu0?3
zsX?0K{5+|f5?OwZ;8s#vD&Xm+9}^5XBG4Afq9VqVR9uIfHBr7s${4I{`EtsrH%2EE
zXmr2(!3Qv{cg&An>+2(Oy2QK)K#2QQ97+L0
zbMR-9s`ajP_f3y)viY7Px+I#o92-AXHznDYYyLQ@=ewp;o`pSr(w$y#WG@3;2%-RK
zqd8i;@r~MI_dCkO@Iz+}M2E*sdPzW0UkgKF*U4G54Yi
z5tf%+^41I&y*}x}@=N@$jG?zT3fHX@lYoOO!9cUu@B4tPccjGZ6bNLFD1I*8dGu|H
zo*j-sQh)`&gj)qPQ8(SHaro8Es<5vsp*Zk^matEYJn7=tc$5T)9B<*PrZ6B+3Uvf5
ze|Z23)pQUBx7_NR#LtF}?4~bbBDiF~@&1h~H>k?d8ols6o8!M#U|7X&GCN-ZViK#5
z2UjkV6C|$Ij>V*dAl*CkRK>@CeHqJ17@r`%#B$>$bRd$UhxH)-HejSs6dVoW
z+*1~SR6?GMTFQQ+)DnXN@CP&(LP9(_%Nszpi`LO-_O
zud8c23PHWLcIT^&a#Bl9Kh{ochy%81OOoc$aTyXx))qrbKG4i9JN9>2z3|}
zeQQ8{n3tJ!wqXE0GNILgOLxXm*zhr;^XhXa&^e0N^-bx8fE)?DR0v}bxpFe|2^tji
zu%jLi!JEkO;t90Ee;pqwIB|UzkyF5Z-F8U3`{2RT@9nnjlas0e$=Z@!;sn*%g4zg=
zRu~}b@ESjVCLzVAEl@-yrg!VT9{vf`1k3`#I(|Sqj8*Rf*4}R%C-KtVJcVyUvP2Jq
z5^Rd~rQcwHk(lJTyuEPs4`3yTcEG*u@o@6>rn;Hu!GgJZ(VSQBL-}|_)GH?XR^l+*
z&+nlwrPvSd69u_jlwaaGFZ}I%{KFG0+$SpPQZRF_%b-$04h>-f$S;Ae&vJT`jR}$F
zCTqoBL=quZv!AZzT1Q=gqNWY%@;QgZVh2F|jVte!LnCq2qO~Umj7eiG;#$z9Tn>ue
z1H!=#7G(ltI3VBq5G;f_8RXM{$Y;)!aci-C1sS!}J%LZ~BprAaD~_&gAsLV^0dNgP
zlQ5^D=tgnfhH(H-kVA;L#`czo2Ng{|BMzjq@_KZZH3|a^hafpX0G@&&gv;c`0cMJa
z^NDflM9D#ktH?kiYOvE~o99r(@CFFZ-rm_2T>2+~2%yveTMJVd=7pU7^VpVw(8~+z
zKC@h-agZC*lQkn#6!o2qZ5&r_Zsk&uYDuSZMKeWI*
zbIZ7N3p&C+Vf_`8X?vV-IeS49tQXpfxcW8=XqEL|@8@T}6Mc_bX_{)LJ2vw9GLQS%
zXhxWOXF7lRTI~^VQwanR77$-lkd;vQ;sUj+;*TROuTQn^^(YfS&G7j7;OImso3;(`
z^GhUQWwElp>7bu)7f&OJM9={^WQ|0wW0?43#_=x{G0BP;cT}Gfs!sw{ppTQ+$K60T
z=Aj$4(Txpgsxv{|iJ(r6)Tu7EntbC2P)?l-L<)4e?OhcJL+6r#f$ca$#gm2&q+Jo@
zHv@5Vr)K0QZOFZ)ADf*xI7Bx@^y3=`5e&6O9mEDofEjn8sJnzXQoz1@iH^U8_}LeK
ztUL1R3UqGWx+8#$SHhc=O%Ki=iXP*RjVCjwljmRAE+~?;ag&(stQ6PGLOZ0Ir5!rEHVB_I_h94MvzE
zW%NqOMV8Y=cWfTZ9Gt3bp%P3AqM6tiE($+@XF&)>fC`xQ^zHiDHFd%tWuWm<@R7dS
ztjD0(O5#WP2)4%|swLClsYLm)&wm>NHGDQ}{iP;i#w56IW_7!*$chl^<_MWG2Fa0~
zmI2yiMjXK;n{`<WOn95)%lL{@S=<^b*R(9gar0;T)X(Es2-1fHc{d1T-6@b32O$n#3}KY&@-<
zWdH)UH{oR_I|U^5S_x_qN&$`i`4Rrcb(NF}BAP=I1Kj`ftzTps+P^PIUXLUa-j4Uz
zls{+&4d5{>{31K5GT1ceM8Fe6EV<@DwRlDgFO9~LS1GLKzdg>i3{N96j&y~5eY+{+
zZh3hlWFkm~cQ5gek}BPcGr$57wU?^z>r_Y=D|TWwVt5wGv1P#m>wa|d9Jq|lr9K~{
z%4!-t>SuQeg=3=p8wY7rE{lZS6^8?I%8f(F29HT31mC&*492pQdAls%z^ZR
z!$;B`Zb_7H-M>*?z%7eTYLA_~A3bqRYY?bpE4-_b32O?m?ep6hpnHUps*6YTu~FcG
z!XQn1^nZk8%HlF~1CGW5-SybQ>`CZMgIV2pNA$uMpO1pUmR7xjY!^M~0GDN)c}zO~
zu0tG=g~&QOS9MT2S3}qmkhR1R=Gc|%;zqWvK=Ew+wSVgOliIS9kl3tP%8ubd_Ahy)
zz%7sY%?e)D(n4tO;7@5yuqyb4`womdC`#G|GN`_=@~r_U1GqkO{Icz1o-Iqo^`ojU{8gZKvU-uR}Qp085?py;j&Kop{O&61SR^MjQ^6l$ky)cOdh*=K8=j)B`V$nO@7070Rv0brX)`
z?sMKe)?6>O44sv##Ji%w`ucwVGpS4J$J(%43YPz~Z&$EJ&x6>@wXuh4q3Y++t+^wa
zkJ*Q9oekF&rkq{zw!($(YjNaFPtS+X|J;V=j;k=oRieKoGI!lT&2cr^Ug9C4rB`
z_L1?W`q*p4wfCFwD-fNQe3W(}Gg!mZ=|biu-InGMK?(eX^{H0J8CS@wvxqkc+qDNfz(;W1NbI~6>0Z)!nx402@;;bpF31}JU7-2evwkT{!S
zzm25JUFB*4nX+??jJvc#dIAW4VqpQv4I{$JAB8VAx>r>pfJs0XIc`wZl@t;SiMXEsBi5h*@V-d^YbqPzb;DuUiA=G|dpcB*qze5bw!&(oYSxaw35<;;b
z0J0;A;EsLau3Wb_Z{A!vxDptEr>7^S&`qs4)sC%8CA2y^I5z4)zCo8O9+HY9hLDT?
znWh*%!3Q4!dj8?y;P0T9XTz*&4aoXTqs}2?fh-YZR&Np2G1oAxrKmnK24e8*Jy$U#
z6bWNMGSgWS07#r2FXtamFv*Bl*IVLASOV&a@Tb+ChT~1=fX$uRzSo6$Km1EiZ1BJdqIVQg)HEY%sr7{4K=z`F*g^)0jVeD2615jK9pd493
ziDE_AO{P%k#w$Y*Lk=H3ioi&O79ekXAih(ROHBQL_xysQ9Lt=u6=L9_wFOI+m6Ri1
zP3dqsjnzFr)+6Y3xGVVMj}D)Y|9JQW0C3kuqhzoe6ar)k(@xC}-<pjlL&m6hS@!TP#jxq;YZb?vSM9@M!>}5OllUa4BR}
zk;#Jm?bokJ>dD`vPf*x0-)=j1IeI`>t8Z2Bv=m~T6Zl4g*h(3Ws^Hs?rH~(