Merge branch 'brennanb2025/settle-split-1' into brennanb2025/settle-split-2

Syncs main b83a6f74be through part 1.
This commit is contained in:
Brennan Benson
2026-10-14 00:52:33 -07:00
508 changed files with 19393 additions and 4648 deletions
+1
View File
@@ -70,6 +70,7 @@ concurrency:
jobs:
verify:
name: Mobile verify
if: github.event_name == 'pull_request'
# Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle,
# Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby`
+4 -2
View File
@@ -251,15 +251,16 @@ jobs:
retention-days: 7
overwrite: true
# orcad's data-root privacy check fails closed without it, so every Windows slot needs one.
# Older requested refs use the previous security implementation and have no addon builder.
- name: Build the Windows security addons for the desktop template
if: inputs.build_template && matrix.os == 'windows-2022'
if: inputs.build_template && matrix.os == 'windows-2022' && hashFiles('config/scripts/build-windows-security-addon.mjs') != ''
shell: bash
run: |
node config/scripts/build-windows-security-addon.mjs --arch=x64
node config/scripts/build-windows-security-addon.mjs --arch=arm64
node -e "require('./.build/windows-security/x64/windows-security.node').currentUserSid()"
- name: Keep the Windows security addons for the desktop template
if: inputs.build_template && matrix.os == 'windows-2022'
if: inputs.build_template && matrix.os == 'windows-2022' && hashFiles('config/scripts/build-windows-security-addon.mjs') != ''
uses: actions/upload-artifact@v7
with:
name: orcad-windows-security
@@ -515,6 +516,7 @@ jobs:
name: orcad-windows-cli-launcher
path: .build/windows-cli-launcher
- name: Collect the Windows security addons
if: hashFiles('config/scripts/build-windows-security-addon.mjs') != ''
uses: actions/download-artifact@v8
with:
name: orcad-windows-security
+1 -1
View File
@@ -10,7 +10,7 @@
1 src/main/ipc/renderer-shutdown-checkpoint.ts
1 src/main/ipc/runtime-environment-removal-cleanup.ts
1 src/main/ipc/runtime-environment-session-reconcile.ts
3 src/main/ipc/session.ts
2 src/main/ipc/session.ts
6 src/main/orca-profiles/profile-project-session-state.ts
4 src/main/orca-profiles/profile-project-source-removal.ts
2 src/main/orca-profiles/profile-project-state-file.ts
File diff suppressed because one or more lines are too long
@@ -88,7 +88,7 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
export interface IBrowser {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee3acfddfa 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..8ce5bad71b26515ecbbb82b76d218d0b16e280a8 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -1,10 +1,15 @@
@@ -413,7 +413,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
}
/**
@@ -113,7 +313,19 @@ export class CompositionHelper {
@@ -113,10 +313,33 @@ export class CompositionHelper {
* @returns Whether the Terminal should continue processing the keydown event.
*/
public keydown(ev: KeyboardEvent): boolean {
@@ -422,7 +422,13 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ this._canceledKey = undefined;
+ return false;
+ }
+ if (ev.key === 'Escape' && (this._isComposing || this.hasPendingCompositionFinalization)) {
+ // An ended session's commit is accepted; only the Escape that ended it (macOS reports that
+ // keydown after compositionend, still composing) cancels it.
+ if (
+ ev.key === 'Escape' &&
+ (this._isComposing ||
+ (this.hasPendingCompositionFinalization && (ev.isComposing || ev.keyCode === 229)))
+ ) {
+ this._canceledKey = { code: ev.code, timeStamp: ev.timeStamp };
+ this._cancelComposition();
+ return false;
@@ -434,7 +440,15 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
if (ev.keyCode === 20 || ev.keyCode === 229) {
// 20 is CapsLock, 229 is Enter
// Continue composing if the keyCode is the "composition character"
@@ -128,6 +340,10 @@ export class CompositionHelper {
+ if (ev.keyCode === 229 && !this._isComposing) {
+ // The ended session's commit is already in the textarea unless it still awaits its echo,
+ // so this key owes its own commit if the finalizer runs before that text arrives.
+ this._imeKeydownAwaitingCommit = !this._pendingComposition?.expectsPostCompositionInput;
+ }
return false;
}
if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
@@ -128,6 +351,10 @@ export class CompositionHelper {
this._finalizeComposition(false);
}
@@ -445,7 +459,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
if (ev.keyCode === 229) {
// If the "composition character" is used but gets to this point it means a non-composition
// character (eg. numbers and punctuation) was pressed when the IME was active.
@@ -138,6 +354,74 @@ export class CompositionHelper {
@@ -138,6 +365,78 @@ export class CompositionHelper {
return true;
}
@@ -454,6 +468,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ * after reconciliation with the final textarea candidate.
+ */
+ public keypress(text: string): boolean {
+ // Every path below sends this key's text, so its keydown no longer owes a commit.
+ this._imeKeydownAwaitingCommit = false;
+ const pending = this._pendingComposition;
+ if (!pending) {
+ return false;
@@ -480,6 +496,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ if (!pending) {
+ return this._claimImeKeydownCommit(text);
+ }
+ // The pending transaction settles this text below, so no keydown still owes it.
+ this._imeKeydownAwaitingCommit = false;
+ if (pending.expectsPostCompositionInput) {
+ pending.inputData += text;
+ pending.expectsPostCompositionInput = false;
@@ -520,7 +538,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
/**
* Finalizes the composition, resuming regular input actions. This is called when a composition
* is ending.
@@ -146,23 +430,52 @@ export class CompositionHelper {
@@ -146,23 +445,52 @@ export class CompositionHelper {
* compositionend event is triggered, such as enter, so that the composition is sent before
* the command is executed.
*/
@@ -584,7 +602,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -172,37 +485,315 @@ export class CompositionHelper {
@@ -172,35 +500,313 @@ export class CompositionHelper {
// - The last compositionupdate event's data property does not always accurately describe
// the character, a counter example being Korean where an ending consonsant can move to
// the following character if the following input is a vowel.
@@ -621,7 +639,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ }
+ if (this._pendingComposition === pending) {
+ this._sendPendingComposition(pending, true);
+ }
}
- }, 0);
+ });
+ }
+ }
@@ -654,7 +673,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ private _cancelPendingFinalizer(pending: IPendingComposition): void {
+ if (pending.finalizerTimer === undefined) {
+ return;
+ }
}
+ clearTimeout(pending.finalizerTimer);
+ this._compositionTimers.delete(pending.finalizerTimer);
+ pending.finalizerTimer = undefined;
@@ -813,8 +832,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ id: pending.transactionId,
+ data: input,
+ dataPendingReconciliation: true
}
- }, 0);
+ }
+ }
+ ));
+ }
@@ -822,9 +840,9 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ private _dispatchCompositionSessionEvent(event: CustomEvent): void {
+ if (typeof this._textarea.dispatchEvent === 'function') {
+ this._textarea.dispatchEvent(event);
}
}
+ }
+ }
+
+ private _dispatchCompositionTransactionSettled(): void {
+ this._dispatchCompositionSessionEvent(new CustomEvent(
+ 'xterm-composition-transaction-settled',
@@ -922,12 +940,10 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
+ }
+ clearTimeout(timer);
+ this._compositionTimers.delete(timer);
+ }
+
}
/**
* Apply any changes made to the textarea after the current event chain is allowed to complete.
* This should be called when not currently composing but a keydown event with the "composition
@@ -222,6 +813,9 @@ export class CompositionHelper {
@@ -222,6 +828,9 @@ export class CompositionHelper {
const diff = newValue.replace(oldValue, '');
@@ -937,7 +953,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +830,105 @@ export class CompositionHelper {
@@ -236,6 +845,105 @@ export class CompositionHelper {
}, 0);
}
@@ -1043,7 +1059,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -243,10 +936,23 @@ export class CompositionHelper {
@@ -243,10 +951,23 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -1068,7 +1084,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..4d62369d0005f465318ec93e221fd3ee
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
@@ -254,31 +960,156 @@ export class CompositionHelper {
@@ -254,31 +975,156 @@ export class CompositionHelper {
const cursorTop = this._bufferService.buffer.y * this._renderService.dimensions.css.cell.height;
const cursorLeft = cursorX * this._renderService.dimensions.css.cell.width;
@@ -102,7 +102,6 @@
6 src/renderer/src/store/slices/terminal-orphan-helpers.ts
4 src/renderer/src/store/slices/terminal-tab-title-batch.ts
6 src/renderer/src/store/slices/terminals.ts
5 src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts
7 src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts
15 src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts
2 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
@@ -0,0 +1,93 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const SUPPORTED_LOCALES = new Set(['es', 'fr', 'ja', 'ko', 'zh'])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
function placeholders(value) {
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
}
function getEntry(catalog, key) {
return key.split('.').reduce((cursor, part) => cursor?.[part], catalog)
}
function setEntry(catalog, key, value) {
const parts = key.split('.')
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (typeof cursor[part] !== 'object' || cursor[part] === null || Array.isArray(cursor[part])) {
cursor[part] = {}
}
cursor = cursor[part]
}
cursor[parts.at(-1)] = value
}
function getEn(catalog, key) {
const value = getEntry(catalog, key)
if (typeof value !== 'string') {
throw new Error(`en.json has no string entry for ${key}`)
}
return value
}
async function main() {
const root = process.cwd()
const translationsPath = process.argv[2] ?? '/tmp/translations.json'
const translations = JSON.parse(await fs.readFile(translationsPath, 'utf8'))
const enCatalog = JSON.parse(await fs.readFile(path.join(root, LOCALES_DIR, 'en.json'), 'utf8'))
for (const [locale, entries] of Object.entries(translations)) {
// Why: the locale becomes a path segment, so an unchecked key could write outside LOCALES_DIR.
if (!SUPPORTED_LOCALES.has(locale)) {
throw new Error(`Unsupported locale: ${locale}`)
}
const localePath = path.join(root, LOCALES_DIR, `${locale}.json`)
const localeCatalog = JSON.parse(await fs.readFile(localePath, 'utf8'))
let added = 0
let skipped = 0
for (const [key, rawValue] of Object.entries(entries)) {
if (getEntry(localeCatalog, key) !== undefined) {
console.log(`[${locale}] already present, skip: ${key}`)
skipped += 1
continue
}
const enValue = getEn(enCatalog, key)
if (placeholders(enValue) !== placeholders(rawValue)) {
throw new Error(
`[${locale}] placeholder mismatch for ${key}: en=${placeholders(enValue)} vs locale=${placeholders(rawValue)}`
)
}
const repaired = repairTranslatedValue({
key,
enValue,
localeValue: rawValue,
locale
})
if (placeholders(enValue) !== placeholders(repaired)) {
throw new Error(
`[${locale}] repair broke placeholders for ${key}: ${JSON.stringify(repaired)}`
)
}
if (repaired !== rawValue) {
console.log(
`[${locale}] policy repair adjusted ${key}: ${JSON.stringify(rawValue)} -> ${JSON.stringify(repaired)}`
)
}
setEntry(localeCatalog, key, repaired)
added += 1
}
// Why: write-then-rename so an interrupted run can't leave the catalog truncated.
const tmpPath = `${localePath}.${process.pid}.tmp`
await fs.writeFile(tmpPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
await fs.rename(tmpPath, localePath)
console.log(`[${locale}] added ${added}, skipped ${skipped}`)
}
}
await main()
@@ -242,7 +242,8 @@ describe('Windows CLI launcher', () => {
argv: process.argv.slice(2),
profile: process.env.ORCA_USER_DATA_PATH,
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null,
nodeOptions: process.env.NODE_OPTIONS ?? null
nodeOptions: process.env.NODE_OPTIONS ?? null,
desktopMarker: process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER ?? null
}))\n`,
'utf8'
)
@@ -262,13 +263,15 @@ describe('Windows CLI launcher', () => {
ELECTRON_RUN_AS_NODE: '1',
NODE_OPTIONS: '--require C:\\missing\\preload.js',
ORCA_USER_DATA_PATH: 'C:\\another\\owner',
ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1',
ORCA_TEST_LAUNCHER: launcherPath
}
const expected = (body) => ({
argv: ['orchestration', 'send', '--body', body, '--json'],
profile,
electronRunAsNode: null,
nodeOptions: null
nodeOptions: null,
desktopMarker: null
})
// Windows PowerShell 5.1 itself drops embedded double quotes, so its body has none.
const shellBody = 'line one & %PATH% ^caret\n\nline three'
@@ -0,0 +1,23 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { mobileWebAppModuleClosure } from './build-mobile-web-app-bundle.mjs'
it('resolves local entry paths against the requested tree and selects browser siblings', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-route-entrypoints-'))
try {
await mkdir(join(root, 'app'))
await writeFile(join(root, 'app', 'route.tsx'), "import 'native-only-package'")
await writeFile(join(root, 'app', 'route.web.tsx'), "import './browser-dependency'")
await writeFile(join(root, 'app', 'browser-dependency.ts'), 'export const browser = true')
for (const entry of ['app/route', './app/route.tsx', join(root, 'app', 'route.tsx')]) {
const { local } = await mobileWebAppModuleClosure([entry], { absWorkingDir: root })
expect(local).toContain('app/route.web.tsx')
expect(local).toContain('app/browser-dependency.ts')
expect(local).not.toContain('app/route.tsx')
}
} finally {
await rm(root, { recursive: true, force: true })
}
})
@@ -40,6 +40,8 @@ export function nodeServerTestPaths({
'src/main/orcad/orcad-packaged-node-pty.integration.test.ts',
'src/main/providers/agent-foreground-process-git-bash.win32.test.ts',
'src/main/orcad/orcad-node-launcher.integration.test.ts',
'src/main/orcad/orcad-cli-launcher.integration.test.ts',
'src/main/orcad/orcad-cli-orchestration.integration.test.ts',
'src/main/orcad/orcad-launcher-isolation.integration.test.ts',
'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts',
'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts',
@@ -1,7 +1,9 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { runInNewContext } from 'node:vm'
import { afterEach, expect, it, vi } from 'vitest'
import { parse } from 'yaml'
import { windowsSecurityAddonOutput } from './build-windows-security-addon.mjs'
import { stageOrcadWindowsSecurity } from './orcad-windows-security.mjs'
@@ -67,3 +69,35 @@ it('stages nothing for non-Windows slots', () => {
stageOrcadWindowsSecurity(root, output, 'darwin-arm64', { platform: 'darwin' })
expect(existsSync(join(output, 'windows-security.node'))).toBe(false)
})
it.each([
['older', false],
['current', true]
])('builds and collects security addons only when the %s source ships them', (_source, ships) => {
const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8'))
const steps = [
...workflow.jobs.persistence.steps.filter((step) =>
['Build', 'Keep'].some((verb) => step.name?.startsWith(`${verb} the Windows security addons`))
),
workflow.jobs.desktop_template.steps.find(
(step) => step.name === 'Collect the Windows security addons'
)
]
expect(steps).toHaveLength(3)
for (const step of steps) {
const hashFiles = vi.fn((path) => {
expect(path).toBe('config/scripts/build-windows-security-addon.mjs')
return ships ? 'source-hash' : ''
})
expect(
runInNewContext(step.if ?? 'true', {
inputs: { build_template: true },
matrix: { os: 'windows-2022' },
hashFiles
}),
step.name
).toBe(ships)
expect(hashFiles).toHaveBeenCalledOnce()
expect(step['continue-on-error']).toBeUndefined()
}
})
@@ -0,0 +1,20 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
it('reserves the required verify check for the full PR gate', () => {
const directory = '.github/workflows'
const providers = readdirSync(directory)
.filter((file) => /\.ya?ml$/.test(file))
.flatMap((file) => {
const workflow = parse(readFileSync(join(directory, file), 'utf8'))
if (!Object.hasOwn(workflow.on ?? {}, 'pull_request')) {
return []
}
return Object.entries(workflow.jobs)
.filter(([id, job]) => (job.name ?? id) === 'verify')
.map(() => file)
})
expect(providers).toEqual(['pr.yml'])
})
@@ -55,6 +55,10 @@ export const NODE_RUNTIME_INCLUDE = [
'src/main/ai-vault-search/session-search-fts5-contract.test.ts',
'src/main/foreign-sqlite-readers/**/*.test.ts',
'src/main/browser/*cookie*.test.ts',
// Playwright's CDP transport needs Node's WebSocket implementation.
'src/main/browser/browser-popup-compatibility.electron.test.ts',
'src/main/browser/browser-popup-native-compatibility.electron.test.ts',
'src/main/browser/browser-popup-background-compatibility.electron.test.ts',
'src/main/ai-vault/session-scanner-devin-db.test.ts',
'src/shared/workspace-session-salvage.test.ts',
'src/main/git/repo-detection.test.ts',
@@ -121,6 +121,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts',
+43
View File
@@ -157,3 +157,46 @@ Method (reproducible):
R3 sign-off clean on all five locales (0 errors, 0 warns; placeholders byte-identical,
0 untranslated leftovers, agent/proxy terminology verified).
## Scan 2026-10-13
- HEAD scanned: 4d65146f2d (origin/main; branch fast-forwarded onto it). Base: de4ee59cba
(the 2026-10-07 scan's HEAD). Window: de4ee59cba .. HEAD (648 commits).
- Note: this branch arrived with an uncommitted partial run (at a9458b5d14, 648 commits
behind origin/main): 81 keys x5 added plus a stale `settings.chat.description`. It was
saved as a draft seed (it also wrongly kept the 3 pinned Chat UI keys) and superseded by
this full scan against current origin/main.
- Gates before fix: catalog verify PASS, coverage --check PASS (13 allowlisted), extraction
PASS, runtime-catalog PASS. No unkeyed strings and en.json in sync; the gap was
target-locale coverage only.
- Delta since base: ~500 new en keys, 161 removed (mostly `auto.*.<hash>` renames),
35 changed en values. All five locales were missing the same delta set
(es 262, fr 261, ja 262, ko 238, zh 250); that set includes the 3 pinned Chat UI keys
(`native-chat-locales.test.ts`). Add the 11 pinned provider-credit-balance keys
(`provider-credit-balance-locales.test.ts`) and all 14 stay English-fallback by design.
- Applied 259/258/259/235/247 new keys (es/fr/ja/ko/zh) via apply-translation-delta.mjs
(pipeline repaired ja 新しい->新規, zh 忽略->关闭). Strictly additive: +1258 keys, 0 removed.
- Changed-value review: `settings.chat.description` was stale in all five ("get their names"
-> "behave"); es `TerminalRemoteRuntimeReconnectBanner.retryingBody` was still English;
es/fr/ja `ZcodePlanAccountsSection.usingCliHelp` still named the removed
`~/.zcode/cli/config.json` path; zh had 9 agent-sense 代理 that the 智能体 standard requires.
Fixed all 18.
- Gates after fix: catalog verify PASS (all five 15902/15916; the 14 omissions are the 11
pinned balance + 3 pinned Chat UI keys), coverage --check PASS, extraction PASS,
runtime-catalog PASS, locale vitest suites PASS (17 i18n files/209 tests + 14 policy
files/79 tests).
- Reviews (4 of max 4 rounds; round 0 = 5 parallel translators, then 5 reviewers/round):
R1 applied 20 (es distro gender x2; fr Maj+Entrée + count/stackSummary plural; ja
runtimeAuth modal + checksPassing 成功; ko plain-http calques x3 + state marker; zh
注释->笔记 x3, 标注->注释 x2, pronoun/label fixes).
R2 applied 11 (es copySignInLink "instead" + WSL lane->cuenta; fr http non chiffré x3 +
deviceDowngraded pronoun; ja queueSearchSteer 今すぐ送信 + seenIn 確認済み; ko
signInLinkCopied 비공개 창; zh claudeRemoveFolder + waiting.label).
R3 applied 4 (fr distro-feminine x3; zh keywordDistro 发行版); es/ja/ko signed off clean.
R4 re-verified fr/zh and applied 1 (fr queueSearchQueue file d'attente); zh clean; rejected
the fr réouvrir->rouvrir rewrite as churn (the catalog mixes 18 réouvrir / 30 rouvrir and
this same feature already ships "Réouvrir").
- Out-of-delta findings left for a future pass (pre-existing, not from this window's PRs):
zh `components.workspace.cleanup.browse.*` bad MT (host->主持人, path->小路, ticket->票,
blockers->阻碍者); 7 zh keys still use Latin "Agent"; zh ZCode TUI title/description left
English; fr search keywords "indigène"/"procuration".
+7 -3
View File
@@ -45,15 +45,19 @@ Notifications.setNotificationHandler({
export default function RootLayout() {
const router = useRouter()
const pathname = usePathname()
const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
const { hostId, worktreeId, executionHost } = useGlobalSearchParams<{
hostId?: string
worktreeId?: string
executionHost?: string
}>()
useEffect(() => {
setNotificationViewingWorkspace(
pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
? { hostId, worktreeId }
? { hostId, worktreeId, ...(typeof executionHost === 'string' ? { executionHost } : {}) }
: null
)
return () => setNotificationViewingWorkspace(null)
}, [pathname, hostId, worktreeId])
}, [pathname, hostId, worktreeId, executionHost])
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
File diff suppressed because it is too large Load Diff
@@ -53,10 +53,10 @@
"ordinal": 51,
"value": "transport failure"
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -190,6 +190,11 @@
"ordinal": 51,
"value": ""
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"281c23abda8b": {
"name": "settings.get#1",
"ordinal": 43,
@@ -235,6 +240,11 @@
"ordinal": 42,
"value": ""
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -316,6 +326,11 @@
"ordinal": 9,
"value": false
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"41f045867f52": {
"name": "linear.status#1",
"ordinal": 46,
@@ -379,6 +394,11 @@
}
}
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -389,11 +409,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"506d5254f01c": {
"name": "pendingHostedStateChange",
"ordinal": 35,
@@ -447,11 +462,6 @@
"$rpc": "null"
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"558df6e44ef7": {
"name": "settings.get#1",
"ordinal": 47,
@@ -472,6 +482,11 @@
"visibleTaskProviders": ["github", "linear"]
}
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"5c284553e240": {
"name": "linear.status#1",
"ordinal": 46,
@@ -556,11 +571,6 @@
"ordinal": 16,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"796a64aabbc9": {
"name": "showGitLabViewPicker",
"ordinal": 13,
@@ -694,6 +704,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -772,16 +787,16 @@
}
}
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"a5110db5951d": {
"name": "linearTeams",
"ordinal": 56,
"value": []
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -829,6 +844,11 @@
"ordinal": 40,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -862,21 +882,11 @@
"$rpc": "null"
}
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b99d5f718523": {
"name": "showLinearDisplayPicker",
"ordinal": 8,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"c4b6b5ed0b10": {
"name": "showCreateTargetPicker",
"ordinal": 31,
@@ -886,14 +896,9 @@
"hydrated": false,
"settings": {}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
},
"cff6ef7f2791": {
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 66,
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
@@ -1008,10 +1013,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -1020,11 +1030,6 @@
"$rpc": "null"
}
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fe7472dae00e": {
"name": "showGitHubPresetPicker",
"ordinal": 12,
@@ -1169,17 +1174,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1253,17 +1259,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1337,17 +1344,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1421,17 +1429,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1505,17 +1514,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1589,17 +1599,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1673,17 +1684,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1757,17 +1769,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1841,17 +1854,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -16,10 +16,10 @@
"ordinal": 51,
"value": "transport failure"
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -83,6 +83,11 @@
"ordinal": 51,
"value": ""
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"281c23abda8b": {
"name": "settings.get#1",
"ordinal": 43,
@@ -128,6 +133,11 @@
"ordinal": 42,
"value": ""
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -209,6 +219,16 @@
"ordinal": 9,
"value": false
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -219,11 +239,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"4979dce5a6d2": {
"name": "preflight.check#1",
"ordinal": 45,
@@ -377,11 +392,6 @@
}
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"558df6e44ef7": {
"name": "settings.get#1",
"ordinal": 47,
@@ -437,6 +447,11 @@
"visibleTaskProviders": ["github", "linear"]
}
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"644df42439c4": {
"name": "linearWorkspaces",
"ordinal": 55,
@@ -486,11 +501,6 @@
"ordinal": 16,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"796a64aabbc9": {
"name": "showGitLabViewPicker",
"ordinal": 13,
@@ -690,6 +700,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -769,16 +784,16 @@
}
}
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"a5110db5951d": {
"name": "linearTeams",
"ordinal": 56,
"value": []
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -794,6 +809,11 @@
"ordinal": 40,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -827,21 +847,11 @@
"$rpc": "null"
}
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b99d5f718523": {
"name": "showLinearDisplayPicker",
"ordinal": 8,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"c4b6b5ed0b10": {
"name": "showCreateTargetPicker",
"ordinal": 31,
@@ -851,14 +861,9 @@
"hydrated": false,
"settings": {}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
},
"cff6ef7f2791": {
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 66,
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
@@ -1008,10 +1013,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -1020,11 +1030,6 @@
"$rpc": "null"
}
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fe7472dae00e": {
"name": "showGitHubPresetPicker",
"ordinal": 12,
@@ -1169,17 +1174,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1253,17 +1259,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1337,17 +1344,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1421,17 +1429,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1505,17 +1514,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1589,17 +1599,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1673,17 +1684,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1757,17 +1769,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1841,17 +1854,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -16,10 +16,10 @@
"ordinal": 51,
"value": "transport failure"
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -182,6 +182,11 @@
"ordinal": 51,
"value": ""
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"281c23abda8b": {
"name": "settings.get#1",
"ordinal": 43,
@@ -227,6 +232,11 @@
"ordinal": 42,
"value": ""
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -308,6 +318,16 @@
"ordinal": 9,
"value": false
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -318,11 +338,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"506d5254f01c": {
"name": "pendingHostedStateChange",
"ordinal": 35,
@@ -376,11 +391,6 @@
"$rpc": "null"
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"558df6e44ef7": {
"name": "settings.get#1",
"ordinal": 47,
@@ -472,6 +482,11 @@
}
}
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"644df42439c4": {
"name": "linearWorkspaces",
"ordinal": 55,
@@ -492,11 +507,6 @@
"ordinal": 16,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"796a64aabbc9": {
"name": "showGitLabViewPicker",
"ordinal": 13,
@@ -630,6 +640,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -678,6 +693,11 @@
"hydrated": true,
"settings": {}
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"a0a551db9148": {
"name": "settings.get#1",
"ordinal": 43,
@@ -755,11 +775,6 @@
"ordinal": 56,
"value": []
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -775,6 +790,11 @@
"ordinal": 40,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -808,21 +828,11 @@
"$rpc": "null"
}
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b99d5f718523": {
"name": "showLinearDisplayPicker",
"ordinal": 8,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"bc24fe05c135": {
"name": "error",
"ordinal": 51,
@@ -905,14 +915,9 @@
}
}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
},
"cff6ef7f2791": {
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 66,
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
@@ -992,10 +997,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -1004,11 +1014,6 @@
"$rpc": "null"
}
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fa983166a0de": {
"name": "settings.get#1",
"ordinal": 43,
@@ -1188,17 +1193,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1406,17 +1412,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1490,17 +1497,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1574,17 +1582,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1658,17 +1667,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1742,17 +1752,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1826,17 +1837,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -18,10 +18,10 @@
"$rpc": "null"
}
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -158,6 +158,11 @@
"ordinal": 52,
"value": false
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"281c23abda8b": {
"name": "settings.get#1",
"ordinal": 43,
@@ -210,6 +215,11 @@
"$rpc": "null"
}
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -338,11 +348,21 @@
"$rpc": "null"
}
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"451dd689d611": {
"name": "error",
"ordinal": 41,
"value": "outer refused"
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -385,11 +405,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"4be752d91ad4": {
"name": "items",
"ordinal": 42,
@@ -519,11 +534,6 @@
"$rpc": "null"
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"54fba5c273ed": {
"name": "mergeMethodTaskItem",
"ordinal": 78,
@@ -571,6 +581,11 @@
"visibleTaskProviders": ["github", "linear"]
}
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"608afe3f4ae9": {
"name": "status.get#1",
"ordinal": 39,
@@ -668,11 +683,6 @@
"ordinal": 47,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"78319deeba27": {
"name": "status.get#1",
"ordinal": 39,
@@ -863,6 +873,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -953,6 +968,11 @@
"ordinal": 63,
"value": false
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"a155086921fa": {
"name": "status.get#1",
"ordinal": 39,
@@ -998,11 +1018,6 @@
"ordinal": 60,
"value": false
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -1025,6 +1040,11 @@
"ordinal": 40,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -1070,11 +1090,6 @@
"ordinal": 46,
"value": false
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b9019cedf8b9": {
"name": "actionItem",
"ordinal": 67,
@@ -1092,11 +1107,6 @@
"ordinal": 49,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"bd86f8dd7be9": {
"name": "taskStateHydrated",
"ordinal": 82,
@@ -1111,6 +1121,18 @@
"hydrated": false,
"settings": {}
},
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
},
"lastViewByProject": {},
"pinned": [],
"recent": []
}
},
"c9c695bcacba": {
"name": "error",
"ordinal": 41,
@@ -1123,28 +1145,11 @@
"$rpc": "null"
}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
},
"cdd9c719af34": {
"name": "showSortPicker",
"ordinal": 58,
"value": false
},
"cff6ef7f2791": {
"name": "githubProjectSettings",
"ordinal": 66,
"value": {
"activeProject": {
"$rpc": "null"
},
"lastViewByProject": {},
"pinned": [],
"recent": []
}
},
"d0241f0b4ae1": {
"name": "showGitLabViewPicker",
"ordinal": 55,
@@ -1254,10 +1259,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -1271,11 +1281,6 @@
"ordinal": 48,
"value": false
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fba288f11327": {
"name": "pendingHostedStateChange",
"ordinal": 77,
@@ -1427,17 +1432,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -16,10 +16,10 @@
"ordinal": 51,
"value": "transport failure"
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -83,6 +83,11 @@
"ordinal": 51,
"value": ""
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"26b2a0d2a879": {
"name": "ui.get#1",
"ordinal": 44,
@@ -162,6 +167,11 @@
"ordinal": 42,
"value": ""
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -309,6 +319,16 @@
"ordinal": 9,
"value": false
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -319,11 +339,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"4b9d206c3696": {
"name": "ui.get#1",
"ordinal": 44,
@@ -446,11 +461,6 @@
"$rpc": "null"
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"558df6e44ef7": {
"name": "settings.get#1",
"ordinal": 47,
@@ -471,6 +481,11 @@
"visibleTaskProviders": ["github", "linear"]
}
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"5ae86146975f": {
"name": "ui.get#1",
"ordinal": 44,
@@ -521,11 +536,6 @@
"ordinal": 16,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"796a64aabbc9": {
"name": "showGitLabViewPicker",
"ordinal": 13,
@@ -659,6 +669,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -703,6 +718,11 @@
"ordinal": 52,
"value": {}
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"a5110db5951d": {
"name": "linearTeams",
"ordinal": 56,
@@ -742,11 +762,6 @@
}
}
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -794,6 +809,11 @@
}
}
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -827,21 +847,11 @@
"$rpc": "null"
}
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b99d5f718523": {
"name": "showLinearDisplayPicker",
"ordinal": 8,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"c4b6b5ed0b10": {
"name": "showCreateTargetPicker",
"ordinal": 31,
@@ -886,14 +896,9 @@
}
}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
},
"cff6ef7f2791": {
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 66,
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
@@ -973,10 +978,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -1020,11 +1030,6 @@
}
}
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fe7472dae00e": {
"name": "showGitHubPresetPicker",
"ordinal": 12,
@@ -1169,17 +1174,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1253,17 +1259,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1337,17 +1344,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1421,17 +1429,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1505,17 +1514,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1589,17 +1599,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1673,17 +1684,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1757,17 +1769,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1841,17 +1854,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -11,10 +11,10 @@
"$rpc": "null"
}
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -78,6 +78,11 @@
"$rpc": "null"
}
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"281c23abda8b": {
"name": "settings.get#1",
"ordinal": 43,
@@ -123,6 +128,11 @@
"ordinal": 42,
"value": ""
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -283,6 +293,11 @@
}
}
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"4532f849627d": {
"name": "settings.get#1",
"ordinal": 43,
@@ -315,6 +330,11 @@
}
}
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -325,11 +345,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"4a708e33e633": {
"name": "preflight.check#1",
"ordinal": 45,
@@ -447,11 +462,6 @@
"$rpc": "null"
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"558df6e44ef7": {
"name": "settings.get#1",
"ordinal": 47,
@@ -504,6 +514,11 @@
"visibleTaskProviders": ["github", "linear"]
}
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"612ee6cb2dfb": {
"name": "error",
"ordinal": 51,
@@ -593,11 +608,6 @@
"ordinal": 16,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"796a64aabbc9": {
"name": "showGitLabViewPicker",
"ordinal": 13,
@@ -763,6 +773,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -876,6 +891,11 @@
}
}
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"9cc1de98a7e9": {
"name": "ui.get#1",
"ordinal": 44,
@@ -913,11 +933,6 @@
"ordinal": 56,
"value": []
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -933,6 +948,11 @@
"ordinal": 40,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -966,21 +986,11 @@
"$rpc": "null"
}
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b99d5f718523": {
"name": "showLinearDisplayPicker",
"ordinal": 8,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"bcbf9866f3f5": {
"name": "error",
"ordinal": 51,
@@ -995,10 +1005,17 @@
"hydrated": false,
"settings": {}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
},
"lastViewByProject": {},
"pinned": [],
"recent": []
}
},
"ce36b2dfa811": {
"name": "ui.get#1",
@@ -1032,18 +1049,6 @@
}
}
},
"cff6ef7f2791": {
"name": "githubProjectSettings",
"ordinal": 66,
"value": {
"activeProject": {
"$rpc": "null"
},
"lastViewByProject": {},
"pinned": [],
"recent": []
}
},
"d4db291a4c50": {
"name": "pendingProjectGitHubMerge",
"ordinal": 34,
@@ -1151,10 +1156,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -1163,11 +1173,6 @@
"$rpc": "null"
}
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fe7472dae00e": {
"name": "showGitHubPresetPicker",
"ordinal": 12,
@@ -1312,17 +1317,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -1461,17 +1467,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
},
@@ -11,10 +11,10 @@
"$rpc": "null"
}
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -73,6 +73,11 @@
"$rpc": "null"
}
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"281c23abda8b": {
"name": "settings.get#1",
"ordinal": 43,
@@ -118,6 +123,11 @@
"ordinal": 42,
"value": ""
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -199,6 +209,16 @@
"ordinal": 9,
"value": false
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -209,11 +229,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"506d5254f01c": {
"name": "pendingHostedStateChange",
"ordinal": 35,
@@ -267,11 +282,6 @@
"$rpc": "null"
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"558df6e44ef7": {
"name": "settings.get#1",
"ordinal": 47,
@@ -292,6 +302,11 @@
"visibleTaskProviders": ["github", "linear"]
}
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"644df42439c4": {
"name": "linearWorkspaces",
"ordinal": 55,
@@ -307,11 +322,6 @@
"ordinal": 16,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"796a64aabbc9": {
"name": "showGitLabViewPicker",
"ordinal": 13,
@@ -445,6 +455,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -489,16 +504,16 @@
"ordinal": 52,
"value": {}
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"a5110db5951d": {
"name": "linearTeams",
"ordinal": 56,
"value": []
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -514,6 +529,11 @@
"ordinal": 40,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -547,21 +567,11 @@
"$rpc": "null"
}
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b99d5f718523": {
"name": "showLinearDisplayPicker",
"ordinal": 8,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"c4b6b5ed0b10": {
"name": "showCreateTargetPicker",
"ordinal": 31,
@@ -571,14 +581,9 @@
"hydrated": false,
"settings": {}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
},
"cff6ef7f2791": {
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 66,
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
@@ -653,10 +658,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -665,11 +675,6 @@
"$rpc": "null"
}
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fe7472dae00e": {
"name": "showGitHubPresetPicker",
"ordinal": 12,
@@ -814,17 +819,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
}
File diff suppressed because it is too large Load Diff
@@ -11,10 +11,10 @@
"$rpc": "null"
}
},
"06945c581b21": {
"name": "defaultGitHubPreset",
"ordinal": 62,
"value": "issues"
"0557cf6e117f": {
"name": "linearFilter",
"ordinal": 66,
"value": "all"
},
"08c3599aa1ff": {
"name": "showGitHubKindPicker",
@@ -73,11 +73,21 @@
"$rpc": "null"
}
},
"265e84a16ebd": {
"name": "githubKind",
"ordinal": 65,
"value": "issues"
},
"294741680522": {
"name": "error",
"ordinal": 42,
"value": ""
},
"2a56620ac048": {
"name": "githubPreset",
"ordinal": 64,
"value": "issues"
},
"2ae77d454712": {
"name": "ui.get#1",
"ordinal": 48,
@@ -159,6 +169,16 @@
"ordinal": 9,
"value": false
},
"4121b85bfa08": {
"name": "provider",
"ordinal": 61,
"value": "github"
},
"45df0a2741dc": {
"name": "query",
"ordinal": 68,
"value": "is:issue is:open"
},
"45e466d86a07": {
"name": "showLinearWorkspacePicker",
"ordinal": 3,
@@ -169,11 +189,6 @@
"ordinal": 23,
"value": false
},
"494f3c869f37": {
"name": "taskStateHydrated",
"ordinal": 69,
"value": true
},
"506d5254f01c": {
"name": "pendingHostedStateChange",
"ordinal": 35,
@@ -227,11 +242,6 @@
"$rpc": "null"
}
},
"542593b21c22": {
"name": "appliedQuery",
"ordinal": 68,
"value": "is:issue is:open"
},
"558df6e44ef7": {
"name": "settings.get#1",
"ordinal": 47,
@@ -247,6 +257,11 @@
"ordinal": 57,
"value": []
},
"5a33c7e88b8e": {
"name": "defaultGitHubPreset",
"ordinal": 63,
"value": "issues"
},
"644df42439c4": {
"name": "linearWorkspaces",
"ordinal": 55,
@@ -262,11 +277,6 @@
"ordinal": 16,
"value": false
},
"737d03a180a4": {
"name": "githubMode",
"ordinal": 61,
"value": "items"
},
"796a64aabbc9": {
"name": "showGitLabViewPicker",
"ordinal": 13,
@@ -400,6 +410,11 @@
"ordinal": 6,
"value": false
},
"90d9c563300c": {
"name": "taskStateHydrated",
"ordinal": 70,
"value": true
},
"917e8342c71e": {
"name": "showCreateTask",
"ordinal": 30,
@@ -448,16 +463,16 @@
"hydrated": true,
"settings": {}
},
"9c3cbdd83e1d": {
"name": "appliedQuery",
"ordinal": 69,
"value": "is:issue is:open"
},
"a5110db5951d": {
"name": "linearTeams",
"ordinal": 56,
"value": []
},
"ac6cda1d7c7a": {
"name": "githubKind",
"ordinal": 64,
"value": "issues"
},
"ace3d3315687": {
"name": "showGitLabFilterPicker",
"ordinal": 14,
@@ -473,6 +488,11 @@
"ordinal": 40,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"b39bd3d3c40b": {
"name": "selectedRepoIds",
"ordinal": 59,
"value": []
},
"b3f3f6a6069b": {
"name": "ui.get#1",
"ordinal": 44,
@@ -506,21 +526,11 @@
"$rpc": "null"
}
},
"b8f36c62d60b": {
"name": "githubPreset",
"ordinal": 63,
"value": "issues"
},
"b99d5f718523": {
"name": "showLinearDisplayPicker",
"ordinal": 8,
"value": false
},
"bb8650c59a0f": {
"name": "visibleProviders",
"ordinal": 59,
"value": ["github", "linear"]
},
"c4b6b5ed0b10": {
"name": "showCreateTargetPicker",
"ordinal": 31,
@@ -530,14 +540,9 @@
"hydrated": false,
"settings": {}
},
"cb9416350e8d": {
"name": "linearFilter",
"ordinal": 65,
"value": "all"
},
"cff6ef7f2791": {
"c967fcbd2446": {
"name": "githubProjectSettings",
"ordinal": 66,
"ordinal": 67,
"value": {
"activeProject": {
"$rpc": "null"
@@ -636,10 +641,15 @@
"$rpc": "null"
}
},
"f48548c0c512": {
"name": "query",
"ordinal": 67,
"value": "is:issue is:open"
"f62cf435afca": {
"name": "githubMode",
"ordinal": 62,
"value": "items"
},
"f7b209507189": {
"name": "visibleProviders",
"ordinal": 60,
"value": ["github", "linear"]
},
"f7c2d2661209": {
"name": "selectedLinearWorkspaceId",
@@ -648,11 +658,6 @@
"$rpc": "null"
}
},
"fa73b8d70b9b": {
"name": "provider",
"ordinal": 60,
"value": "github"
},
"fe7472dae00e": {
"name": "showGitHubPresetPicker",
"ordinal": 12,
@@ -797,17 +802,18 @@
"a5110db5951d",
"575605e669c4",
"f7c2d2661209",
"bb8650c59a0f",
"fa73b8d70b9b",
"737d03a180a4",
"06945c581b21",
"b8f36c62d60b",
"ac6cda1d7c7a",
"cb9416350e8d",
"cff6ef7f2791",
"f48548c0c512",
"542593b21c22",
"494f3c869f37"
"b39bd3d3c40b",
"f7b209507189",
"4121b85bfa08",
"f62cf435afca",
"5a33c7e88b8e",
"2a56620ac048",
"265e84a16ebd",
"0557cf6e117f",
"c967fcbd2446",
"45df0a2741dc",
"9c3cbdd83e1d",
"90d9c563300c"
]
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Monitor, Server } from 'lucide-react-native'
import { parseExecutionHostId } from '../../../src/shared/execution-host'
import { colors } from '../theme/mobile-theme'
/** The desktop's own machine is a monitor, any other host a server, as on the desktop's run targets. */
export function HostKindIcon({ hostId, size }: { hostId: string | undefined; size: number }) {
// Rows from hosts that predate hostId stamping are local: a remote row always carries one.
const Icon = (parseExecutionHostId(hostId)?.kind ?? 'local') === 'local' ? Monitor : Server
return <Icon size={size} color={colors.textMuted} />
}
@@ -2,7 +2,8 @@ import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { HostProtocolGate, useHostProtocolGates } from './HostProtocolGate'
import { HostProtocolGate } from './HostProtocolGate'
import { useHostProtocolGates } from './host-protocol-gates-context'
const nativeTestState = vi.hoisted(() => ({
openUrl: vi.fn(),
+2 -10
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { useHostClient } from '../transport/client-context'
import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates'
import { useHostStatusGates } from '../transport/host-status-gates'
import { colors } from '../theme/mobile-theme'
import { HostStatusGatesContext, useOptionalHostProtocolGates } from './host-protocol-gates-context'
import { HostStatusGatesContext } from './host-protocol-gates-context'
import { ProtocolBlockScreen } from './ProtocolBlockScreen'
type Props = {
@@ -11,14 +11,6 @@ type Props = {
children: ReactNode
}
export function useHostProtocolGates(): HostStatusGates {
const gates = useOptionalHostProtocolGates()
if (!gates) {
throw new Error('useHostProtocolGates must be used inside <HostProtocolGate>')
}
return gates
}
// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the
// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable.
export function HostProtocolGate({ hostId, children }: Props) {
@@ -0,0 +1,83 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
import type { ActionSheetAction } from './ActionSheetModal'
const sheet = vi.hoisted(() => {
const state: { labels: string[] } = { labels: [] }
return state
})
vi.mock('react-native', () => ({
KeyboardAvoidingView: 'KeyboardAvoidingView',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Icon',
Copy: 'Icon',
FileText: 'Icon',
Plus: 'Icon',
Send: 'Icon',
Trash2: 'Icon',
X: 'Icon'
}))
vi.mock('./ActionSheetModal', () => ({
ActionSheetContent: ({ actions }: { actions: ActionSheetAction[] }) => {
sheet.labels = actions.map((action) => action.label)
return null
}
}))
vi.mock('./ConfirmModal', () => ({ ConfirmContent: () => null }))
vi.mock('./keyed-bottom-drawer', () => ({
KeyedBottomDrawer: ({
sheet: presented,
children
}: {
sheet: unknown
children: (presented: unknown) => unknown
}) => children(presented)
}))
import { MobileDiffReviewDrawers } from './MobileDiffReviewDrawers'
async function reviewActionLabels(executionHost?: string): Promise<string[]> {
sheet.labels = []
const controller = mountFixture<Parameters<typeof MobileDiffReviewDrawers>[0]['controller']>({
sheet: { kind: 'actions' },
reviewedUnstagedCount: 0,
screenState: { kind: 'loading' },
currentItem: null,
busyAction: null,
unsentComments: []
})
await act(async () => {
create(
createElement(
WorkspaceExecutionHostContext.Provider,
{ value: normalizeExecutionHostId(executionHost) ?? undefined },
createElement(MobileDiffReviewDrawers, { controller })
)
)
})
return sheet.labels
}
describe('the review screen’s actions', () => {
it('offers Open in Session for a workspace the desktop hosts', async () => {
expect(await reviewActionLabels()).toContain('Open in Session')
})
it('leaves out Open in Session for a server workspace, whose host has no editor tabs', async () => {
const labels = await reviewActionLabels('runtime:vm')
expect(labels).not.toContain('Open in Session')
expect(labels).toContain('Copy Notes')
})
})
@@ -17,6 +17,7 @@ import { reviewSheetKey, type ReviewSheet } from '../session/mobile-diff-review-
import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
import { hostOs } from '../platform/host-os'
import { useWorkspaceOnPairedHost } from '../navigation/workspace-execution-host'
type Props = {
controller: ReturnType<typeof useMobileDiffReviewController>
@@ -132,6 +133,7 @@ function useSendActions(
}
function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
const sessionTabsAvailable = useWorkspaceOnPairedHost()
return useMemo<ActionSheetAction[]>(
() => [
{
@@ -173,14 +175,18 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
skipAutoClose: true,
onPress: () => void controller.markUnreviewed()
},
{
label: 'Open in Session',
icon: FileText,
disabled: !controller.currentItem || controller.currentItem.scope === 'branch',
onPress: () => void controller.openInSession()
}
...(sessionTabsAvailable
? [
{
label: 'Open in Session',
icon: FileText,
disabled: !controller.currentItem || controller.currentItem.scope === 'branch',
onPress: () => void controller.openInSession()
}
]
: [])
],
[controller]
[controller, sessionTabsAvailable]
)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import {
type MobilePrTitleAction
} from '../session/use-mobile-pr-title-action'
import { useMobilePrAiTriage, type MobilePrAiTriage } from '../session/use-mobile-pr-ai-triage'
import { useHostProtocolGates } from './HostProtocolGate'
import { useHostProtocolGates } from './host-protocol-gates-context'
import { usePRBotAuthorOverrides } from '../session/use-pr-bot-author-overrides'
import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
@@ -24,7 +24,7 @@ export function NewWorktreeFormSheet(props: {
loading: boolean
hasRepos: boolean
project: Selection | null
runTarget: Selection | null
runTarget: (Selection & { hostId: string }) | null
projectBadgeColor: string | null
selectedRepoIsGit: boolean
selectedRepoConnectionId: string | null
+206 -1
View File
@@ -1,6 +1,7 @@
import { createElement, Suspense, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import type { RpcClient } from '../transport/rpc-client'
const asyncStorage = vi.hoisted(() => ({
@@ -11,6 +12,7 @@ const asyncStorage = vi.hoisted(() => ({
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
AppState: { currentState: 'active' },
FlatList: 'FlatList',
Image: 'Image',
Keyboard: { dismiss: vi.fn() },
@@ -44,8 +46,15 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { setCachedRepos } from '../cache/repo-cache'
import { recordHostDescriptor } from '../transport/host-descriptor-store'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
import { NewWorktreeModal } from './NewWorktreeModal'
import { NewWorktreeModalController } from './NewWorktreeModalController'
const RelayedOptionsSchema = z.object({ envelope: z.object({ executionHost: z.string() }) })
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
@@ -75,6 +84,15 @@ function sourceInputs(renderer: ReactTestRenderer) {
)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the modal only sends requests.
const requestsOnly = (sendRequest: unknown) => ({ sendRequest }) as unknown as RpcClient
function runTargetPicker(renderer: ReactTestRenderer) {
return renderer.root
.findAll((node) => node.type === 'PickerListDrawer')
.find((node) => node.props.title === 'Run on')!
}
async function flushUpdates(): Promise<void> {
await act(async () => {
await Promise.resolve()
@@ -174,7 +192,7 @@ describe('NewWorktreeModal project targets', () => {
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
expect.objectContaining({
label: 'SSH · build-server',
label: 'build-server',
detail: '/home/dev/orca'
})
])
@@ -306,6 +324,193 @@ describe('NewWorktreeModal project targets', () => {
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
})
it("creates a server repo's workspace on that server and names it for the route", async () => {
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
const serverSshRepo = { ...serverRepo, id: 'server-ssh', connectionId: 'its-own-ssh' }
const reply = (method: string, listed: unknown[], hostPlatform: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: listed } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform } })
}
if (method === 'worktree.create') {
return Promise.resolve({
ok: true,
result: { worktree: { id: 'srv-wt', displayName: 'srv-wt', path: '/srv/orca-wt' } }
})
}
if (method === 'settings.get') {
return Promise.resolve({ ok: true, result: { settings: {} } })
}
return Promise.resolve({ ok: true, result: {} })
}
const desktop = vi.fn((method: string) => reply(method, repos, 'darwin'))
const server = vi.fn((method: string) => reply(method, [serverRepo, serverSshRepo], 'linux'))
const onCreated = vi.fn()
recordHostDescriptor('host-1', { machineName: null, platform: 'darwin' })
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client: requestsOnly(desktop),
serverClients: new Map([['runtime:env-1', requestsOnly(server)]]),
hostId: 'host-1',
hostLabelById: new Map([['runtime:env-1', 'scratch-server']]),
onCreated,
onClose: () => {}
})
)
})
await flushUpdates()
const runTargets = pickerItems(renderer, 'Run on')
expect(runTargets.map((item) => [item.label, item.detail])).toEqual([
[LOCAL_HOST_LABEL, '/src/orca'],
['scratch-server', '/srv/orca']
])
const serverTarget = runTargetPicker(renderer)
const iconOf = (item: unknown) => {
let icon: ReactTestRenderer | undefined
act(() => {
icon = create(serverTarget.props.renderIcon(item))
})
return icon?.toJSON()
}
expect(serverTarget.props.items.map(iconOf)).toMatchObject([
{ type: 'Monitor' },
{ type: 'Server' }
])
await act(async () => serverTarget.props.onSelect(serverTarget.props.items[1]))
await flushUpdates()
// The Run on field shows the picked server's glyph, as its row does.
expect(renderer.root.findAll((node) => node.type === 'Server')).toHaveLength(1)
expect(pickerItems(renderer, 'Run on')[0]?.label).toBe(LOCAL_HOST_LABEL)
act(() => sourceInputs(renderer)[0]!.props.onChangeText('srv-wt'))
const form = renderer.root.find((node) => typeof node.props.onCreate === 'function')
await act(async () => form.props.onCreate())
await flushUpdates()
expect(server).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({ repo: 'id:server-repo' }),
expect.anything()
)
expect(desktop).not.toHaveBeenCalledWith(
'worktree.create',
expect.anything(),
expect.anything()
)
expect(onCreated).toHaveBeenCalledWith('srv-wt', expect.any(String), undefined, 'runtime:env-1')
})
it("offers the desktop's servers, by name, in a sheet opened before the list has them", async () => {
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
// One desktop: its own repos, its server list, and a server's repos relayed through it.
const desktop = new FakeSession('connected')
desktop.sendRequest.mockImplementation(async (method: string, ...rest: unknown[]) => {
const relayed = RelayedOptionsSchema.safeParse(rest[1]).data?.envelope.executionHost
const result =
method === MOBILE_RELAY_HOSTS_LIST_METHOD
? {
hosts: [
{ hostId: 'runtime:vm', label: 'scratch-vm', health: 'available', relay: 'ready' }
]
}
: method === 'repo.list'
? { repos: relayed === 'runtime:vm' ? [serverRepo] : repos }
: {}
return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
})
const listCalls = () =>
desktop.sendRequest.mock.calls.filter(([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD)
// The host list's own names: no server rows have loaded behind a sheet opened from Home.
const sheet = (routeVisible: boolean) =>
createElement(NewWorktreeModalController, {
routeVisible,
client: desktop,
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
hostId: 'host-1',
hostLabelById: new Map(),
openExternalUrl: () => {},
onCreated: () => {},
onRouteVisibleChange: () => {}
})
await act(async () => {
renderer = create(sheet(false))
})
await flushUpdates()
// Closed, it asks nothing of the desktop.
expect(listCalls()).toHaveLength(0)
await act(async () => renderer.update(sheet(true)))
await flushUpdates()
expect(pickerItems(renderer, 'Run on').map((item) => [item.label, item.detail])).toEqual([
[expect.any(String), '/src/orca'],
['scratch-vm', '/srv/orca']
])
})
it("lists the desktop's repos at once while a server has not answered", async () => {
const desktop = vi.fn((method: string) =>
method === 'repo.list'
? Promise.resolve({ ok: true, result: { repos } })
: new Promise(() => {})
)
const hungServer = vi.fn(() => new Promise(() => {}))
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client: requestsOnly(desktop),
serverClients: new Map([['runtime:env-1', requestsOnly(hungServer)]]),
hostId: 'host-cold',
onCreated: () => {},
onClose: () => {}
})
)
})
await flushUpdates()
expect(hungServer).toHaveBeenCalledWith('repo.list')
expect(pickerItems(renderer, 'Run on').map((item) => item.detail)).toEqual(['/src/orca'])
})
it('keeps a server repo picked when the desktop lists a repo with the same id', async () => {
const serverRepo = { ...repos[0]!, path: '/srv/orca' }
const listing = (listed: unknown[]) =>
vi.fn((method: string) =>
method === 'repo.list'
? Promise.resolve({ ok: true, result: { repos: listed } })
: new Promise(() => {})
)
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client: requestsOnly(listing(repos)),
serverClients: new Map([['runtime:env-1', requestsOnly(listing([serverRepo]))]]),
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
})
)
})
await flushUpdates()
const picker = runTargetPicker(renderer)
await act(async () => picker.props.onSelect(picker.props.items[1]))
await flushUpdates()
expect(runTargetPicker(renderer).props.selectedId).toBe('runtime:env-1')
expect(
renderer.root.find((node) => node.props.runTarget !== undefined).props.runTarget.detail
).toBe('/srv/orca')
})
it('ignores a stale repo list after the client changes', async () => {
let resolveOldList: ((value: unknown) => void) | undefined
const oldList = new Promise((resolve) => {
+36 -12
View File
@@ -1,12 +1,15 @@
import { useMemo, useState } from 'react'
import { Keyboard } from 'react-native'
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
import { useHostDescriptor } from '../transport/host-descriptor-store'
import { isSameMobileWorkspaceRepo } from '../worktree/new-workspace-dialog-repo-selection'
import {
buildRetiredWorktreeNamesRefreshKey,
useRetiredWorktreeNames
@@ -32,6 +35,12 @@ import { useNewWorkspaceRuntimeContext } from './use-new-workspace-runtime-conte
import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script'
import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
/** The server a repo runs on; undefined for the desktop's own (local, SSH, folder). */
function serverOf(repo: MobileWorkspaceRepo): `runtime:${string}` | undefined {
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
return host?.kind === 'runtime' ? host.id : undefined
}
export function NewWorktreeModal(props: NewWorktreeModalProps) {
// Why: each drawer opening is a fresh form session; remounting resets local
// form state before paint instead of clearing it in a visible-prop Effect.
@@ -57,8 +66,10 @@ export function NewWorktreeModal(props: NewWorktreeModalProps) {
function NewWorktreeModalContent(props: NewWorktreeModalProps) {
const {
visible,
client,
client: desktopClient,
serverClients,
hostId,
hostLabelById,
existingWorktreePaths,
existingWorktrees,
openExternalUrl,
@@ -66,16 +77,24 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
onClose
} = props
const { repos, selectedRepo, setSelectedRepo, loading } = useNewWorkspaceRepositories({
client,
client: desktopClient,
serverClients,
hostId,
visible
})
// Why: everything about the picked repo runs where it does; the routing table keeps the
// desktop's own concerns (settings, accounts) on the desktop.
const repoServer = selectedRepo ? serverOf(selectedRepo) : undefined
const client = repoServer ? (serverClients?.get(repoServer) ?? null) : desktopClient
const navigation = useNewWorktreeDrawerNavigation(visible)
const [note, setNote] = useState('')
const [error, setError] = useState('')
const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId)
const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } =
useNewWorktreeRuntimeCapabilities(client, visible)
const repoRuntime = useNewWorktreeRuntimeCapabilities(client, visible)
const { tasksSupported, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } = repoRuntime
// The desktop's own run target is named for the desktop, whichever host the picked repo is on.
const desktopPlatform = useHostDescriptor(hostId)?.platform ?? null
const hostPlatform = repoServer ? desktopPlatform : repoRuntime.hostPlatform
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
const executionTarget = useNewWorkspaceExecutionTarget({
client,
@@ -132,7 +151,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
getAgentLaunchSupport,
transitionDrawer: navigation.transitionDrawer,
setError,
onCreated,
onCreated: (worktreeId, name, warning) => onCreated(worktreeId, name, warning, repoServer),
onClose
})
@@ -145,25 +164,30 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
gitlabAvailable: runtime.availableProviders.includes('gitlab'),
linearAvailable: runtime.availableProviders.includes('linear')
}
// Why one client's: a source's repo switch is looked up through the picked repo's client.
const hostRepos = useMemo(
() => repos.filter((repo) => serverOf(repo) === repoServer),
[repoServer, repos]
)
const pasteRepos = useMemo<PasteRepoCandidate[]>(
() =>
repos.map((repo) => ({
hostRepos.map((repo) => ({
id: repo.id,
displayName: repo.displayName,
slug: deriveRepoSlug(repo)
})),
[repos]
[hostRepos]
)
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
const selectedProject =
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
const runTargetPickerItems = useMemo(
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform),
[hostPlatform, repos, selectedProjectId]
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById),
[hostLabelById, hostPlatform, repos, selectedProjectId]
)
const selectedRunTarget = selectedRepo
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform, hostLabelById)
: null
const needsSetupChoice = Boolean(setupScript.setupCommand) && setupScript.setupRunPolicy === 'ask'
const canCreate =
@@ -178,7 +202,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
}
function selectRepo(repo: MobileWorkspaceRepo, clearRepoScopedSource: boolean): void {
const repoChanged = repo.id !== selectedRepo?.id
const repoChanged = !selectedRepo || !isSameMobileWorkspaceRepo(repo, selectedRepo)
setSelectedRepo(repo)
if (
clearRepoScopedSource &&
@@ -247,7 +271,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
composer={composer}
sourceAvailability={sourceAvailability}
selectedRepo={selectedRepo}
repos={repos}
repos={hostRepos}
pasteRepos={pasteRepos}
sshReady={!executionTarget.sshGate.requiresConnection}
projectPickerItems={projectPickerItems}
@@ -1,22 +1,26 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { useReachableServerClients } from '../worktree/use-reachable-server-clients'
import { withServerHosts } from '../worktree/server-workspaces'
import { NewWorktreeModal } from './NewWorktreeModal'
import type { NewWorktreeModalProps } from './new-worktree-modal-types'
export type NewWorktreeModalControllerHandle = {
open: () => void
}
type Props = {
type Props = Omit<
NewWorktreeModalProps,
'visible' | 'onClose' | 'serverClients' | 'hostLabelById'
> & {
/** The desktop's capabilities, which say whether its servers are reachable through it. */
hostCapabilities: readonly string[]
/** The desktop's own host names; the sheet adds its servers' from the feed. */
hostLabelById: ReadonlyMap<ExecutionHostId, string>
routeVisible: boolean
client: RpcClient | null
hostId?: string
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
openExternalUrl: (url: string) => void
onVisibleChange?: (visible: boolean) => void
onRouteVisibleChange: (visible: boolean) => void
onCreated: (worktreeId: string, name: string, warning?: string) => void
}
export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerHandle, Props>(
@@ -24,7 +28,9 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
{
routeVisible,
client,
hostCapabilities,
hostId,
hostLabelById,
existingWorktreePaths,
existingWorktrees,
openExternalUrl,
@@ -36,6 +42,12 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
) {
const [manualVisible, setManualVisible] = useState(false)
const visible = routeVisible || manualVisible
// Why the feed, not the list's server rows: the list stops refreshing behind the sheet.
const { servers, serverClients } = useReachableServerClients(client, hostCapabilities, visible)
const serverLabelById = useMemo(
() => withServerHosts(hostLabelById, servers ?? [], (host) => host.label),
[hostLabelById, servers]
)
useImperativeHandle(
ref,
@@ -60,7 +72,9 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
<NewWorktreeModal
visible={visible}
client={client}
serverClients={serverClients}
hostId={hostId}
hostLabelById={serverLabelById}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
openExternalUrl={openExternalUrl}
@@ -1,9 +1,9 @@
import { View } from 'react-native'
import { Monitor } from 'lucide-react-native'
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors } from '../theme/mobile-theme'
import { HostKindIcon } from './HostKindIcon'
import { MobileAgentIcon } from './MobileAgentIcon'
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
@@ -27,6 +27,7 @@ export function NewWorktreeModalDrawers(props: {
composer: Composer
sourceAvailability: SmartModeAvailabilityInput
selectedRepo: MobileWorkspaceRepo | null
/** The picked repo's host's repos: a source can switch repos only within that host. */
repos: MobileWorkspaceRepo[]
pasteRepos: PasteRepoCandidate[]
sshReady: boolean
@@ -85,10 +86,10 @@ export function NewWorktreeModalDrawers(props: {
visible={props.visible && props.drawerView === 'runTarget'}
title="Run on"
items={props.runTargetPickerItems}
selectedId={props.selectedRepo?.id ?? ''}
selectedId={props.selectedRepo ? getRepoExecutionHostId(props.selectedRepo) : ''}
onSelect={(item) => props.onRepoChange(item.repo)}
onClose={props.onTransitionToForm}
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
renderIcon={(item) => <HostKindIcon hostId={item.hostId} size={16} />}
/>
<PickerListDrawer
@@ -1,5 +1,6 @@
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
import { ChevronDown, Monitor } from 'lucide-react-native'
import { ChevronDown } from 'lucide-react-native'
import { HostKindIcon } from './HostKindIcon'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Selection = { label: string; detail?: string }
@@ -12,7 +13,7 @@ export function NewWorktreeProjectTargetFields({
onOpenRunTarget
}: {
project: Selection | null
runTarget: Selection | null
runTarget: (Selection & { hostId: string }) | null
projectBadgeColor: string | null
onOpenProject: () => void
onOpenRunTarget: () => void
@@ -33,7 +34,7 @@ export function NewWorktreeProjectTargetFields({
<View style={styles.field}>
<Text style={styles.label}>Run on</Text>
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
<Monitor size={14} color={colors.textMuted} />
<HostKindIcon hostId={runTarget?.hostId} size={14} />
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
+3 -17
View File
@@ -1,21 +1,13 @@
import { memo } from 'react'
import {
Bell,
ChevronDown,
ChevronRight,
GitBranch,
GitPullRequest,
Monitor,
Server
} from 'lucide-react-native'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { parseExecutionHostId } from '../../../src/shared/execution-host'
import type { MobileRenderableRepoIcon } from '../host-screen/host-screen-reply-schema'
import { triggerMediumImpact } from '../platform/haptics'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { Worktree } from '../worktree/workspace-list-types'
import { areStructurallyEqual } from '../worktree/structural-equality'
import { AgentSpinner } from './AgentSpinner'
import { HostKindIcon } from './HostKindIcon'
import { MobileRepoIcon } from './MobileRepoIcon'
import { WorktreeAgentList } from './WorktreeAgentList'
import { WorktreeMetaGlyphs, prStateColor } from './WorktreeMetaGlyphs'
@@ -140,13 +132,7 @@ function WorktreeListRowComponent({
.filter(Boolean)
.join(', ')}
>
{/* Rows from hosts that predate hostId stamping are local: a remote row always carries one. */}
{(parseExecutionHostId(item.hostContextHostId ?? item.hostId)?.kind ?? 'local') ===
'local' ? (
<Monitor size={10} color={colors.textMuted} />
) : (
<Server size={10} color={colors.textMuted} />
)}
<HostKindIcon hostId={item.hostContextHostId ?? item.hostId} size={10} />
<Text style={[styles.childBadgeText, styles.hostBadgeText]} numberOfLines={1}>
{item.hostContextLabel}
</Text>
@@ -8,3 +8,12 @@ export const HostStatusGatesContext = createContext<HostStatusGates | null>(null
export function useOptionalHostProtocolGates(): HostStatusGates | null {
return useContext(HostStatusGatesContext)
}
/** The gates of the host route this screen sits under. */
export function useHostProtocolGates(): HostStatusGates {
const gates = useOptionalHostProtocolGates()
if (!gates) {
throw new Error('useHostProtocolGates must be used inside <HostProtocolGate>')
}
return gates
}
@@ -43,16 +43,16 @@ describe('new workspace project targets', () => {
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
})
it('labels local, SSH, and paired runtime targets', () => {
it('labels local, SSH, and paired runtime targets as the host list names them', () => {
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin')
).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
).toEqual({ hostId: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca' })
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' })
).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' })
).toMatchObject({ label: 'This computer' })
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32')
).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' })
).toMatchObject({ label: 'Local Windows' })
expect(
getNewWorkspaceRunTarget({
id: 'ssh',
@@ -60,15 +60,19 @@ describe('new workspace project targets', () => {
path: 'C:\\src\\orca',
executionHostId: 'ssh:Windows%20VM'
})
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({
id: 'runtime',
displayName: 'orca',
path: '/src/orca',
executionHostId: 'runtime:devbox'
})
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
).toEqual({ hostId: 'ssh:Windows%20VM', label: 'Windows VM', detail: 'C:\\src\\orca' })
const server = {
id: 'runtime',
displayName: 'orca',
path: '/src/orca',
executionHostId: 'runtime:20269ff3-60aa-4e79-a581-513a3f8e394b'
} as const
const labels = new Map([[server.executionHostId, 'scratch-server']] as const)
expect(getNewWorkspaceRunTarget(server, 'darwin', labels)).toEqual({
hostId: server.executionHostId,
label: 'scratch-server',
detail: '/src/orca'
})
})
it('shows one target per host when the project has multiple local worktrees', () => {
@@ -87,8 +91,12 @@ describe('new workspace project targets', () => {
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
expect.objectContaining({
id: 'ssh:build-server',
label: 'build-server',
detail: '/home/dev/orca'
})
])
})
})
@@ -1,14 +1,10 @@
import type { Repo } from '../../../src/shared/repo-types'
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
getRepoExecutionHostId,
parseExecutionHostId
} from '../../../src/shared/execution-host'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import {
getProjectIdentityKey,
getProjectProviderIdentity
} from '../../../src/shared/project-host-setup-projection'
import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels'
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
Partial<
@@ -24,6 +20,7 @@ export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
id: string
hostId: ExecutionHostId
label: string
detail: string
repo: TRepo
@@ -58,24 +55,23 @@ export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
})
}
/** Named like the host list's badge; the desktop's picker shows the bare host label too. */
export function getNewWorkspaceRunTarget(
repo: WorkspaceRepo,
localPlatform: NodeJS.Platform | null = null
localPlatform: NodeJS.Platform | null = null,
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
): {
hostId: ExecutionHostId
label: string
detail: string
} {
const hostId = getRepoExecutionHostId(repo)
const host = parseExecutionHostId(hostId)
const hostLabel = getExecutionHostLabel(hostId)
if (host?.kind === 'ssh') {
return { label: `SSH · ${hostLabel}`, detail: repo.path }
}
if (host?.kind === 'runtime') {
return { label: `Remote · ${hostLabel}`, detail: repo.path }
}
return {
label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer',
hostId,
label: getHostContextLabel(hostId, {
hostLabelById,
hostPlatform: localPlatform
}),
detail: repo.path
}
}
@@ -83,7 +79,8 @@ export function getNewWorkspaceRunTarget(
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectId: string | null,
localPlatform: NodeJS.Platform | null = null
localPlatform: NodeJS.Platform | null = null,
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
): NewWorkspaceRunTargetOption<TRepo>[] {
if (!projectId) {
return []
@@ -95,9 +92,10 @@ export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
}
const hostId = getRepoExecutionHostId(repo)
if (!options.has(hostId)) {
// Keyed by host: a repo id names one repo per host, so two hosts can share one.
options.set(hostId, {
id: repo.id,
...getNewWorkspaceRunTarget(repo, localPlatform),
id: hostId,
...getNewWorkspaceRunTarget(repo, localPlatform, hostLabelById),
repo
})
}
@@ -1,3 +1,4 @@
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
import type { RpcClient } from '../transport/rpc-client'
import type { SetupHookTrust } from '../tasks/setup-hook-trust'
@@ -20,11 +21,21 @@ export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'
export type NewWorktreeModalProps = {
visible: boolean
client: RpcClient | null
/** The desktop's servers this phone can reach now; their repos are offered and created there. */
serverClients?: ReadonlyMap<ExecutionHostId, RpcClient>
hostId?: string
/** The host list's badge labels, so a run target reads as its row does. */
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
openExternalUrl: (url: string) => void
onCreated: (worktreeId: string, name: string, warning?: string) => void
/** `executionHost` names the server the workspace was created on; absent for the desktop's own. */
onCreated: (
worktreeId: string,
name: string,
warning?: string,
executionHost?: ExecutionHostId
) => void
onClose: () => void
}
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { RpcClient } from '../transport/rpc-client'
import { nativeChatRepoListRead } from '../session/mobile-session-read-operations'
import { getCachedRepos, setCachedRepos } from '../cache/repo-cache'
import { listHostRepos } from '../worktree/server-repos'
import { useServerRepos } from '../worktree/use-server-repos'
import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo'
import {
getMobileNewWorkspaceDialogEligibleRepos,
@@ -10,8 +12,16 @@ import {
} from '../worktree/new-workspace-dialog-repo-selection'
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
const NO_SERVER_CLIENTS: ReadonlyMap<ExecutionHostId, RpcClient> = new Map()
/**
* The desktop's repos, then each reachable server's as it answers: a slow server never holds back
* the desktop's own list.
*/
export function useNewWorkspaceRepositories(args: {
client: RpcClient | null
/** The desktop's servers this phone can reach now, whose repos are offered too. */
serverClients?: ReadonlyMap<ExecutionHostId, RpcClient>
hostId?: string
visible: boolean
}): {
@@ -20,14 +30,27 @@ export function useNewWorkspaceRepositories(args: {
setSelectedRepo: (repo: MobileWorkspaceRepo | null) => void
loading: boolean
} {
const { client, hostId, visible } = args
const { client, serverClients = NO_SERVER_CLIENTS, hostId, visible } = args
const [initialRepos] = useState(() =>
hostId ? (getCachedRepos(hostId) as MobileWorkspaceRepo[] | null) : null
)
const [repos, setRepos] = useState<MobileWorkspaceRepo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<MobileWorkspaceRepo | null>(null)
const [desktopRepos, setDesktopRepos] = useState<MobileWorkspaceRepo[]>(initialRepos ?? [])
const serverRepos = useServerRepos<MobileWorkspaceRepo>(serverClients, visible)
const [pickedRepo, setSelectedRepo] = useState<MobileWorkspaceRepo | null>(null)
const [loading, setLoading] = useState(initialRepos == null)
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
const repos = useMemo(
() =>
serverRepos.size === 0
? desktopRepos
: [...desktopRepos, ...[...serverRepos.values()].flat()],
[desktopRepos, serverRepos]
)
// Why derived: the pick follows its repo's latest listing, and goes once its host stops listing it.
const selectedRepo = useMemo(
() => refreshMobileNewWorkspaceDialogSelectedRepo(repos, pickedRepo),
[pickedRepo, repos]
)
useEffect(() => {
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
@@ -50,25 +73,15 @@ export function useNewWorkspaceRepositories(args: {
}
let stale = false
setLoading(true)
void nativeChatRepoListRead
.request(client)
.then((response) => {
if (stale) {
void listHostRepos<MobileWorkspaceRepo>(client)
.then((listed) => {
if (stale || !listed) {
return
}
const listed = nativeChatRepoListRead.interpret(response)
if (!listed.accepted) {
return
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
const listedRepos = listed.value as MobileWorkspaceRepo[]
setRepos(listedRepos)
setDesktopRepos(listed)
if (hostId) {
setCachedRepos(hostId, listedRepos)
setCachedRepos(hostId, listed)
}
setSelectedRepo((current) =>
refreshMobileNewWorkspaceDialogSelectedRepo(listedRepos, current)
)
})
.catch(() => undefined)
.finally(() => {
@@ -1,4 +1,5 @@
import { Buffer } from 'buffer/index.js'
import { Buffer as NodeBuffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '../transport/types'
import {
@@ -76,14 +77,15 @@ describe('mobile media download', () => {
'files.readChunk',
'files.stat'
])
expect(r.sink.append).toHaveBeenNthCalledWith(
1,
Buffer.from(bytes.subarray(0, MOBILE_MEDIA_CHUNK_BYTES))
)
expect(r.sink.append).toHaveBeenNthCalledWith(
2,
Buffer.from(bytes.subarray(MOBILE_MEDIA_CHUNK_BYTES))
)
expect(r.sink.append.mock.calls.map((args) => args.length)).toEqual([1, 1])
const appended = r.sink.append.mock.calls.map(([value]) => {
if (!(value instanceof Uint8Array)) {
throw new Error('The sink received something other than bytes')
}
return NodeBuffer.from(value)
})
expect(appended.map((part) => part.length)).toEqual([MOBILE_MEDIA_CHUNK_BYTES, 3])
expect(NodeBuffer.concat(appended).equals(NodeBuffer.from(bytes))).toBe(true)
expect(r.progress).toHaveBeenLastCalledWith(bytes.length, bytes.length)
expect(r.sink.dispose).not.toHaveBeenCalled()
})
@@ -92,6 +92,17 @@ export const worktreePinWrite = bindDeferredRpcOperation(
})
)
/** Sleeping a row. Fire and forget: the row shows slept at once, and nothing reads the reply. */
export const worktreeSleep = bindDeferredRpcOperation(
defineRpcOperation({
name: 'worktree.sleep-or-skip',
method: 'worktree.sleep',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant('worktree-slept', hostScreenUnreadReplySchema)
})
)
/** Deleting a row. Only acceptance is read: a refusal is what puts the row back. */
export const worktreeRemove = bindDeferredRpcOperation(
defineRpcOperation({
@@ -0,0 +1,83 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
AppState: { currentState: 'active' },
Linking: { openURL: vi.fn() },
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('lucide-react-native', () => ({ Check: 'Check', Moon: 'Moon' }))
vi.mock('../agent-history/worktree-navigation-actions', () => ({
buildWorktreeNavigationActions: () => []
}))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetContent: 'ActionSheetContent' }))
vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' }))
vi.mock('../components/NewWorktreeModalController', () => ({
NewWorktreeModalController: 'NewWorktreeModalController'
}))
vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { Worktree } from '../worktree/workspace-list-types'
import { HostScreenOverlays } from './host-screen-overlays'
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
/** The long-press sheet's action labels for `row`. */
async function actionLabels(row: Partial<Worktree>): Promise<string[]> {
const controller = {
actions: {},
catalog: {},
client: new FakeSession('connected'),
connState: 'connected',
existingWorktreePaths: [],
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
hostId: 'host-1',
sectionsResult: { uniqueRepos: [] },
settings: { activeFilterCount: 0 },
showNewWorktree: false,
state: {
actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row },
confirmDelete: null,
filters: { filterRepoIds: new Set() },
hostLabelById: new Map(),
pinnedIds: new Set()
}
}
await act(async () => {
renderer = create(
createElement(HostScreenOverlays, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action sheet reads only these members; the drawers and pickers that read the rest are mocked.
controller: controller as unknown as Parameters<typeof HostScreenOverlays>[0]['controller']
})
)
})
const sheet = renderer!.root.find((node) => String(node.type) === 'ActionSheetContent')
return sheet.props.actions.map((action: { label: string }) => action.label)
}
describe('the workspace long-press sheet', () => {
it("offers Sleep for the desktop's own workspace", async () => {
expect(await actionLabels({})).toContain('Sleep')
})
it("does not offer Sleep for a server's workspace, which the desktop would sleep by its own id", async () => {
expect(await actionLabels({ worktreeId: 'runtime:vm-wt', hostId: 'runtime:vm' })).toEqual([
'Pin',
'Delete'
])
})
})
+21 -20
View File
@@ -2,6 +2,7 @@ import { openExternalLink } from '../platform/external-link'
import { Pressable, Text, View } from 'react-native'
import { Check, Moon } from 'lucide-react-native'
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
import { workspaceRouteHref } from '../navigation/workspace-execution-host'
import { useWorkspaceServer } from '../transport/workspace-server'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
@@ -10,7 +11,6 @@ import { NewWorktreeModalController } from '../components/NewWorktreeModalContro
import { PickerModal } from '../components/PickerModal'
import { colors } from '../theme/mobile-theme'
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS,
@@ -179,23 +179,19 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
navigate: actions.navigateFromHostList,
onDone: () => state.setActionTarget(null)
}),
{
label: 'Sleep',
icon: Moon,
onPress: () => {
if (client) {
state.setSleptIds((prev) =>
new Set(prev).add(getWorktreeRowIdentity(actionTarget))
)
void client
.sendRequest('worktree.sleep', {
worktree: `id:${actionTarget.worktreeId}`
})
.catch(() => null)
}
state.setActionTarget(null)
}
},
// Server sleep is not offered yet; the desktop would sleep its own id.
...(actionServer.server
? []
: [
{
label: 'Sleep',
icon: Moon,
onPress: () => {
actions.putWorktreeToSleep(actionTarget)
state.setActionTarget(null)
}
}
]),
{
label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin',
onPress: () => {
@@ -230,7 +226,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
ref={state.newWorktreeModalRef}
routeVisible={showNewWorktree}
client={client}
hostCapabilities={hostCapabilities}
hostId={hostId}
hostLabelById={state.hostLabelById}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={state.worktrees}
// The seam, not react-native's `Linking`: this screen is in the tasks page closure, and
@@ -239,10 +237,13 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
onVisibleChange={(visible) => {
state.newWorktreeModalVisibleRef.current = visible
}}
onCreated={(worktreeId, worktreeName, warning) => {
onCreated={(worktreeId, worktreeName, warning, executionHost) => {
void catalog.fetchWorktrees({ allowDuringModal: true })
actions.navigateFromHostList(
hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName, warning)
workspaceRouteHref(
hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName, warning),
executionHost
)
)
}}
onRouteVisibleChange={actions.setShowNewWorktreeVisible}
@@ -19,6 +19,10 @@ vi.mock('../transport/use-worktree-resync', () => ({
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { createBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
import {
recordHostDescriptor,
resetHostDescriptorStoreForTests
} from '../transport/host-descriptor-store'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
@@ -99,6 +103,8 @@ type Screen = {
notice: () => string | null
catalogError: () => string | null
pin: (worktreeId: string) => void
sleep: (worktreeId: string) => void
slept: () => ReadonlySet<string> | undefined
serverWorkspaces: () => unknown
swapClient: (next: RpcClient) => Promise<void>
navigations: string[]
@@ -145,7 +151,7 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis
})
held.state = state
held.fetch = catalog.fetchWorktrees
held.rows = useHostDisplayWorktrees('connected', state)
held.rows = useHostDisplayWorktrees('connected', state, 'host-1')
return null
}
await act(async () => {
@@ -174,6 +180,13 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis
act(() => {
held.actions?.togglePin(held.rows.find((entry) => entry.worktreeId === worktreeId)!)
}),
sleep: (worktreeId) =>
act(() => {
held.actions?.putWorktreeToSleep(
held.rows.find((entry) => entry.worktreeId === worktreeId)!
)
}),
slept: () => held.state?.sleptIds,
serverWorkspaces: () => held.state?.serverWorkspaces,
swapClient: async (next) => {
client = next
@@ -206,6 +219,19 @@ describe("a desktop's server workspaces on the phone", () => {
])
})
it("names the desktop's own row for the desktop's platform once servers mix the list", async () => {
recordHostDescriptor('host-1', { machineName: null, platform: 'darwin' })
try {
const screen = await mountScreen(desktop(), RELAYS)
expect(screen.rows()[0]).toMatchObject({
worktreeId: 'mac-wt',
hostContextLabel: 'Local Mac'
})
} finally {
resetHostDescriptorStoreForTests()
}
})
it('keeps showing today’s view under an older desktop or an older shell', async () => {
const oldDesktop = desktop()
const oldShell = desktop({ carriesRpcEnvelope: false })
@@ -339,6 +365,14 @@ describe("a desktop's server workspaces on the phone", () => {
expect(screen.rows().find((entry) => entry.worktreeId === 'runtime:vm-wt')?.isPinned).toBe(true)
})
it('sleeps a desktop workspace on the desktop and shows it slept at once', async () => {
const client = desktop()
const screen = await mountScreen(client, RELAYS)
screen.sleep('mac-wt')
expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { worktree: 'id:mac-wt' })
expect(screen.slept()?.size).toBe(1)
})
it('reads a replaced client\u2019s servers at once, and keeps an unchanged poll\u2019s list', async () => {
const stuck = desktop()
const base = stuck.sendRequest.getMockImplementation()!
@@ -69,7 +69,7 @@ async function displayedRows(args: {
state.clientRef.current = client
setWorktrees = state.setWorktrees
seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state })
seen.rows = useHostDisplayWorktrees('connected', state)
seen.rows = useHostDisplayWorktrees('connected', state, 'host-1')
return null
}
await act(async () => {
@@ -1,14 +1,18 @@
import { useMemo } from 'react'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { useHostDescriptor } from '../transport/host-descriptor-store'
import type { ConnectionState } from '../transport/types'
import { serverHostHealth } from '../worktree/server-workspaces'
import { serverHostHealth, withServerHosts } from '../worktree/server-workspaces'
import { filterVisibleHostRows } from '../worktree/visible-host-rows'
import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
import type { HostScreenState } from './use-host-screen-state'
/** The rows the host list renders: the live or last-known catalog with display state and host badges. */
export function useHostDisplayWorktrees(connState: ConnectionState, state: HostScreenState) {
export function useHostDisplayWorktrees(
connState: ConnectionState,
state: HostScreenState,
hostId: string | undefined
) {
const { serverWorkspaces } = state
// Why: a server's label and health come with its rows, alongside the desktop's SSH ones.
const hostLabelById = useMemo(
@@ -19,6 +23,9 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS
() => withServerHosts(state.hostHealthById, serverWorkspaces.hosts, serverHostHealth),
[state.hostHealthById, serverWorkspaces.hosts]
)
const descriptorPlatform = useHostDescriptor(hostId)?.platform ?? null
// Why: metadata reads the platform only for a mixed desktop catalog; server rows mix it too.
const hostPlatform = state.hostPlatform ?? descriptorPlatform
return useMemo(() => {
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
@@ -33,7 +40,7 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS
{
repoHostIdByRepoId: state.repoHostIdByRepoId,
hostLabelById,
hostPlatform: state.hostPlatform,
hostPlatform,
hostHealthById
}
)
@@ -47,22 +54,7 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS
state.optimisticActiveWorktreeIdentity,
state.repoHostIdByRepoId,
hostLabelById,
state.hostPlatform,
hostPlatform,
hostHealthById
])
}
function withServerHosts<Value>(
desktop: ReadonlyMap<ExecutionHostId, Value>,
hosts: HostScreenState['serverWorkspaces']['hosts'],
read: (host: HostScreenState['serverWorkspaces']['hosts'][number]) => Value
): ReadonlyMap<ExecutionHostId, Value> {
if (hosts.length === 0) {
return desktop
}
const merged = new Map(desktop)
for (const host of hosts) {
merged.set(host.hostId, read(host))
}
return merged
}
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { useLocalSearchParams, usePathname } from 'expo-router'
import { useRouteHandoff } from '../navigation/route-handoff'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { visibleHostRouteNotice } from '../host-route-notice'
import { resolveHostRouteActionState } from '../host-route-action-state'
import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll'
@@ -103,7 +103,7 @@ export function useHostScreenController({
}
const showNewWorktree = resolvedRouteActionState.showNewWorktree
const displayWorktrees = useHostDisplayWorktrees(connState, state)
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
const sectionsResult = useWorkspaceSections({
displayWorktrees,
sortMode: state.sortMode,
@@ -20,7 +20,12 @@ import {
removeWorktreeRow
} from '../worktree/worktree-host-row-identity'
import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections'
import { worktreeActivate, worktreePinWrite, worktreeRemove } from './host-screen-operations'
import {
worktreeActivate,
worktreePinWrite,
worktreeRemove,
worktreeSleep
} from './host-screen-operations'
import type { HostScreenState } from './use-host-screen-state'
export function useHostWorktreeActions(args: {
@@ -60,6 +65,7 @@ export function useHostWorktreeActions(args: {
setRouteActionState,
setServerNotice,
setServerWorkspaces,
setSleptIds,
setWorktrees,
worktrees
} = state
@@ -153,6 +159,17 @@ export function useHostWorktreeActions(args: {
[client, clientForRow, worktrees, pinnedIds, updateLocalPins]
)
const putWorktreeToSleep = useCallback(
(item: Worktree) => {
if (!client) {
return
}
setSleptIds((prev) => new Set(prev).add(getWorktreeRowIdentity(item)))
void worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }).catch(() => null)
},
[client, setSleptIds]
)
const handleDeleteWorktree = useCallback(
async (item: Worktree) => {
const rowClient = clientForRow(item)
@@ -279,6 +296,7 @@ export function useHostWorktreeActions(args: {
openNewWorktreeModal,
openWorktreeSession,
setShowNewWorktreeVisible,
putWorktreeToSleep,
togglePin
}
}
@@ -53,7 +53,9 @@ vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths:
vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
encodeBase64Url: () => 'session-id'
}))
vi.mock('../components/HostProtocolGate', () => ({ useHostProtocolGates: () => doubles.gates }))
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({
useHostClient: () => ({ client: {}, state: 'connected' })
}))
@@ -51,7 +51,9 @@ vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths:
vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
encodeBase64Url: () => 'session-id'
}))
vi.mock('../components/HostProtocolGate', () => ({ useHostProtocolGates: () => doubles.gates }))
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/rpc-operation', () => ({
defineRpcOperation: (definition: unknown) => definition,
@@ -46,7 +46,9 @@ vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths:
vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
encodeBase64Url: () => 'session-id'
}))
vi.mock('../components/HostProtocolGate', () => ({ useHostProtocolGates: () => doubles.gates }))
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/rpc-operation', () => ({
defineRpcOperation: (definition: unknown) => definition,
@@ -71,7 +71,9 @@ vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths:
vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
encodeBase64Url: () => 'session-id'
}))
vi.mock('../components/HostProtocolGate', () => ({ useHostProtocolGates: () => doubles.gates }))
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/rpc-operation', () => ({
defineRpcOperation: (definition: unknown) => definition,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { useHostClient } from '../transport/client-context'
import type { GenerationStore } from './generation-store'
import { deriveHostCacheKey } from './host-cache-key'
@@ -9,6 +9,16 @@ export function useWorkspaceExecutionHost(): ExecutionHostId | undefined {
return useContext(WorkspaceExecutionHostContext)
}
/**
* Whether this workspace runs on the paired desktop itself. Gates actions whose only result is an
* editor tab; for everything else the host's reply decides where it goes.
* A phone paired directly with a headless host still reads true, matching main's behaviour there.
*/
export function useWorkspaceOnPairedHost(): boolean {
// Why the route, not a host signal: no host advertises a desktop window, and a server workspace's calls go to its own host.
return useWorkspaceExecutionHost() === undefined
}
/** The params a push into another screen of this workspace carries, so it stays on the server. */
export function workspaceRouteParams(executionHost: ExecutionHostId | undefined): {
executionHost?: ExecutionHostId
@@ -77,3 +77,17 @@ it('preserves the originating pane in the workspace route', () => {
?.sessionTarget?.params
).toEqual({ hostId: 'host', worktreeId: 'folder:/work', paneKey })
})
it('scopes a tap to a server workspace, and keeps anything else on the desktop', () => {
const paramsFor = (executionHost: unknown) =>
getNotificationNavigationTarget({ hostId: 'host', worktreeId: 'repo::/srv', executionHost })
?.sessionTarget?.params
expect(paramsFor('runtime:env-1')).toEqual({
hostId: 'host',
worktreeId: 'repo::/srv',
executionHost: 'runtime:env-1'
})
for (const notAServer of [undefined, '', 'local', 'ssh:box', 'runtime:', 'env-1', 42]) {
expect(paramsFor(notAServer)).toEqual({ hostId: 'host', worktreeId: 'repo::/srv' })
}
})
@@ -1,3 +1,4 @@
import { parseExecutionHostId } from '../../../src/shared/execution-host'
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
import { mobileSessionRouteTarget } from '../session/mobile-session-route'
import type { HostCredentialStatus } from '../transport/types'
@@ -46,6 +47,8 @@ export function getNotificationNavigationTarget(
}
const worktreeId = readNonEmptyString(record.worktreeId)
// Only a server the desktop relays to; anything else opens the desktop's own workspace, as before.
const executionHost = parseExecutionHostId(readNonEmptyString(record.executionHost))
const credentialStatus = options.credentialStatusByHostId?.get(hostId)
return {
hostId,
@@ -53,7 +56,8 @@ export function getNotificationNavigationTarget(
? mobileSessionRouteTarget({
hostId,
worktreeId,
paneKey: readNonEmptyString(record.paneKey) ?? undefined
paneKey: readNonEmptyString(record.paneKey) ?? undefined,
...(executionHost?.kind === 'runtime' ? { executionHost: executionHost.id } : {})
})
: null,
...(credentialStatus === 'missing'
@@ -1,12 +1,13 @@
import { AppState } from 'react-native'
let viewing: { hostId: string; worktreeId: string } | null = null
// `executionHost` is the route's server; absent for the desktop's own workspaces.
let viewing: { hostId: string; worktreeId: string; executionHost?: string } | null = null
export function setNotificationViewingWorkspace(value: typeof viewing): void {
viewing = value
}
export function shouldSuppressNotificationWhileViewing(
event: { worktreeId?: string },
event: { worktreeId?: string; executionHost?: string },
hostId: string,
suppressWhileViewing: boolean
): boolean {
@@ -14,6 +15,7 @@ export function shouldSuppressNotificationWhileViewing(
suppressWhileViewing &&
AppState.currentState === 'active' &&
viewing?.hostId === hostId &&
viewing.worktreeId === event.worktreeId
viewing.worktreeId === event.worktreeId &&
viewing.executionHost === event.executionHost
)
}
+3 -1
View File
@@ -9,6 +9,7 @@ export type OrcaPushPayload = {
readonly notificationEpoch?: string
readonly paneKey?: string
readonly worktreeId?: string
readonly executionHost?: string
}
function readString(value: unknown): string | undefined {
@@ -38,6 +39,7 @@ export function readOrcaPushPayload(data: unknown): OrcaPushPayload | null {
notificationSeq: readSeq(record.notificationSeq),
notificationEpoch: readString(record.notificationEpoch),
paneKey: readString(record.paneKey),
worktreeId: readString(record.worktreeId)
worktreeId: readString(record.worktreeId),
executionHost: readString(record.executionHost)
}
}
@@ -177,6 +177,26 @@ describe('pushNotificationRouteData', () => {
})
})
it('opens a server workspace on its server, from either provider shape', () => {
for (const providerData of [apnsData, fcmData]) {
const data = pushNotificationRouteData(
providerData({
hostFingerprint,
worktreeId: 'repo::/srv/feature',
executionHost: 'runtime:env-1',
source: 'agent-task-complete'
}),
hosts,
true
)
expect(getNotificationNavigationTarget(data)?.sessionTarget?.params).toEqual({
hostId: 'host-1',
worktreeId: 'repo::/srv/feature',
executionHost: 'runtime:env-1'
})
}
})
it('maps a push without a worktree to the host screen', () => {
const data = pushNotificationRouteData(
fcmData({ hostFingerprint, source: 'terminal-bell' }),
@@ -297,3 +317,27 @@ it('allows the viewed workspace after backgrounding during eligibility reads', a
await expect(eligibility).resolves.toBe(true)
await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(false)
})
it('silences only the viewed workspace on its own host, desktop or server', async () => {
AppState.currentState = 'active'
let seq = 0
const present = (executionHost?: string) =>
canPresentForegroundPush({
hostFingerprint,
worktreeId: 'repo::/work',
...(executionHost ? { executionHost } : {}),
notificationId: `viewing-${++seq}`,
notificationEpoch: 'epoch',
notificationSeq: seq
})
setNotificationViewingWorkspace({ hostId: 'host-1', worktreeId: 'repo::/work' })
await expect(present()).resolves.toBe(false)
await expect(present('runtime:env-1')).resolves.toBe(true)
setNotificationViewingWorkspace({
hostId: 'host-1',
worktreeId: 'repo::/work',
executionHost: 'runtime:env-1'
})
await expect(present('runtime:env-1')).resolves.toBe(false)
await expect(present()).resolves.toBe(true)
})
+2 -1
View File
@@ -147,6 +147,7 @@ export function pushNotificationRouteData(
return {
hostId,
...(payload.paneKey ? { paneKey: payload.paneKey } : {}),
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {})
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}),
...(payload.executionHost ? { executionHost: payload.executionHost } : {})
}
}
@@ -6,7 +6,7 @@ import { normalizeReviewAreaParam } from './mobile-diff-review-positioning'
import { useMobileDiffReviewController } from './use-mobile-diff-review-controller'
import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
/**
@@ -0,0 +1,114 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold'
const platform = vi.hoisted(() => ({ OS: 'web' }))
vi.mock('react-native', () => ({ Platform: platform }))
type Row = {
isConnected: boolean
children: Row[]
getBoundingClientRect: () => { top: number; bottom: number }
}
/** A row 100 tall whose top is `tops[0]` when first measured and `tops[1]` from then on. */
function row(tops: number[], children: Row[] = []): Row {
const answers = [...tops]
return {
isConnected: true,
children,
getBoundingClientRect: () => {
const top = answers.length > 1 ? (answers.shift() ?? 0) : answers[0]
return { top, bottom: top + 100 }
}
}
}
/** A 600 tall view, 40 from the top of its content, over the given header block and list rows. */
function scrollerOver(headerRows: Row[], listRows: Row[]) {
const header = row([-40], headerRows)
const heights = [1_000, 3_500]
const node = {
scrollTop: 40,
// Grows by 2,500 where 2,000 lands above the reader: live output lands below in the same update.
get scrollHeight(): number {
return heights.length > 1 ? (heights.shift() ?? 0) : heights[0]
},
getBoundingClientRect: () => ({ top: 0, bottom: 600 }),
firstElementChild: row([-40], [header, ...listRows])
}
return { node, listRef: { current: { getScrollableNode: () => node } } }
}
describe('MobileNativeChatScrollHold', () => {
let renderer: ReactTestRenderer | null = null
async function show(headKey: string, listRef: ReturnType<typeof scrollerOver>['listRef']) {
const element = createElement(MobileNativeChatScrollHold, { headKey, listRef })
await act(async () => {
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
platform.OS = 'web'
})
it('moves by what landed above the reader, not by live output landing below it too', async () => {
const onScreen = row([300, 2_300])
const { node, listRef } = scrollerOver([], [row([100, 2_100]), onScreen, row([700, 3_200])])
await show('a', listRef)
await show('older', listRef)
expect(node.scrollTop).toBe(2_040)
})
it('holds a reader who is inside the loaded history, above the list’s own rows', async () => {
const control = row([-40])
const { node, listRef } = scrollerOver([control, row([200, 2_200])], [row([900, 2_900])])
await show('a', listRef)
await show('older', listRef)
expect(node.scrollTop).toBe(2_040)
})
it('leaves the view alone when the row it was holding is gone', async () => {
const onScreen = row([300, 2_300])
const { node, listRef } = scrollerOver([], [onScreen])
await show('a', listRef)
onScreen.isConnected = false
await show('older', listRef)
expect(node.scrollTop).toBe(40)
})
it('leaves the view alone while the first row is the same', async () => {
const { node, listRef } = scrollerOver([], [row([300, 2_300])])
await show('a', listRef)
await show('a', listRef)
expect(node.scrollTop).toBe(40)
})
it('does nothing in a native build, where the list holds its own place', async () => {
platform.OS = 'ios'
const { node, listRef } = scrollerOver([], [row([300, 2_300])])
await show('a', listRef)
await show('older', listRef)
expect(node.scrollTop).toBe(40)
})
})
@@ -0,0 +1,70 @@
import { Component, type RefObject } from 'react'
import { Platform } from 'react-native'
type Box = { top: number; bottom: number }
/** The parts of a DOM element this reads; on the page the list's scrollable node is one. */
type RowElement = {
isConnected: boolean
getBoundingClientRect: () => Box
children: ArrayLike<RowElement>
}
type Scroller = {
scrollTop: number
getBoundingClientRect: () => Box
firstElementChild: RowElement | null
}
type Held = { row: RowElement; top: number }
type Props = {
/** The first row's key: a change in it is rows changing above the reader. */
headKey: string | undefined
listRef: RefObject<{ getScrollableNode: () => Scroller | null | undefined } | null>
}
/** The lowest row on screen. Why the lowest: the "Load earlier" control stays at the top while
* history lands under it, so the topmost element does not move with the rows. */
function lowestRowOnScreen(scroller: Scroller): Held | null {
const content = scroller.firstElementChild
if (!content) {
return null
}
const view = scroller.getBoundingClientRect()
// The list's own rows, then the rows of the header block that precedes them.
const header = content.children[0]
for (const rows of [content.children, header?.children ?? []]) {
for (let at = rows.length - 1; at >= 0; at -= 1) {
const box = rows[at].getBoundingClientRect()
if (box.top < view.bottom && box.bottom > view.top && rows[at] !== header) {
return { row: rows[at], top: box.top }
}
}
}
return null
}
/** Keeps the page's view on the same rows when rows above it change, by putting a row that was on
* screen back where it was. A class because only `getSnapshotBeforeUpdate` sees that row before
* the new ones are in the document. A native list holds its own place
* (`maintainVisibleContentPosition`). */
export class MobileNativeChatScrollHold extends Component<Props> {
getSnapshotBeforeUpdate(previous: Props): Held | null {
if (Platform.OS !== 'web' || previous.headKey === this.props.headKey) {
return null
}
const scroller = this.props.listRef.current?.getScrollableNode()
return scroller ? lowestRowOnScreen(scroller) : null
}
componentDidUpdate(_previous: Props, _state: unknown, held: Held | null): void {
const scroller = held?.row.isConnected ? this.props.listRef.current?.getScrollableNode() : null
if (held && scroller) {
scroller.scrollTop += held.row.getBoundingClientRect().top - held.top
}
}
render(): null {
return null
}
}
+129 -20
View File
@@ -6,6 +6,7 @@ import { MobileNativeChatView } from './MobileNativeChatView'
const scrollToEnd = vi.hoisted(() => vi.fn())
const scrollToOffset = vi.hoisted(() => vi.fn())
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('react-native', async () => {
const React = await import('react')
@@ -15,6 +16,7 @@ vi.mock('react-native', async () => {
React.useImperativeHandle(ref, () => ({ scrollToEnd, scrollToOffset }), [])
return React.createElement('FlatList', props)
}),
Platform: platform,
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
@@ -593,30 +595,23 @@ describe('MobileNativeChatView', () => {
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_200 })
})
it('keeps a history load detached after its triggering drag settles', async () => {
it('resumes following when the drag that paged in history ends at the tail', async () => {
vi.useFakeTimers()
try {
// A chat shorter than the screen: its top and its tail are the same place.
const shortChat = {
contentOffset: { y: 0 },
contentSize: { height: 400 },
layoutMeasurement: { height: 500 }
}
const folded = [assistantTurn('a1', 'Short history')]
const onLoadEarlier = vi.fn()
await render({ folded, hasMore: true, onLoadEarlier })
scrollToEnd.mockClear()
act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll({
nativeEvent: {
contentOffset: { y: 0 },
contentSize: { height: 400 },
layoutMeasurement: { height: 500 }
}
})
list().props.onScrollEndDrag?.({
nativeEvent: {
contentOffset: { y: 0 },
contentSize: { height: 400 },
layoutMeasurement: { height: 500 }
}
})
list().props.onScroll({ nativeEvent: shortChat })
list().props.onScrollEndDrag?.({ nativeEvent: shortChat })
})
await act(async () => {
vi.advanceTimersByTime(200)
@@ -624,14 +619,128 @@ describe('MobileNativeChatView', () => {
act(() => list().props.onContentSizeChange(320, 950))
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 950 })
expect(
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
).toHaveLength(0)
} finally {
vi.useRealTimers()
}
})
it('pages in history only from a scroll the reader made', async () => {
const onLoadEarlier = vi.fn()
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
const nearTop = {
nativeEvent: {
contentOffset: { y: 40 },
contentSize: { height: 520 },
layoutMeasurement: { height: 500 }
}
}
// The tail pin of a chat barely taller than the screen reports an offset near the top too.
act(() => list().props.onScroll(nearTop))
expect(onLoadEarlier).not.toHaveBeenCalled()
act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll(nearTop)
})
expect(onLoadEarlier).toHaveBeenCalledOnce()
})
it('anchors the first visible row, which the header above it is not', async () => {
const folded = [assistantTurn('a1', 'History')]
await render({ folded, hasMore: true })
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 1 })
await update({ folded, hasMore: false })
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 })
})
describe('on the page, where the list reports no drag or momentum', () => {
beforeEach(() => {
platform.OS = 'web'
})
afterEach(() => {
platform.OS = 'ios'
})
function scrolledTo(y: number, contentHeight: number) {
return {
nativeEvent: {
contentOffset: { y },
contentSize: { height: contentHeight },
layoutMeasurement: { height: 500 }
}
}
}
it('stops following when the reader scrolls up, and resumes at the end', async () => {
await render({ folded: [assistantTurn('a1', 'Latest')] })
act(() => {
list().props.onContentSizeChange(320, 1_000)
list().props.onScroll(scrolledTo(500, 1_000))
})
scrollToOffset.mockClear()
// No drag event arrives on the page: the move up is all there is to go on.
act(() => {
list().props.onScroll(scrolledTo(300, 1_000))
list().props.onContentSizeChange(320, 1_200)
})
expect(scrollToOffset).not.toHaveBeenCalled()
expect(
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
).toHaveLength(1)
} finally {
vi.useRealTimers()
}
act(() => {
list().props.onScroll(scrolledTo(700, 1_200))
list().props.onContentSizeChange(320, 1_400)
})
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_400 })
})
it('does not take its own pin, landing short of a grown end, for the reader', async () => {
await render({ folded: [assistantTurn('a1', 'Latest')] })
act(() => {
list().props.onContentSizeChange(320, 1_000)
list().props.onScroll(scrolledTo(500, 1_000))
})
scrollToOffset.mockClear()
// The pin reached the end it was sent to; the reply had grown 400 past it by then.
act(() => {
list().props.onScroll(scrolledTo(700, 1_600))
list().props.onContentSizeChange(320, 1_600)
})
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_600 })
})
it('pins a resized viewport to the measured end, never the list estimate', async () => {
await render({ folded: [assistantTurn('a1', 'Latest')] })
act(() => list().props.onContentSizeChange(320, 1_000))
scrollToEnd.mockClear()
act(() => list().props.onLayout?.({}))
expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_000 })
})
it('pages in history when the reader scrolls up to the top, not when a pin passes it', async () => {
const onLoadEarlier = vi.fn()
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
act(() => list().props.onScroll(scrolledTo(40, 540)))
expect(onLoadEarlier).not.toHaveBeenCalled()
act(() => list().props.onScroll(scrolledTo(10, 540)))
expect(onLoadEarlier).toHaveBeenCalledOnce()
})
})
it('routes an accepted send through the immediate nonanimated tail owner', async () => {
+20 -12
View File
@@ -27,8 +27,12 @@ import {
mobileNativeChatEmptyState,
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatPageHistory } from './use-mobile-native-chat-page-history'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow'
import {
mobileNativeChatRowAnchor,
useMobileNativeChatTailFollow
} from './use-mobile-native-chat-tail-follow'
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
import {
mobileNativeChatComposerPlaceholder,
@@ -45,6 +49,7 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold'
import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
@@ -243,7 +248,7 @@ export function MobileNativeChatView({
endUserDrag,
beginMomentum,
endMomentum,
detachFromTail,
readerIsScrolling,
recordScrollMetrics
} = useMobileNativeChatTailFollow<NativeChatMessage>({ hasItems: data.length > 0 })
@@ -263,21 +268,18 @@ export function MobileNativeChatView({
[onSend, onClearSendError, jumpToTail]
)
const loadEarlier = useCallback(() => {
detachFromTail()
onLoadEarlier?.()
}, [detachFromTail, onLoadEarlier])
const loadEarlier = useCallback(() => onLoadEarlier?.(), [onLoadEarlier])
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset } = e.nativeEvent
recordScrollMetrics(e.nativeEvent)
// Near the top — page in older history.
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
// Near the top — page in older history. Only the reader's scroll: a pin reports offsets too.
if (contentOffset.y < 60 && hasMore && !loadingEarlier && readerIsScrolling()) {
loadEarlier()
}
},
[hasMore, loadingEarlier, loadEarlier, recordScrollMetrics]
[hasMore, loadingEarlier, loadEarlier, readerIsScrolling, recordScrollMetrics]
)
// Per-turn status rows: one live indicator while the turn runs, then a settled
@@ -319,6 +321,8 @@ export function MobileNativeChatView({
[toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns, unsentNotices]
)
const history = useMobileNativeChatPageHistory(turns.listMessages, renderItem)
const liveStatus = turns.liveLine ? (
<MobileNativeChatLiveLine
line={turns.liveLine}
@@ -357,12 +361,13 @@ export function MobileNativeChatView({
</View>
) : (
<GestureHandlerRootView style={styles.listWrap}>
<MobileNativeChatScrollHold headKey={turns.listMessages[0]?.id} listRef={listRef} />
<GestureDetector gesture={pinchGesture}>
<FlatList
ref={listRef}
data={turns.listMessages}
data={history.listRows}
keyExtractor={(item) => item.id}
renderItem={renderItem}
renderItem={history.renderListRow}
contentContainerStyle={styles.listContent}
// Let link/file taps land while the composer keyboard is up
// instead of being swallowed by the dismiss gesture.
@@ -375,7 +380,10 @@ export function MobileNativeChatView({
scrollEventThrottle={32}
onContentSizeChange={pinToTailAfterContentResize}
onLayout={pinToTail}
ListHeaderComponent={mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier)}
maintainVisibleContentPosition={mobileNativeChatRowAnchor(hasMore === true)}
ListHeaderComponent={history.withHistory(
mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier)
)}
ListFooterComponent={mobileNativeChatListFooter(
liveStatus,
turns.waitingRows,
@@ -15,6 +15,7 @@ vi.mock('react-native', async () => {
React.useImperativeHandle(ref, () => ({ scrollToEnd, scrollToOffset }), [])
return React.createElement('FlatList', props)
}),
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
@@ -13,6 +13,7 @@ vi.mock('react-native', async () => {
React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), [])
return React.createElement('FlatList', props)
}),
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
@@ -0,0 +1,80 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
import type { ActionSheetAction } from '../components/ActionSheetModal'
const sheets = vi.hoisted(() => ({ labelsByTitle: new Map<string, string[]>() }))
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('lucide-react-native', () => ({
Copy: 'Icon',
FileText: 'Icon',
Globe: 'Icon',
RefreshCw: 'Icon',
SquareTerminal: 'Icon'
}))
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({}) }))
vi.mock('../platform/haptics', () => ({ triggerSuccess: () => {}, triggerError: () => {} }))
vi.mock('../components/ActionSheetModal', () => ({
ActionSheetModal: ({ title, actions }: { title: string; actions: ActionSheetAction[] }) => {
sheets.labelsByTitle.set(
title,
actions.map((action) => action.label)
)
return null
}
}))
vi.mock('../components/TextInputModal', () => ({ TextInputModal: () => null }))
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: () => null }))
vi.mock('../components/CustomKeyModal', () => ({ CustomKeyModal: () => null }))
vi.mock('../components/MobileDictationSetupSheet', () => ({
MobileDictationSetupSheet: () => null
}))
vi.mock('./MobileSessionHeaderMoreActionsSheet', () => ({
MobileSessionHeaderMoreActionsSheet: () => null
}))
vi.mock('./QuickCommandsSheet', () => ({ QuickCommandsSheet: () => null }))
vi.mock('./MobileBrowserTabActionSheet', () => ({ MobileBrowserTabActionSheet: () => null }))
vi.mock('./mobile-terminal-action-sheet-actions', () => ({
getMobileTerminalActionSheetActions: () => []
}))
import { MobileSessionSheets } from './MobileSessionSheets'
async function newTabLabels(executionHost?: string): Promise<string[]> {
sheets.labelsByTitle.clear()
const controller = mountFixture<Parameters<typeof MobileSessionSheets>[0]['controller']>({
worktreeId: 'repo::/wt',
isFolderWorkspaceRoute: false,
isFloatingWorkspaceRoute: false,
sessionTabs: [],
nativeChatController: { isTabChatView: () => false },
createTabAgentActions: [],
sendDiffNotesAgentActions: [],
closeWithBulkActions: () => []
})
await act(async () => {
create(
createElement(
WorkspaceExecutionHostContext.Provider,
{ value: normalizeExecutionHostId(executionHost) ?? undefined },
createElement(MobileSessionSheets, { controller })
)
)
})
return sheets.labelsByTitle.get('New Tab') ?? []
}
describe('the New Tab sheet', () => {
it('offers Markdown Note for a workspace the desktop hosts', async () => {
expect(await newTabLabels()).toContain('Markdown Note')
})
it('leaves out Markdown Note for a server workspace, whose host opens no editor tabs', async () => {
const labels = await newTabLabels('runtime:vm')
expect(labels).toEqual(['Terminal', 'Browser'])
})
})
+14 -8
View File
@@ -9,6 +9,7 @@ import { TextInputModal } from '../components/TextInputModal'
import { ConfirmModal } from '../components/ConfirmModal'
import { CustomKeyModal } from '../components/CustomKeyModal'
import { MobileDictationSetupSheet } from '../components/MobileDictationSetupSheet'
import { useWorkspaceOnPairedHost } from '../navigation/workspace-execution-host'
import { MobileBrowserTabActionSheet } from './MobileBrowserTabActionSheet'
import { getMobileTerminalActionSheetActions } from './mobile-terminal-action-sheet-actions'
import {
@@ -91,6 +92,7 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
showChecksAction
} = controller
const clipboard = useClipboardWriter()
const onPairedHost = useWorkspaceOnPairedHost()
return (
<>
<MobileSessionHeaderMoreActionsSheet
@@ -144,14 +146,18 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
setShowCreateBrowserModal(true)
}
},
{
label: 'Markdown Note',
icon: FileText,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
...(onPairedHost
? [
{
label: 'Markdown Note',
icon: FileText,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
]
: [])
])
]}
onClose={() => setShowCreateTabDrawer(false)}
@@ -664,4 +664,86 @@ describe('openMobileFileTap', () => {
expect(switchSessionTab).not.toHaveBeenCalled()
})
async function tapOnHostWithNoDesktopWindow(options: { leaveDuringOpen: boolean }) {
let latestActivationSeq = 1
const client = {
sendRequest: vi.fn(async (method: string) => {
if (method !== 'files.open') {
return ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
})
}
if (options.leaveDuringOpen) {
latestActivationSeq = 2
}
return { ok: false, error: { code: 'runtime_error', message: 'renderer_unavailable' } }
})
}
const pushPreviewRoute = vi.fn()
const onOpenFailed = vi.fn()
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
worktreeName: 'api',
pathText: 'src/index.ts',
line: null,
column: null,
pushPreviewRoute,
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => 'terminal-tab',
getActivationState: (activated) => ({
...activeTerminalState(activated),
latestActivationSeq
}),
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
onOpenFailed
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(client.sendRequest).toHaveBeenCalledWith(
'files.open',
{ worktree: 'id:wt-1', relativePath: 'src/index.ts' },
{ timeoutMs: 15_000 }
)
expect(onOpenFailed).not.toHaveBeenCalled()
return pushPreviewRoute
}
it('previews a tapped worktree file when the host has no desktop window', async () => {
const pushPreviewRoute = await tapOnHostWithNoDesktopWindow({ leaveDuringOpen: false })
expect(pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'worktree',
relativePath: 'src/index.ts',
worktreeName: 'api'
})
})
})
it('does not push the preview once the user has moved on during the open call', async () => {
const pushPreviewRoute = await tapOnHostWithNoDesktopWindow({ leaveDuringOpen: true })
expect(pushPreviewRoute).not.toHaveBeenCalled()
})
})
+17 -5
View File
@@ -7,6 +7,7 @@ import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
import { classifyMobileArtifact } from './mobile-artifact-kind'
import { fileTapOpenRun, fileTapPathResolve } from './mobile-session-launch-operations'
import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
import { isRendererUnavailableReply } from '../transport/renderer-unavailable-reply'
import type { RpcOperationSender } from '../transport/rpc-operation-sender'
export type FileTapSessionTab = {
@@ -146,11 +147,7 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
return
}
options.triggerOpenFeedback()
if (
resolvedWorktreeId !== options.worktreeId ||
options.line !== null ||
options.column !== null
) {
const pushWorktreePreview = (): void =>
options.pushPreviewRoute(
createMobileFilePreviewHref({
hostId: options.hostId,
@@ -163,6 +160,12 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
...(resolvedWorktreeName ? { worktreeName: resolvedWorktreeName } : {})
})
)
if (
resolvedWorktreeId !== options.worktreeId ||
options.line !== null ||
options.column !== null
) {
pushWorktreePreview()
return
}
if (
@@ -178,6 +181,15 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
{ worktree: resolvedWorktree, relativePath: openedPath },
{ timeoutMs: 15_000 }
)
// Why: a host with no desktop window opens no editor tabs; the preview route reads the file through files.read.
if (isRendererUnavailableReply(openResponse)) {
// Not a failure: the user moved off the source tab during the call.
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
return
}
pushWorktreePreview()
return
}
const opened = fileTapOpenRun.interpret(openResponse)
if (!opened.accepted) {
reportOpenFailure(options)
@@ -1,13 +1,9 @@
import { isRendererUnavailableReply } from '../transport/renderer-unavailable-reply'
import type { RpcFailure } from '../transport/types'
import type { MarkdownDocState } from './mobile-session-route-types'
const RENDERER_UNAVAILABLE = 'renderer_unavailable'
export function shouldReadMarkdownFromDiskAfterReadTabFailure(response: RpcFailure): boolean {
return (
response.error.code === RENDERER_UNAVAILABLE ||
(response.error.code === 'runtime_error' && response.error.message === RENDERER_UNAVAILABLE)
)
return isRendererUnavailableReply(response)
}
export function buildMarkdownDiskFallbackDoc(args: {
@@ -37,6 +37,7 @@ vi.mock('react-native', async () => {
React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), [])
return React.createElement('FlatList', props)
}),
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1, absoluteFillObject: {} },
Text: 'Text',
@@ -91,6 +92,7 @@ const HOST_SUPPORT = {
queuedCommands: false,
statusFeed: true,
quietRepeatedStop: true,
sendOptions: false,
conversationStop: false
}
@@ -8,6 +8,7 @@ import {
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities'
import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
describe('structuredAgentSessionHostSupport', () => {
@@ -19,6 +20,7 @@ describe('structuredAgentSessionHostSupport', () => {
queuedCommands: false,
quietRepeatedStop: false,
statusFeed: false,
sendOptions: false,
conversationStop: false
}
expect(structuredAgentSessionHostSupport([])).toEqual(none)
@@ -40,6 +42,9 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY])
).toEqual({ ...none, statusFeed: true })
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY])
).toEqual({ ...none, sendOptions: true })
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY])
).toEqual({ ...none, conversationStop: true })
@@ -7,6 +7,7 @@ import {
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities'
/** Structured-session features the connected host advertised; null until the status probe answers. */
export type StructuredAgentSessionHostSupport = {
@@ -21,6 +22,8 @@ export type StructuredAgentSessionHostSupport = {
statusFeed: boolean
/** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */
quietRepeatedStop: boolean
/** A send may carry the model and effort its turn runs on. */
sendOptions: boolean
/** A Stop naming no turn stops what the chat has in flight; an older host needs the turn. */
conversationStop: boolean
}
@@ -35,6 +38,7 @@ export function structuredAgentSessionHostSupport(
queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY),
statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY),
sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY),
conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY)
}
}
@@ -20,6 +20,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
attachments: readonly StructuredAgentSessionAttachment[]
/** Sent only when the host advertises `agent-session.queued-messages.v1`. */
delivery?: 'queue-if-active'
/** Sent only when the host advertises `agent-session.send-options.v1`. */
options?: Readonly<Record<string, string>>
deadline?: number
/** The id the host records the message under. */
clientMessageId: string
@@ -39,7 +41,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
expectedRuntimeFence: input.expectedRuntimeFence,
fields: {
body: structuredAgentSessionSendBody(input.text, input.attachments),
...(input.delivery ? { delivery: input.delivery } : {})
...(input.delivery ? { delivery: input.delivery } : {}),
...(input.options ? { options: input.options } : {})
},
clientOperationId: input.clientMessageId,
timeoutMs
@@ -0,0 +1,95 @@
import { createElement, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useMobileNativeChatPageHistory } from './use-mobile-native-chat-page-history'
const platform = vi.hoisted(() => ({ OS: 'web' }))
vi.mock('react-native', () => ({ Platform: platform }))
type Item = { id: string }
type History = ReturnType<typeof useMobileNativeChatPageHistory<Item>>
const rows = (...ids: string[]): Item[] => ids.map((id) => ({ id }))
const renderRow = vi.fn(({ item, index }: { item: Item; index: number }): ReactElement =>
createElement('Row', { id: item.id, index })
)
describe('useMobileNativeChatPageHistory', () => {
let renderer: ReactTestRenderer | null = null
let history: History | null = null
function Harness({ items }: { items: Item[] }): null {
history = useMobileNativeChatPageHistory(items, renderRow)
return null
}
async function show(items: Item[]): Promise<void> {
await act(async () => {
if (renderer) {
renderer.update(createElement(Harness, { items }))
} else {
renderer = create(createElement(Harness, { items }))
}
})
}
/** What the list header draws below the control it was given. */
function headerRows(): { id: string; index: number }[] {
const drawn: { header?: ReactTestRenderer } = {}
act(() => {
drawn.header = create(
createElement('Header', null, history?.withHistory(createElement('Control')))
)
})
const found = drawn.header?.root.findAll((node) => typeof node.props.index === 'number') ?? []
return found.map((row) => ({ id: row.props.id, index: row.props.index }))
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
history = null
platform.OS = 'web'
})
it('draws history that loads above the list in its header, leaving the list its rows', async () => {
await show(rows('a', 'b'))
expect(history?.listRows).toEqual(rows('a', 'b'))
expect(headerRows()).toEqual([])
await show(rows('x', 'y', 'a', 'b'))
// RN Web's list draws by index: handing it the new rows would redraw a different set.
expect(history?.listRows).toEqual(rows('a', 'b'))
expect(headerRows()).toEqual([
{ id: 'x', index: 0 },
{ id: 'y', index: 1 }
])
// The list's first row is the third row of the chat, and is drawn as that.
history?.renderListRow({ item: { id: 'a' }, index: 0 })
expect(renderRow).toHaveBeenLastCalledWith({ item: { id: 'a' }, index: 2 })
})
it('gives the list every row again once the row it started at is gone', async () => {
await show(rows('a', 'b'))
await show(rows('x', 'a', 'b'))
await show(rows('p', 'q'))
expect(history?.listRows).toEqual(rows('p', 'q'))
expect(headerRows()).toEqual([])
})
it('leaves a native list all its rows: it holds its own place', async () => {
platform.OS = 'ios'
await show(rows('a', 'b'))
const all = rows('x', 'y', 'a', 'b')
await show(all)
expect(history?.listRows).toBe(all)
expect(history?.renderListRow).toBe(renderRow)
expect(headerRows()).toEqual([])
})
})
@@ -0,0 +1,63 @@
import { Fragment, useCallback, useMemo, useState, type ReactElement } from 'react'
import { Platform } from 'react-native'
const NO_ROWS: readonly never[] = []
type Row<TItem> = { item: TItem; index: number }
/** RN Web's list draws rows by index, so rows added at the front push the ones a reader is on out
* of what it draws. On the page, rows that arrive above the list's first row go in its header
* instead: the list's own rows keep their indexes and stay drawn. */
export function useMobileNativeChatPageHistory<TItem extends { id: string }>(
rows: readonly TItem[],
renderRow: (row: Row<TItem>) => ReactElement
): {
listRows: readonly TItem[]
renderListRow: (row: Row<TItem>) => ReactElement
/** The list header: what was given, then the history loaded above the list's own rows. */
withHistory: (header: ReactElement | null) => ReactElement | null
} {
const onPage = Platform.OS === 'web'
// The row the list starts at, for as long as that row exists.
const [headId, setHeadId] = useState<string | null>(null)
const headIndex = useMemo(
() => (onPage && headId !== null ? rows.findIndex((row) => row.id === headId) : -1),
[onPage, headId, rows]
)
const firstId = rows[0]?.id
if (onPage && headIndex === -1 && firstId !== undefined && firstId !== headId) {
setHeadId(firstId)
}
const historyCount = Math.max(headIndex, 0)
const historyRows = useMemo(
() => (historyCount === 0 ? NO_ROWS : rows.slice(0, historyCount)),
[historyCount, rows]
)
const listRows = useMemo(
() => (historyCount === 0 ? rows : rows.slice(historyCount)),
[historyCount, rows]
)
const renderOffsetRow = useCallback(
(row: Row<TItem>) => renderRow({ item: row.item, index: row.index + historyCount }),
[renderRow, historyCount]
)
const withHistory = useCallback(
(header: ReactElement | null) =>
historyRows.length === 0 ? (
header
) : (
<>
{header}
{historyRows.map((item, index) => (
<Fragment key={item.id}>{renderRow({ item, index })}</Fragment>
))}
</>
),
[historyRows, renderRow]
)
return {
listRows,
renderListRow: historyCount === 0 ? renderRow : renderOffsetRow,
withHistory
}
}
@@ -198,8 +198,8 @@ describe('useMobileNativeChatSession', () => {
expect(state?.messages).toHaveLength(100)
expect(state?.messages[0]?.id).toBe('paged-0')
// A replay carrying one message that arrived while away merges it in; the
// grown window stays bounded, so only the single oldest row trims.
// A replay carrying one message that arrived while away merges it in, and
// nothing the phone already shows is dropped to make room.
await act(async () =>
emit({
type: 'snapshot',
@@ -208,13 +208,12 @@ describe('useMobileNativeChatSession', () => {
beforeOffset: 100
})
)
expect(state?.messages).toHaveLength(100)
expect(state?.messages[0]?.id).toBe('paged-1')
expect(state?.messages).toHaveLength(101)
expect(state?.messages[0]?.id).toBe('paged-0')
expect(state?.messages.at(-1)?.id).toBe('live-1')
})
it('enables paging when a replay trims a window that previously had no earlier rows', async () => {
// Never settles: this asserts the request the replay's cursor produces.
it('keeps the rows it shows when a replay window has moved past them', async () => {
const sendRequest = vi.fn(() => new Promise<never>(() => {}))
const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
@@ -224,6 +223,7 @@ describe('useMobileNativeChatSession', () => {
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
// The host's newest window now starts one row later; its `hasMore` is about that window.
await act(async () =>
emit({
type: 'snapshot',
@@ -233,14 +233,11 @@ describe('useMobileNativeChatSession', () => {
})
)
expect(state?.messages[0]?.id).toBe('win-1')
expect(state?.hasMore).toBe(true)
act(() => state?.loadEarlier())
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
})
expect(state?.messages.map((entry) => entry.id)).toEqual([
...window.map((entry) => entry.id),
'live-1'
])
expect(state?.hasMore).toBe(false)
})
it('drops paged rows that an authoritative replay says were removed', async () => {
@@ -381,48 +378,65 @@ describe('useMobileNativeChatSession', () => {
expect(state?.messages.map((entry) => entry.id)).toEqual(['early-b', 'base-c'])
})
it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => {
let resolveCursorPage: (response: unknown) => void = () => {}
it('keeps every row it shows while live output outgrows the first window', async () => {
let resolvePage: (response: unknown) => void = () => {}
const sendRequest = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve)))
.mockResolvedValueOnce({
.mockImplementationOnce(() => new Promise((resolve) => (resolvePage = resolve)))
.mockImplementation(() => new Promise<never>(() => {}))
const window = Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`))
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 })
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
const shown = window.map((entry) => entry.id)
// A reader scrolled up is looking at these rows; a busy turn must not push them out.
act(() => state?.loadEarlier())
await act(async () => emit({ type: 'appended', messages: [message('live-1')] }))
expect(state?.messages.map((entry) => entry.id)).toEqual([...shown, 'live-1'])
// The page asked for before that output still lands ahead of the rows it was read behind.
await act(async () => {
resolvePage({
ok: true,
result: { messages: [message('fresh-growing-tail')], hasMore: false }
result: { messages: [message('older-page')], hasMore: true, beforeOffset: 50 }
})
await Promise.resolve()
})
expect(state?.messages.map((entry) => entry.id)).toEqual(['older-page', ...shown, 'live-1'])
})
it('pages a growing tail from what the phone holds, not from the first window', async () => {
// Never settles: this asserts the request an older runtime, which has no cursor, is sent.
const sendRequest = vi.fn(() => new Promise<never>(() => {}))
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
hasMore: true
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () => emit({ type: 'appended', messages: [message('live-trim')] }))
await act(async () => {
resolveCursorPage({
ok: true,
result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 }
await act(async () =>
emit({
type: 'appended',
messages: Array.from({ length: 30 }, (_unused, index) => message(`live-${index}`))
})
await Promise.resolve()
})
expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page')
)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
act(() => state?.loadEarlier())
// 70 rows held plus one page: a read of 100 would come back shorter than the list it replaces.
expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
limit: 130
})
expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail'])
})
})
@@ -175,7 +175,8 @@ export function useMobileNativeChatSession(args: {
const applied = applyMobileNativeChatStreamFrame({
merger: mergerRef.current,
frame,
limit: limitRef.current,
// Why not the read window: a busy turn outruns it, dropping rows a scrolled-up reader is on.
limit: MAX_MESSAGES,
replaceSnapshot: !snapshotSeenRef.current
})
if (applied.kind === 'ignored') {
@@ -238,8 +239,10 @@ export function useMobileNativeChatSession(args: {
// apply this read's result onto the new session (mirrors desktop's guard).
const requestSessionId = sessionId
const requestGeneration = streamGenerationRef.current
const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES)
const pageLimit = nextLimit - limitRef.current
// Live output grows the list past the last read, so the next page counts from what is held.
const held = Math.max(limitRef.current, mergerRef.current.list.length)
const nextLimit = Math.min(held + PAGE, MAX_MESSAGES)
const pageLimit = nextLimit - held
if (pageLimit <= 0) {
setHasMore(false)
return
@@ -1,12 +1,38 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
import type { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
import {
Platform,
type FlatList,
type NativeScrollEvent,
type NativeSyntheticEvent
} from 'react-native'
/** Distance from the bottom, in points, still treated as "at the tail". */
const AT_TAIL_SLOP = 80
function isAtTail(event: NativeScrollEvent): boolean {
/** RN Web's ScrollView reports no drag or momentum, so on the page the offsets themselves have to
* say what the reader did. It ignores `maintainVisibleContentPosition` too. */
function listReportsGestures(): boolean {
return Platform.OS !== 'web'
}
/** Offset change, in points, below which the page treats the view as not having moved. */
const PAGE_MOVE_SLOP = 1
/** Holds the first visible row in place when rows above it change. The header is not a row. */
const ANCHOR_BELOW_HEADER = { minIndexForVisible: 1 } as const
const ANCHOR_AT_FIRST_ROW = { minIndexForVisible: 0 } as const
export function mobileNativeChatRowAnchor(hasHeader: boolean): { minIndexForVisible: number } {
return hasHeader ? ANCHOR_BELOW_HEADER : ANCHOR_AT_FIRST_ROW
}
function distanceFromTail(event: NativeScrollEvent): number {
const { contentOffset, contentSize, layoutMeasurement } = event
return contentSize.height - (contentOffset.y + layoutMeasurement.height) <= AT_TAIL_SLOP
return contentSize.height - (contentOffset.y + layoutMeasurement.height)
}
function isAtTail(event: NativeScrollEvent): boolean {
return distanceFromTail(event) <= AT_TAIL_SLOP
}
export type MobileNativeChatTailFollow<TItem> = {
@@ -24,8 +50,8 @@ export type MobileNativeChatTailFollow<TItem> = {
endUserDrag: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
beginMomentum: () => void
endMomentum: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
/** Leave the tail deliberately, e.g. before prepending older history. */
detachFromTail: () => void
/** Whether a scroll near the top is the reader's own, and so may page in older history. */
readerIsScrolling: () => boolean
recordScrollMetrics: (event: NativeScrollEvent) => void
}
@@ -55,6 +81,10 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
const atTailRef = useRef(true)
const userScrollActiveRef = useRef(false)
const userScrollSettleFrameRef = useRef<number | null>(null)
// Page only: what the offsets last said, in place of the gestures RN Web does not report.
const pageOffsetRef = useRef(0)
const pageContentHeightRef = useRef(0)
const pageReaderMovedUpRef = useRef(false)
// Single writer, so the event-time ref and the render flag cannot disagree.
const setFollowing = useCallback((next: boolean) => {
@@ -77,11 +107,17 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
if (!followingRef.current || !hasItems) {
return
}
listRef.current?.scrollToEnd({ animated: false })
if (listReportsGestures()) {
listRef.current?.scrollToEnd({ animated: false })
return
}
// Why not `scrollToEnd`: its estimate can land short, and the page reads a move up as the reader's.
listRef.current?.scrollToOffset({ animated: false, offset: pageContentHeightRef.current })
}, [hasItems])
const pinToTailAfterContentResize = useCallback(
(_width: number, height: number) => {
pageContentHeightRef.current = height
if (!followingRef.current || !hasItems) {
return
}
@@ -98,8 +134,25 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
}, [])
const recordScrollMetrics = useCallback(
(event: NativeScrollEvent) => setAtTail(isAtTail(event)),
[setAtTail]
(event: NativeScrollEvent) => {
const endsAtTail = isAtTail(event)
setAtTail(endsAtTail)
if (listReportsGestures()) {
return
}
const moved = event.contentOffset.y - pageOffsetRef.current
pageOffsetRef.current = event.contentOffset.y
pageReaderMovedUpRef.current = moved < -PAGE_MOVE_SLOP
if (followingRef.current) {
// A pin only moves toward the end and a clamp lands on it: up and short of it is the reader.
if (pageReaderMovedUpRef.current && distanceFromTail(event) > PAGE_MOVE_SLOP) {
setFollowing(false)
}
} else if (moved > PAGE_MOVE_SLOP && endsAtTail) {
setFollowing(true)
}
},
[setAtTail, setFollowing]
)
const jumpToTail = useCallback(() => {
@@ -159,12 +212,10 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
[finishUserScroll]
)
const detachFromTail = useCallback(() => {
clearUserScrollSettle()
userScrollActiveRef.current = false
setAtTail(false)
setFollowing(false)
}, [clearUserScrollSettle, setAtTail, setFollowing])
const readerIsScrolling = useCallback(
() => (listReportsGestures() ? userScrollActiveRef.current : pageReaderMovedUpRef.current),
[]
)
useEffect(() => clearUserScrollSettle, [clearUserScrollSettle])
@@ -178,7 +229,7 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
endUserDrag,
beginMomentum,
endMomentum,
detachFromTail,
readerIsScrolling,
recordScrollMetrics
}
}
@@ -16,7 +16,7 @@ import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
import { useLiveWorktreeName } from './use-live-worktree-name'
import { useMissingWorktreeBounce } from './use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../host-route-notice'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
export function useMobileSessionFoundation() {
const {
@@ -48,7 +48,8 @@ async function mount(initial: Props) {
sessionId: 'session-1',
enabled: true,
fence: props.fence,
mutate
mutate,
takesSendOptions: true
})
frames.push(modelChoices(options.optionSnapshot))
return null
@@ -59,7 +59,8 @@ async function mountOptions(props: ProbeProps) {
enabled: true,
fence: probe.fence,
mutate,
optionStamp: probe.optionStamp
optionStamp: probe.optionStamp,
takesSendOptions: true
})
return null
}
@@ -157,7 +157,8 @@ function Probe(props: ProbeProps): null {
sessionId: props.sessionId,
enabled: true,
fence: props.fence,
mutate: props.mutate
mutate: props.mutate,
takesSendOptions: true
})
)
return null
@@ -7,7 +7,6 @@ import type {
AgentSessionOptionsResult
} from '../../../src/shared/agent-session-wire'
import type {
SessionOptionDescriptor,
SessionOptionsSurface,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
@@ -18,9 +17,12 @@ import {
commitStructuredAgentSessionOptionValues,
createStructuredAgentSessionOptionState,
structuredAgentSessionOptionPicks,
structuredAgentSessionOptionSnapshot,
type StructuredAgentSessionOptionState
} from '../../../src/shared/structured-agent-session-options'
import {
structuredAgentSessionOptionView,
structuredAgentSessionShownSnapshot
} from '../../../src/shared/structured-agent-session-option-view'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode'
import type { RpcClient } from '../transport/rpc-client'
import {
@@ -35,18 +37,6 @@ import {
} from './mobile-created-structured-sessions'
import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec'
type StructuredOptionsController = {
optionPickerRequest: { id: string; sequence: number } | null
conversationCommands: readonly AgentSessionConversationCommand[]
/** The chat takes images, as its options report says; else only Claude and Codex, as before. */
acceptsImages: boolean
optionSnapshot: SessionOptionDescriptor[]
optionSurface: SessionOptionsSurface
pendingOptionId: string | null
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeStructuredOption: (id: string) => Promise<boolean>
}
export function useMobileStructuredAgentOptions(args: {
agent: string | null
client: RpcClient | null
@@ -56,8 +46,9 @@ export function useMobileStructuredAgentOptions(args: {
mutate: StructuredAgentSessionMutate
/** Moves when the host's status says a session option changed; each move re-reads them. */
optionStamp?: string
}): StructuredOptionsController {
const { agent, client, enabled, fence, mutate, optionStamp, sessionId } = args
takesSendOptions: boolean
}) {
const { agent, client, enabled, fence, mutate, optionStamp, sessionId, takesSendOptions } = args
// Every agent's seed, as on the desktop: a built-in list or the provider-default pill.
const optionCatalog = useMemo(
() => (agent ? structuredAgentSessionSeedCatalog(agent) : null),
@@ -173,8 +164,8 @@ export function useMobileStructuredAgentOptions(args: {
])
const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
[optionState]
() => structuredAgentSessionShownSnapshot(optionState, takesSendOptions),
[optionState, takesSendOptions]
)
const setStructuredOption = useCallback(
@@ -187,7 +178,11 @@ export function useMobileStructuredAgentOptions(args: {
!sessionId ||
!optionCatalog ||
encoded === null ||
!canSetStructuredAgentSessionOption(currentState, id, value)
!canSetStructuredAgentSessionOption(
structuredAgentSessionOptionView(currentState, undefined, {}, takesSendOptions),
id,
value
)
) {
return false
}
@@ -275,7 +270,7 @@ export function useMobileStructuredAgentOptions(args: {
}
}
},
[agent, client, mutate, optionCatalog, sessionId, updateOptionState]
[agent, client, mutate, optionCatalog, sessionId, takesSendOptions, updateOptionState]
)
const invokeStructuredOption = useCallback(
@@ -292,9 +287,11 @@ export function useMobileStructuredAgentOptions(args: {
const setOption = useCallback(
async (id: string, value: SessionOptionValue) => {
await setStructuredOption(id, value)
return { snapshot: structuredAgentSessionOptionSnapshot(optionStateRef.current) }
return {
snapshot: structuredAgentSessionShownSnapshot(optionStateRef.current, takesSendOptions)
}
},
[setStructuredOption]
[setStructuredOption, takesSendOptions]
)
const optionSurface = useMemo<SessionOptionsSurface>(
@@ -311,6 +308,7 @@ export function useMobileStructuredAgentOptions(args: {
optionPickerRequest,
conversationCommands:
conversationSupport?.sessionId === sessionId ? conversationSupport.commands : [],
/** The chat takes images, as its options report says; else only Claude and Codex, as before. */
acceptsImages:
(imageSupport?.sessionId === sessionId ? imageSupport.acceptsImages : undefined) ??
(agent !== null && isAgentSessionHandleProvider(agent)),
@@ -125,6 +125,7 @@ function Harness({
queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false,
sendOptions: false,
conversationStop: false
},
onSendError: vi.fn()
@@ -19,6 +19,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = {
queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false,
sendOptions: false,
conversationStop: false
}
export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false }
@@ -9,6 +9,11 @@ import { fieldsOf, ok } from './use-mobile-structured-agent-session-queued.test-
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities'
import {
structuredAgentSessionHostSupport,
type StructuredAgentSessionHostSupport
} from './mobile-structured-agent-session-host-support'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
import {
@@ -66,6 +71,7 @@ describe('mobile structured send actions', () => {
let listener: ((value: unknown) => void) | null = null
let storedOperations: Map<string, string>
let agent = 'codex'
let hostSupport: StructuredAgentSessionHostSupport | null = null
const onSendError = vi.fn()
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const subscribe = vi.fn<RpcClient['subscribe']>((_method, _params, onData) => {
@@ -94,7 +100,7 @@ describe('mobile structured send actions', () => {
enabled: true,
connected: true,
agent,
hostSupport: null,
hostSupport,
onSendError
})
return createElement('span', null, banner.message)
@@ -146,6 +152,95 @@ describe('mobile structured send actions', () => {
banner = null
listener = null
agent = 'codex'
hostSupport = null
})
it.each([
['carries the shown model and effort to a host that takes them', true],
['sends the old shape to a host that does not', false]
])('%s', async (_name, takesOptions) => {
agent = 'claude'
hostSupport = structuredAgentSessionHostSupport(
takesOptions ? [AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY] : []
)
const high = [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send'
? sendResult('accepted')
: method === 'agentSession.options'
? ok({
models: [{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts: high }],
current: { model: 'opus[1m]', effort: 'high' }
})
: ok({})
)
await mountSession()
await act(async () => {
await vi.waitFor(() =>
expect(sendRequest.mock.calls.some(([method]) => method === 'agentSession.options')).toBe(
true
)
)
})
await act(async () => {
expect(await hook!.sendWithOutcome('hello')).toBe('accepted')
expect(await hook!.sendWithOutcome('again')).toBe('accepted')
})
for (const [, params] of calls()) {
if (takesOptions) {
expect(fieldsOf(params).options).toEqual({ model: 'opus[1m]', effort: 'high' })
} else {
expect(fieldsOf(params)).not.toHaveProperty('options')
}
}
})
it('takes an effort the replacement it shows offers, and sends on that replacement', async () => {
agent = 'claude'
hostSupport = structuredAgentSessionHostSupport([AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY])
const efforts = [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
sendRequest.mockImplementation(async (method) =>
method === 'agentSession.send'
? sendResult('accepted')
: method === 'agentSession.modelCatalog'
? ok({
origin: 'probe',
fetchedAt: 1,
models: [{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts }]
})
: method === 'agentSession.options'
? ok({
// A stopped chat lists the host's rows and its own model.
models: [
{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts },
{ id: 'claude-gone', label: 'claude-gone', isDefault: false, efforts: [] }
],
current: { model: 'claude-gone', effort: 'high' }
})
: ok({})
)
await mountSession()
await vi.waitFor(async () => {
await act(async () => {})
expect(
hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model')?.kind
).toMatchObject({ currentValue: 'opus[1m]' })
})
await act(async () => {
await hook!.setStructuredOption('effort', 'low')
})
const picks = sendRequest.mock.calls.filter(([method]) => method === 'agentSession.setOption')
expect(picks.map(([, params]) => fieldsOf(params).value)).toEqual(['low'])
await act(async () => {
expect(await hook!.sendWithOutcome('hello')).toBe('accepted')
})
expect(fieldsOf(calls()[0]![1]).options).toMatchObject({ model: 'opus[1m]' })
})
it("sends a command the chat's own agent runs as a message, though Codex's host claims it", async () => {
@@ -106,7 +106,8 @@ export function useMobileStructuredAgentSession(args: {
enabled,
fence: state.fence,
mutate,
optionStamp
optionStamp,
takesSendOptions: hostSupport?.sendOptions === true
})
const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } =
options
@@ -193,7 +194,8 @@ export function useMobileStructuredAgentSession(args: {
hold: sends.hold,
commandPending: commandPendingRef,
controller: sendController,
onSendError
onSendError,
carriesOptions: hostSupport?.sendOptions === true
})
const turnIndicator = useMemo(
() => ({
@@ -20,6 +20,7 @@ import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-age
import type { MobileStructuredSends } from './use-mobile-structured-sends'
import { structuredSessionOperationId } from './structured-session-operation-id'
import { timeoutForDeadline } from './mobile-structured-agent-session-rpc'
import { structuredAgentSessionSendSelection } from '../../../src/shared/structured-agent-session-option-view'
import {
pendingStructuredApproval,
pendingStructuredQuestion
@@ -57,6 +58,8 @@ export function useMobileStructuredSendWithOutcome(args: {
'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands'
>
onSendError: MobileNativeChatSendErrorReporter
/** Each send carries the model and effort the picker shows; an older host takes neither. */
carriesOptions?: boolean
}): (
text: string,
images?: string[],
@@ -74,6 +77,7 @@ export function useMobileStructuredSendWithOutcome(args: {
hold,
onSendError,
queueCapable,
carriesOptions,
sessionId,
stateRef,
stopping
@@ -138,6 +142,11 @@ export function useMobileStructuredSendWithOutcome(args: {
queuedAt: Date.now(),
...(stopping ? { sentWhileStopping: true as const } : {})
}
// A pick on its way holds the send, so the picker shows what the chat holds.
const options =
carriesOptions && agent
? structuredAgentSessionSendSelection(agent, controller.snapshot)
: undefined
const outcome = await sendMobileStructuredAgentSessionMessage({
client,
sessionId,
@@ -147,6 +156,7 @@ export function useMobileStructuredSendWithOutcome(args: {
...(mobileStructuredSendQueues(queueCapable, stateRef.current.items)
? { delivery: 'queue-if-active' as const }
: {}),
...(options ? { options } : {}),
deadline,
clientMessageId: send.clientMessageId,
onError: onSendError
@@ -169,6 +179,7 @@ export function useMobileStructuredSendWithOutcome(args: {
hold,
onSendError,
queueCapable,
carriesOptions,
sessionId,
stateRef,
stopping
@@ -267,8 +267,9 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => {
queuedMessages: false,
queuedCommands: false,
quietRepeatedStop: false,
conversationStop: false,
statusFeed
statusFeed,
sendOptions: false,
conversationStop: false
},
agent: 'codex',
onSendError
@@ -6,7 +6,7 @@ import type { RpcResponse } from '../transport/types'
import type { MobileCommitFailureRecovery } from './mobile-commit-failure-recovery'
vi.mock('../platform/haptics', () => ({ triggerError: vi.fn(), triggerSuccess: vi.fn() }))
vi.mock('../components/HostProtocolGate', () => ({
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => ({
hostCapabilities: [
'agent.launch.v2',
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { launchAgentWithPrompt, promptedLaunchNotice } from '../session/pr-ai-triage-launch'
import { resolveMobileAgentLaunchAvailability } from '../session/mobile-agent-launch-availability'
import {
@@ -0,0 +1,129 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
const route = vi.hoisted(() => {
const opened: unknown[] = []
return {
opened,
router: {
push: (href: unknown) => opened.push(['push', href]),
replace: (href: unknown) => opened.push(['replace', href]),
navigate: (href: unknown) => opened.push(['navigate', href]),
back: () => opened.push(['back'])
}
}
})
vi.mock('../navigation/route-handoff', () => ({ useRouteHandoff: () => route.router }))
vi.mock('../platform/haptics', () => ({ triggerError: vi.fn(), triggerSelection: vi.fn() }))
import { useMobileSourceControlOpeners } from './use-mobile-source-control-openers'
const ENTRY = { path: 'src/app.ts', status: 'modified', area: 'unstaged' } as const
const META = { runtimeId: 'runtime-1' }
async function tapChangedFile(args: {
executionHost?: string
openDiffReply: RpcResponse
unmountDuringOpen?: boolean
}): Promise<{ methods: string[]; actionErrors: (string | null)[]; openedDiffs: string[] }> {
route.opened.length = 0
const methods: string[] = []
const actionErrors: (string | null)[] = []
const openedDiffs: string[] = []
const mountedRef = { current: true }
const sendRequest = async (method: string): Promise<RpcResponse> => {
methods.push(method)
if (args.unmountDuringOpen) {
mountedRef.current = false
}
return args.openDiffReply
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the opener reaches `sendRequest` and nothing else on the client.
const client = { sendRequest } as RpcClient
const held: { openFile: ((entry: typeof ENTRY) => Promise<void>) | null } = { openFile: null }
function Probe(): null {
held.openFile = useMobileSourceControlOpeners({
client,
connState: 'connected',
hostId: 'host-1',
worktreeId: 'wt',
name: 'api',
origin: 'session',
embedded: false,
onOpenedFileDiff: (path) => openedDiffs.push(path),
branchCompareState: { kind: 'idle' },
mountedRef,
busyActionRef: { current: null },
setActionError: (message) => actionErrors.push(message)
}).openFile
return null
}
await act(async () => {
create(
createElement(
WorkspaceExecutionHostContext.Provider,
{ value: normalizeExecutionHostId(args.executionHost) ?? undefined },
createElement(Probe)
)
)
})
await act(async () => {
await held.openFile?.(ENTRY)
})
return { methods, actionErrors, openedDiffs }
}
describe('tapping a changed file from the session', () => {
it("opens a server workspace's diff on the review screen when its host has no desktop window", async () => {
const result = await tapChangedFile({
executionHost: 'runtime:vm',
openDiffReply: {
id: 'call',
ok: false,
error: { code: 'runtime_error', message: 'renderer_unavailable' },
_meta: META
}
})
expect(result.methods).toEqual(['files.openDiff'])
expect(route.opened).toEqual([
[
'push',
'/h/host-1/review/wt?scope=all&file=src%2Fapp.ts&area=unstaged&name=api&executionHost=runtime%3Avm'
]
])
expect(result.actionErrors).toEqual([null])
expect(result.openedDiffs).toEqual([])
})
it('does not push the review screen once the source control screen has gone during the call', async () => {
const result = await tapChangedFile({
executionHost: 'runtime:vm',
unmountDuringOpen: true,
openDiffReply: {
id: 'call',
ok: false,
error: { code: 'runtime_error', message: 'renderer_unavailable' },
_meta: META
}
})
expect(result.methods).toEqual(['files.openDiff'])
expect(route.opened).toEqual([])
})
it("opens a local workspace's diff as a desktop tab", async () => {
const result = await tapChangedFile({
openDiffReply: { id: 'call', ok: true, result: {}, _meta: META }
})
expect(result.methods).toEqual(['files.openDiff'])
expect(result.openedDiffs).toEqual(['src/app.ts'])
expect(route.opened).toEqual([['back']])
expect(result.actionErrors).toEqual([null])
})
})

Some files were not shown because too many files have changed in this diff Show More