mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 11:37:25 +00:00
Merge branch 'brennanb2025/settle-split-1' into brennanb2025/settle-split-2
Syncs main b83a6f74be through part 1.
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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".
|
||||
|
||||
|
||||
@@ -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
+163
-149
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+163
-149
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+139
-127
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user