Merge branch 'main' of https://github.com/stablyai/orca into diff-view-does-not-load-too-long-file

This commit is contained in:
Jinjing
2026-08-30 20:23:36 -07:00
838 changed files with 51623 additions and 23021 deletions
@@ -1,5 +1,5 @@
name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for CI jobs.
description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching.
inputs:
native-runtime:
@@ -14,6 +14,10 @@ inputs:
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
required: false
default: 'true'
cache-electron-package:
description: Cache the Electron package archive and export ELECTRON_CACHE for following steps.
required: false
default: 'false'
outputs:
node-version:
@@ -22,6 +26,9 @@ outputs:
native-cache-scope:
description: Operating-system image scope used by the native module cache.
value: ${{ steps.native-cache-scope.outputs.scope }}
native-cache-hit:
description: Whether the compiled native module cache was restored.
value: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }}
runs:
using: composite
@@ -90,6 +97,32 @@ runs:
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
fi
- name: Resolve Electron package cache
id: electron-package-cache
if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
shell: bash
run: |
set -euo pipefail
case "$RUNNER_OS" in
Linux) cache_root="$HOME/.cache/electron" ;;
macOS) cache_root="$HOME/Library/Caches/electron" ;;
Windows) cache_root="${LOCALAPPDATA:-$HOME/AppData/Local}/electron/Cache" ;;
*)
echo "::error::Unsupported runner OS for Electron cache: $RUNNER_OS"
exit 2
;;
esac
printf 'cache-root=%s\n' "$cache_root" >> "$GITHUB_OUTPUT"
printf 'ELECTRON_CACHE=%s\n' "$cache_root" >> "$GITHUB_ENV"
printf 'version=%s\n' "$(node -p "require('./node_modules/electron/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Electron package archive
if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
uses: actions/cache@v5
with:
path: ${{ steps.electron-package-cache.outputs.cache-root }}
key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }}
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
@@ -112,6 +145,7 @@ runs:
echo "scope=$scope" >> "$GITHUB_OUTPUT"
- name: Restore compiled native modules
id: native-cache-restore
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
uses: actions/cache@v5
with:
@@ -119,9 +153,10 @@ runs:
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Restore compiled native modules without saving
id: native-cache-restore-only
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
uses: actions/cache/restore@v5
with:
@@ -129,7 +164,7 @@ runs:
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
+8 -4
View File
@@ -55,6 +55,10 @@ on:
schedule:
- cron: '0 6 * * *'
concurrency:
group: computer-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
native-smoke:
if: github.event_name == 'pull_request'
@@ -70,7 +74,7 @@ jobs:
with:
persist-credentials: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
@@ -118,7 +122,7 @@ jobs:
src/shared/remote-runtime-client.test.ts
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm run build:electron-vite:parallel
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
@@ -174,7 +178,7 @@ jobs:
native-runtime: electron
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm run build:electron-vite:parallel
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
windows:
@@ -191,5 +195,5 @@ jobs:
native-runtime: electron
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm run build:electron-vite:parallel
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
+58 -10
View File
@@ -31,6 +31,7 @@ jobs:
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }}
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
test: ${{ steps.filter.outputs.test }}
@@ -214,16 +215,16 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
# the next run skip unchanged files. CI threw it away each time. The key is per-SHA
# so each run saves its own; restore-keys inherit the newest prior graph to diff against.
# the next run skip unchanged files. Share one cache entry across commits while the
# PR base stays stable; actions/cache keeps the first successful graph and the
# compiler still invalidates stale files from its content hashes.
- name: Cache TypeScript incremental state
uses: actions/cache@v5
with:
path: config/*.tsbuildinfo
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }}
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.event.pull_request.base.sha }}
restore-keys: |
tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
tsbuildinfo-${{ runner.os }}-
- run: pnpm run typecheck
@@ -294,6 +295,47 @@ jobs:
done
exit "$status"
# Why this job: Orca's session index-heal depends on a Codex behavior — a
# `thread/read` of an unindexed rollout performs a read-repair that inserts the
# `threads` row. Every unit test drives a stub app-server and asserts only that the
# call did not error, so if Codex dropped the repair they would all stay green while
# the subsystem went inert. This runs the pinned real binary and fails when the
# repair stops happening. Pinned because the binary is the thing expected to drift.
codex_index_heal_contract:
name: Codex index-heal contract
needs: [code_paths]
if: needs.code_paths.outputs.codex_index_heal_contract == 'true'
runs-on: ubuntu-latest
env:
CODEX_CLI_VERSION: '0.150.1'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Install pinned Codex CLI
run: |
set -euo pipefail
npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/codex-cli" \
"@openai/codex@$CODEX_CLI_VERSION"
- name: Verify Codex index-heal contract
env:
# Why REQUIRED: without a binary the suite skips, and a job that skips
# reports success. This turns a failed or missing install into a red test
# instead of a green no-op.
ORCA_CODEX_CONTRACT_REQUIRED: '1'
ORCA_CODEX_CONTRACT_VERSION: ${{ env.CODEX_CLI_VERSION }}
run: |
set -euo pipefail
ORCA_CODEX_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli/node_modules/.bin/codex" \
pnpm exec vitest run --config config/vitest.config.ts \
src/main/codex/codex-index-heal-binary-contract.test.ts
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
@@ -308,9 +350,12 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
# Why: the check rebuilds every package in the manifest from a pinned upstream
# commit — @xterm/xterm and the two addons, each built twice (once unmodified to
# prove the toolchain still reproduces the published bundles, once patched). Caching
# the npm metadata and the shallow clone keeps the repeated cost to the builds
# themselves; the key is the manifest, so a commit, package or toolchain bump
# invalidates it.
- name: Restore upstream xterm build inputs
uses: actions/cache@v5
with:
@@ -585,9 +630,7 @@ jobs:
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~/.cache/electron
~/.cache/electron-builder
path: ~/.cache/electron-builder
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-linux-
@@ -678,6 +721,7 @@ jobs:
persist-native-cache: 'false'
- name: Save compiled Node native modules
if: steps.deps.outputs.native-cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: |
@@ -843,6 +887,7 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- codex_index_heal_contract
- xterm_patch_sync
- shell_contracts
- test
@@ -873,6 +918,8 @@ jobs:
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }}
CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN: ${{ needs.code_paths.outputs.codex_index_heal_contract }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
@@ -918,6 +965,7 @@ jobs:
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN"
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
check_job test "$TEST" "$TEST_SHOULD_RUN"
+1
View File
@@ -32,6 +32,7 @@ jobs:
with:
native-runtime: node
node-version: ${{ matrix.node }}
cache-electron-package: 'true'
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
-2
View File
@@ -27,7 +27,6 @@ inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
@@ -47,7 +46,6 @@ inline src/shared/keybindings.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
mobile-config scripts/mock-server.ts
mobile-config src/terminal/terminal-webview-html.ts
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,212 @@
diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts
index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b01fdff0a 100644
--- a/src/SerializeAddon.ts
+++ b/src/SerializeAddon.ts
@@ -12,6 +12,36 @@ import { IAttributeData } from 'common/buffer/Types';
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
import { UnderlineStyle } from 'common/buffer/Constants';
+type OscLinkData = { id?: string; uri: string };
+type OscLinkedCell = { extended?: { urlId?: number } };
+type TerminalWithOscLinks = Terminal & {
+ _core?: {
+ _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell };
+ _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined };
+ };
+};
+
+function getOscLinkId(cell: IBufferCell | IAttributeData): number {
+ return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0;
+}
+
+function getOscLinkOpenSequence(terminal: Terminal, linkId: number): string {
+ if (!linkId) {
+ return '';
+ }
+ const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId);
+ if (!data) {
+ return '';
+ }
+ const params = data.id === undefined ? '' : `id=${data.id}`;
+ return `\u001b]8;${params};${data.uri}\u001b\\`;
+}
+
+function getActiveOscLinkSequence(terminal: Terminal): string {
+ const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;
+ return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';
+}
+
function constrain(value: number, low: number, high: number): number {
return Math.max(low, Math.min(value, high));
}
@@ -148,12 +178,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
// this is a null cell for reference for checking whether background is empty or not
private _backgroundCell: IBufferCell = this._buffer.getNullCell();
+ private _defaultCell: IBufferCell = this._buffer.getNullCell();
private _firstRow: number = 0;
private _lastCursorRow: number = 0;
private _lastCursorCol: number = 0;
private _lastContentCursorRow: number = 0;
private _lastContentCursorCol: number = 0;
+ private _activeOscLinkId: number = 0;
constructor(
buffer: IBuffer,
@@ -214,7 +246,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (
// you must output character to cause overflow, control sequence can't do this
nextRowFirstChar.getChars() &&
- isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0
+ (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)
) {
if (
// the last character can't be null,
@@ -251,9 +283,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (this._nullCellCount > 0) {
// do these because we filled the last several null slot, which we shouldn't
rowSeparator += '\u001b[A';
- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`;
+ const contentCellCount = currentLine.length - this._nullCellCount;
+ if (contentCellCount > 0) {
+ rowSeparator += `\u001b[${contentCellCount}C`;
+ }
rowSeparator += `\u001b[${this._nullCellCount}X`;
- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`;
+ if (contentCellCount > 0) {
+ rowSeparator += `\u001b[${contentCellCount}D`;
+ }
rowSeparator += '\u001b[B';
}
@@ -310,7 +347,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
if (flagsChanged) {
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
- if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
+ // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so
+ // they must be diffed as one intensity group with the clearing 22 emitted
+ // BEFORE any re-set. Upstream's independent per-flag diff could emit
+ // "1;22" (bold set, then wiped by dim's clear — \x1b[2mA\x1b[22m\x1b[1mB
+ // loses B's bold on round-trip) or a bare "22" that drops a still-set
+ // bold/dim, garbling Orca's hidden-terminal snapshot restores.
+ const boldChanged = cell.isBold() !== oldCell.isBold();
+ const dimChanged = cell.isDim() !== oldCell.isDim();
+ if (boldChanged || dimChanged) {
+ const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim());
+ if (clearsIntensity) { sgrSeq.push(22); }
+ if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); }
+ if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); }
+ }
if (!equalUnderline(cell, oldCell)) {
const style = cell.getUnderlineStyle();
if (style === UnderlineStyle.NONE) {
@@ -337,7 +387,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }
if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }
if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }
- if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); }
+ // PATCH(orca): dim handled in the intensity group above.
if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }
}
}
@@ -346,6 +396,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
return sgrSeq;
}
+ private _setOscLink(linkId: number): string {
+ if (linkId === this._activeOscLinkId) {
+ return '';
+ }
+ let sequence = this._activeOscLinkId ? '\u001b]8;;\u001b\\' : '';
+ this._activeOscLinkId = 0;
+ const openSequence = getOscLinkOpenSequence(this._terminal, linkId);
+ if (openSequence) {
+ this._activeOscLinkId = linkId;
+ sequence += openSequence;
+ }
+ return sequence;
+ }
+
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {
// a width 0 cell don't need to be count because it is just a placeholder after a CJK character;
const isPlaceHolderCell = cell.getWidth() === 0;
@@ -356,12 +420,21 @@ class StringSerializeHandler extends BaseSerializeHandler {
// this cell don't have content
const isEmptyCell = cell.getChars() === '';
+ const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined;
+ const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar);
+ // A pending wide glyph recreates its own final-column padding during replay.
+ const isWideWrapPadding = col === this._terminal.cols - 1 &&
+ nextLine?.isWrapped &&
+ (nextRowFirstCell?.getWidth() ?? 0) > 1 &&
+ !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell);
+ // Cursor movement cannot reproduce an inverse cell's visible background.
+ const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding;
const sgrSeq = this._diffStyle(cell, this._cursorStyle);
- // the empty cell style is only assumed to be changed when background changed, because
- // foreground is always 0.
- const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0;
+ const styleChanged = isEmptyCell
+ ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell)
+ : sgrSeq.length > 0;
/**
* handles style change
@@ -395,7 +468,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
/**
* handles actual content
*/
- if (isEmptyCell) {
+ if (isEmptyCell && !materializeEmptyCell) {
this._nullCellCount += cell.getWidth();
} else {
if (this._nullCellCount > 0) {
@@ -411,7 +484,22 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount = 0;
}
- this._currentRow += cell.getChars();
+ // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable.
+ this._currentRow += this._setOscLink(getOscLinkId(cell));
+
+ if (materializeEmptyCell) {
+ const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline();
+ if (hasDecoration) {
+ this._currentRow += '\u001b[24;29;55m';
+ }
+ this._currentRow += ' '.repeat(cell.getWidth());
+ if (hasDecoration) {
+ const restoreSgrSeq = this._diffStyle(cell, this._defaultCell);
+ this._currentRow += `\u001b[0m\u001b[${restoreSgrSeq.join(';')}m`;
+ }
+ } else {
+ this._currentRow += cell.getChars();
+ }
// update cursor
this._lastContentCursorRow = this._lastCursorRow = row;
@@ -439,6 +527,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}
+ // Each buffer is self-contained so links cannot leak into a following buffer.
+ content += this._setOscLink(0);
+
// restore the cursor
if (!excludeFinalCursorPosition) {
const realCursorRow = this._buffer.baseY + this._buffer.cursorY;
@@ -616,6 +707,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi {
content += this._serializeScrollRegion(this._terminal);
}
+ // Restore the source terminal's live OSC pen only after all buffers are complete.
+ content += getActiveOscLinkSequence(this._terminal);
+
return content;
}
@@ -0,0 +1,131 @@
diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
index 742f0879ff4f04509e4a07c8efdf0d5743fe8ee5..885a206a394fff783737a412c0b75bf935dd0eca 100644
--- a/src/GlyphRenderer.ts
+++ b/src/GlyphRenderer.ts
@@ -61,6 +61,8 @@ function createFragmentShaderSource(maxFragmentShaderTextureUnits: number): stri
for (let i = 1; i < maxFragmentShaderTextureUnits; i++) {
textureConditionals += ` else if (v_texpage == ${i}) { outColor = texture(u_texture[${i}], v_texcoord); }`;
}
+ // A v_texpage beyond the sampler budget matches no branch above. Leaving outColor unwritten
+ // is undefined behaviour in GLSL ES and paints garbage, so fall through to transparent.
return (`#version 300 es
precision lowp float;
@@ -74,7 +76,7 @@ out vec4 outColor;
void main() {
if (v_texpage == 0) {
outColor = texture(u_texture[0], v_texcoord);
- } ${textureConditionals}
+ } ${textureConditionals} else { outColor = vec4(0.0, 0.0, 0.0, 0.0); }
}`);
}
diff --git a/src/TextureAtlas.ts b/src/TextureAtlas.ts
index 4977ad741065e26bbfd35bc50e7558a033b13340..f55805ddc3f266415f7f8e81e5b8c318e7db194c 100644
--- a/src/TextureAtlas.ts
+++ b/src/TextureAtlas.ts
@@ -3,6 +3,7 @@
* @license MIT
*/
+import { FontWeight } from '@xterm/xterm';
import { IColorContrastCache } from 'browser/Types';
import { DIM_OPACITY, TEXT_BASELINE } from './Constants';
import { tryDrawCustomGlyph } from './customGlyphs/CustomGlyphRasterizer';
@@ -135,21 +136,23 @@ export class TextureAtlas implements ITextureAtlas {
private _pageLayoutVersion = 0;
public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
+ // Orca diagnostics: read by terminal-render-desync-weight-probe.ts to tell a real
+ // bold-collapse from a repaint problem. Canvas silently keeps its previous font when an
+ // assignment fails to parse, which rasterizes glyphs at a stale weight.
+ public fontProbeMismatchCount = 0;
+ public fontProbeLastMismatch: { desired: string, actual: string } | undefined;
+
public clearTexture(): void {
- if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
+ // Guard on every page rather than pages[0]: a merged page is never written through
+ // currentRow, so once one lands at index 0 the old check made every later clear a no-op.
+ if (this._pages.every(page => page.glyphs.length === 0 && page.currentRow.x === 0 && page.currentRow.y === 0)) {
return;
}
- for (const page of this._pages) {
- page.clear();
- }
- this._cacheMap.clear();
- this._cacheMapCombined.clear();
- this._didWarmUp = false;
-
- // Invalidate renderer models so all texture pages are refreshed. The atlas may be shared, in
- // which case the clearing renderer has cleared only its own model and every other owner still
- // holds texture coords into the rows just wiped.
- this._pageLayoutVersion++;
+ // Return the atlas to its constructor state instead of clearing in place: page.clear() leaves
+ // page.glyphs populated, which would keep the guard above from ever firing again. Eviction
+ // also bumps _pageLayoutVersion, so every renderer sharing this atlas rebuilds its model.
+ this._evictAllPages();
+ this._createNewPage();
}
private _createNewPage(): AtlasPage {
@@ -465,6 +468,36 @@ export class TextureAtlas implements ITextureAtlas {
return this._config.colors.contrastCache;
}
+ /**
+ * Orca diagnostic. Canvas ignores a font assignment it cannot parse and silently keeps the
+ * previous value, so a bad family or weight rasterizes every glyph at a stale weight. Record
+ * the mismatch rather than correcting it: the goal is to tell that failure apart from a
+ * repaint bug when a terminal renders bold-collapsed.
+ */
+ private _probeRasterizationFontWeight(fontWeight: FontWeight): void {
+ const desired = String(fontWeight);
+ // Only numeric weights are comparable; keywords round-trip through Canvas unchanged.
+ if (!/^(?:[1-8]\d{2}|900)$/.test(desired)) {
+ return;
+ }
+ // Canvas normalizes font serialization: Chromium omits 400 and emits the keyword bold for 700.
+ const token = this._tmpCtx.font.match(
+ /(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/
+ )?.[1] ?? '400';
+ const actual = token === 'normal' ? '400' : token === 'bold' ? '700' : token;
+ if (actual === desired) {
+ return;
+ }
+ this.fontProbeMismatchCount++;
+ this.fontProbeLastMismatch = { desired, actual: this._tmpCtx.font };
+ try {
+ (globalThis as { __orcaAtlasFontProbe?: (mismatch: { desired: string, actual: string }) => void })
+ .__orcaAtlasFontProbe?.(this.fontProbeLastMismatch);
+ } catch {
+ // Diagnostics only; a throwing listener must never break rasterization.
+ }
+ }
+
private _drawToCache(codeOrChars: number | string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars;
@@ -536,6 +569,7 @@ export class TextureAtlas implements ITextureAtlas {
const fontStyle = italic ? 'italic' : '';
this._tmpCtx.font =
`${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
+ this._probeRasterizationFontWeight(fontWeight);
this._tmpCtx.textBaseline = TEXT_BASELINE;
const powerlineGlyph = chars.length === 1 && isPowerlineGlyph(chars.charCodeAt(0));
diff --git a/src/WebglRenderer.ts b/src/WebglRenderer.ts
index a951efba5c75e82735cd39b6b22c4b5d5fad5928..e7f80c3a8c14dd65a16a97f1d17e3da1d65ed8bf 100644
--- a/src/WebglRenderer.ts
+++ b/src/WebglRenderer.ts
@@ -386,7 +386,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
// page's version, so re-run the update and force a full texture rebind.
let merged = false;
let mergeRetries = 0;
- while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
+ // Test the retry budget before beginFrame: beginFrame latches the page layout version it
+ // observed, so tripping the limit after consuming it would strand a stale model with no
+ // later frame able to notice it needs rebuilding.
+ while (this._charAtlas && mergeRetries++ < Constants.MERGE_RETRY_LIMIT && this._glyphRenderer.value.beginFrame()) {
merged = true;
this._clearModel(true);
this._updateModel(0, this._terminal.rows - 1);
@@ -1,5 +1,5 @@
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1e931791d 100644
index 67893b7966eb095db4459b8837de9766921f36c5..0abf7ecac1aa108f7caf711d0a5c610a688d34fd 100644
--- a/src/browser/CoreBrowserTerminal.ts
+++ b/src/browser/CoreBrowserTerminal.ts
@@ -325,6 +325,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -32,7 +32,7 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
this._register(this.onRender(() => this._compositionHelper!.updateCompositionElements()));
}
@@ -551,6 +565,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -552,6 +566,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._compositionView = this._document.createElement('div');
this._compositionView.classList.add('composition-view');
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
@@ -44,7 +44,7 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1
this._helperContainer.appendChild(this._compositionView);
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
@@ -1008,7 +1027,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -1009,7 +1028,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._onKey.fire({ key, domEvent: ev });
this._showCursor();
@@ -55,7 +55,7 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1
this._keyPressHandled = true;
@@ -1026,6 +1047,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -1027,6 +1048,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
* @param ev The input event to be handled.
*/
protected _inputEvent(ev: InputEvent): boolean {
@@ -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..cf3e685c7e1b29a6e9d0d4747e1b30f01bd98554 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe0c516b06 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -3,8 +3,9 @@
@@ -575,7 +575,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
// 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 +479,310 @@ export class CompositionHelper {
@@ -172,37 +479,315 @@ 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.
@@ -609,11 +609,11 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
+ pending.finalizerTimer = undefined;
+ if (this._compositionTransactionId === pending.transactionId) {
+ this._isAwaitingCompositionEnd = false;
+ }
+ if (this._pendingComposition === pending) {
+ this._sendPendingComposition(pending, true);
}
- }, 0);
+ if (this._pendingComposition === pending) {
+ this._sendPendingComposition(pending, true);
+ }
+ });
}
}
@@ -830,12 +830,17 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
+ if (
+ this._compositionEndTimer !== timer ||
+ !this._isComposing ||
+ this._compositionTransactionId !== transactionId ||
+ !this._compositionEndBelongsToCurrentTransaction(endData)
+ this._compositionTransactionId !== transactionId
+ ) {
+ return;
+ }
+ this._compositionEndTimer = undefined;
+ if (!this._compositionEndBelongsToCurrentTransaction(endData)) {
+ if (endData.length === 0 && !this._hasCompositionProgress()) {
+ this._cancelComposition();
+ }
+ return;
+ }
+ this._finalizeComposition(true, endData);
+ this._dispatchCompositionSessionEvent(new CustomEvent(
+ XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
@@ -913,7 +918,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
/**
* 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 +802,9 @@ export class CompositionHelper {
@@ -222,6 +807,9 @@ export class CompositionHelper {
const diff = newValue.replace(oldValue, '');
@@ -923,7 +928,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +819,101 @@ export class CompositionHelper {
@@ -236,6 +824,101 @@ export class CompositionHelper {
}, 0);
}
@@ -1025,7 +1030,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
/**
* 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 +921,23 @@ export class CompositionHelper {
@@ -243,10 +926,23 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -1050,7 +1055,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
@@ -265,20 +956,38 @@ export class CompositionHelper {
@@ -265,20 +961,38 @@ export class CompositionHelper {
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
+64 -6
View File
@@ -2,7 +2,7 @@
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
"upstream": {
"repository": "https://github.com/xtermjs/xterm.js.git",
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
"commit": "d3e32b344dfe7dd6015cff6a9aeaaeaeccdc2789",
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
},
"sourcemaps": {
@@ -19,17 +19,75 @@
"packages": [
{
"name": "@xterm/xterm",
"version": "6.1.0-beta.287",
"version": "6.1.0-beta.303",
"packageDir": ".",
"versionStampFile": "src/common/Version.ts",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.303.patch",
"generatedPaths": ["lib/"],
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
"build": [
{
"cwd": ".",
"command": "npm",
"args": ["run", "package"]
}
]
},
{
"name": "@xterm/addon-webgl",
"version": "0.20.0-beta.299",
"packageDir": "addons/addon-webgl",
"$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.",
"sourcePatch": "config/patches/xterm-src/@xterm__addon-webgl@0.20.0-beta.299.src.patch",
"patch": "config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch",
"generatedPaths": ["lib/"],
"build": [
{
"cwd": "../..",
"command": "npm",
"args": ["run", "build"]
},
{
"cwd": ".",
"command": "npm",
"args": ["run", "package"]
},
{
"cwd": "../..",
"command": "npm",
"args": ["run", "esbuild-package"]
}
]
},
{
"name": "@xterm/addon-serialize",
"version": "0.15.0-beta.300",
"packageDir": "addons/addon-serialize",
"$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.",
"sourcePatch": "config/patches/xterm-src/@xterm__addon-serialize@0.15.0-beta.300.src.patch",
"patch": "config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch",
"generatedPaths": ["lib/"],
"build": [
{
"cwd": "../..",
"command": "npm",
"args": ["run", "build"]
},
{
"cwd": ".",
"command": "npm",
"args": ["run", "package"]
},
{
"cwd": "../..",
"command": "npm",
"args": ["run", "esbuild-package"]
}
]
}
],
"forbiddenBuildScripts": {
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. The packaging scripts are `package` and `esbuild-package`; nothing here may run a development pass after them.",
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
}
}
+18 -2
View File
@@ -23,6 +23,15 @@ export const OXLINT_SCANS = [
}
]
const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([
[
'react-doctor(no-derived-state-effect)',
new Set([
'src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts'
])
]
])
export function parseAddedLineRanges(diff) {
const ranges = []
const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
@@ -263,6 +272,11 @@ function printDiagnostic(diagnostic, root) {
console.error(`${file}:${line} ${code}: ${diagnostic.message}`)
}
function isSuppressedDiagnostic(diagnostic, root) {
const files = SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS.get(diagnostic.code)
return files?.has(normalizedDiagnosticPath(root, diagnostic.filename)) ?? false
}
function runOxlintScan(root, scan, files) {
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
@@ -295,8 +309,10 @@ export function main(
let failures = 0
for (const scan of OXLINT_SCANS) {
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
const diagnostics = runOxlintScan(root, scan, files).filter(
(diagnostic) =>
!isSuppressedDiagnostic(diagnostic, root) &&
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
)
for (const diagnostic of diagnostics) {
printDiagnostic(diagnostic, root)
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('Codex index-heal contract PR gate', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const job = workflow.jobs.codex_index_heal_contract
it('installs and verifies against one pinned Codex version', () => {
const install = job.steps.find((step) => step.name === 'Install pinned Codex CLI')
const verify = job.steps.find((step) => step.name === 'Verify Codex index-heal contract')
// Why one source: the install and the runtime version assertion drifting apart is
// the failure that would leave this job verifying a Codex nobody declared.
expect(job.env.CODEX_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
expect(install.run).toContain('"@openai/codex@$CODEX_CLI_VERSION"')
expect(verify.env.ORCA_CODEX_CONTRACT_VERSION).toBe('${{ env.CODEX_CLI_VERSION }}')
// The install prefix and the binary the test is pointed at must be the same tree.
expect(install.run).toContain('--prefix "$RUNNER_TEMP/codex-cli"')
expect(verify.run).toContain(
'ORCA_CODEX_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli/node_modules/.bin/codex"'
)
expect(verify.run).toContain('src/main/codex/codex-index-heal-binary-contract.test.ts')
})
it('fails rather than skipping when the Codex binary is missing', () => {
const verify = job.steps.find((step) => step.name === 'Verify Codex index-heal contract')
// Why asserted: the contract skips itself without a binary, so a failed install
// would otherwise turn this job into a green no-op that verifies nothing.
expect(verify.env.ORCA_CODEX_CONTRACT_REQUIRED).toBe('1')
expect(job.steps.find((step) => step.name === 'Install pinned Codex CLI').run).toContain(
'set -euo pipefail'
)
})
})
+39 -3
View File
@@ -6,6 +6,17 @@ import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
describe('computer-use e2e workflow', () => {
it('cancels superseded pull request runs without cancelling scheduled runs', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
expect(workflow.concurrency).toEqual({
group: 'computer-e2e-${{ github.event.pull_request.number || github.ref }}',
'cancel-in-progress': "${{ github.event_name == 'pull_request' }}"
})
})
it('runs computer-use e2e files serially because they share desktop focus', () => {
const config = readFileSync(join(projectDir, 'tests/e2e/vitest.config.ts'), 'utf8')
@@ -121,6 +132,29 @@ describe('computer-use e2e workflow', () => {
}
})
it('keeps Linux native imports available without installing the GUI-only stack in PR smoke', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const nativeSmokeInstall = workflow.jobs['native-smoke'].steps.find(
(step) => step.if === "runner.os == 'Linux'"
)
const scheduledLinuxInstall = workflow.jobs.linux.steps.find((step) =>
step.run?.includes('apt-get install')
)
expect(nativeSmokeInstall.run).toContain('python3')
expect(nativeSmokeInstall.run).toContain('python3-gi')
expect(nativeSmokeInstall.run).toContain('gir1.2-atspi-2.0')
expect(nativeSmokeInstall.run).toContain('at-spi2-core')
expect(nativeSmokeInstall.run).not.toContain('gedit')
expect(nativeSmokeInstall.run).not.toContain('xvfb')
expect(nativeSmokeInstall.run).not.toContain('xdotool')
expect(scheduledLinuxInstall.run).toContain('gedit')
expect(scheduledLinuxInstall.run).toContain('xvfb')
expect(scheduledLinuxInstall.run).toContain('xdotool')
})
it('builds and tests the macOS helper on every trigger without hosted TCC e2e', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
@@ -210,7 +244,7 @@ describe('computer-use e2e workflow', () => {
)
const steps = workflow.jobs['native-smoke'].steps
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const buildIndex = runs.indexOf('pnpm run build:electron-vite:parallel')
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
@@ -226,7 +260,9 @@ describe('computer-use e2e workflow', () => {
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const steps = workflow.jobs['native-smoke'].steps
const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite')
const buildIndex = steps.findIndex(
(step) => step.run === 'pnpm run build:electron-vite:parallel'
)
const reproIndex = steps.findIndex(
(step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs'
)
@@ -275,7 +311,7 @@ describe('computer-use e2e workflow', () => {
const runs = workflow.jobs[jobName].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const buildIndex = runs.indexOf('pnpm run build:electron-vite:parallel')
const e2eIndexes = runs
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
.filter((index) => index >= 0)
@@ -125,7 +125,9 @@ function repairElectronPathFile() {
async function installElectronPackageBinary() {
const electronDistDir = resolve(electronPackageDir, 'dist')
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
const cacheRoot = join(tempDir, 'cache')
const persistentCacheRoot =
process.env.ORCA_ELECTRON_PACKAGE_CACHE_ROOT || process.env.ELECTRON_CACHE || null
const cacheRoot = persistentCacheRoot ?? join(tempDir, 'cache')
const extractDir = join(tempDir, 'extract')
try {
@@ -135,11 +137,13 @@ async function installElectronPackageBinary() {
platform: targetPlatform,
arch: targetArch,
cacheRoot,
force: true,
force: !persistentCacheRoot,
tempDirectory: tempDir,
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
}
const zipPath = await downloadElectronArtifactWithRetry(downloadOptions)
const zipPath = await downloadElectronArtifactWithRetry(downloadOptions, {
cacheRootIsPersistent: Boolean(persistentCacheRoot)
})
// Why: CI has observed partial extracts directly under node_modules/electron
// that leave only dist/locales. Verify in temp before replacing package dist.
@@ -159,7 +163,7 @@ async function installElectronPackageBinary() {
}
}
async function downloadElectronArtifactWithRetry(downloadOptions) {
async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) {
const retryDelays = getDownloadRetryDelays()
for (let attempt = 0; ; attempt += 1) {
@@ -175,7 +179,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) {
`[electron-package] Transient Electron download failure (${formatDownloadError(error)}); ` +
`retrying in ${retryDelay}ms (${attempt + 2}/${retryDelays.length + 1}).`
)
rmSync(downloadOptions.cacheRoot, { recursive: true, force: true })
if (!cacheRootIsPersistent) {
rmSync(downloadOptions.cacheRoot, { recursive: true, force: true })
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryDelay))
}
}
@@ -38,6 +38,7 @@ describe('install-electron-package-binary', () => {
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toMatch(
/cacheRoot=.*orca-electron-.*cache/
)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain('force=true')
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
'electron'
)
@@ -79,6 +80,29 @@ describe('install-electron-package-binary', () => {
}
})
it('reuses a configured persistent cache without forcing a fresh download', () => {
const projectDir = mkTempProject()
const cacheRoot = join(projectDir, 'electron-cache')
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: cacheRoot
})
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain(
`cacheRoot=${cacheRoot} platform=linux arch=x64 force=false`
)
expect(existsSync(cacheRoot)).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('preserves an existing Electron distribution when replacement download fails', () => {
const projectDir = mkTempProject()
@@ -205,6 +229,33 @@ describe('install-electron-package-binary', () => {
}
})
it('keeps a persistent cache root while retrying transient failures', () => {
const projectDir = mkTempProject()
const cacheRoot = join(projectDir, 'electron-cache')
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 1,
downloadErrorCode: 'ECONNRESET'
})
writeFakeExtractor(projectDir, { createExecutable: true })
mkdirSync(cacheRoot, { recursive: true })
writeFileSync(join(cacheRoot, 'preserved.marker'), 'keep me')
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: cacheRoot,
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(cacheRoot, 'preserved.marker'))).toBe(true)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')).toHaveLength(2)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('retries HTTP/2 stream refusal Electron download failures', () => {
const projectDir = mkTempProject()
@@ -385,6 +436,8 @@ function runInstallScript(projectDir, extraEnv = {}) {
encoding: 'utf8',
env: {
...process.env,
ELECTRON_CACHE: undefined,
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
@@ -450,7 +503,7 @@ exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n'
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
@@ -13,16 +13,6 @@ const packageJson = JSON.parse(readProject('package.json'))
const pnpmWorkspace = parse(readProject('pnpm-workspace.yaml'))
describe('Electron runtime package contract', () => {
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
const patch = readProject('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
expect(patch).toContain('this.clearModelGeneration++')
expect(patch).toContain('this._atlas._clearModelGeneration||0')
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
})
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron')
+22
View File
@@ -20,6 +20,7 @@ export const PR_CHECK_JOBS = [
'static_analysis',
'typecheck',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
'shell_contracts',
'test',
@@ -48,6 +49,24 @@ const GIT_COMPAT_PREFIXES = [
'config/scripts/git-binary-compatibility'
]
// Why narrow: the contract pins Codex's read-repair, so it runs when the heal that
// depends on it, its app-server transport, or the contract itself changes.
const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [
'src/main/codex/codex-index-heal-binary-contract',
'src/main/codex/codex-session-index-heal',
'src/main/codex/codex-app-server-session',
'src/main/codex/codex-state-db',
'src/main/sqlite/sync-database',
'src/main/codex/codex-app-server-capability-signal',
'src/main/codex/codex-process-exit-deadline',
'src/main/codex/codex-session-backfill',
'src/main/codex/codex-session-index-heal-state',
'src/main/codex-cli/command',
'src/main/win32-utils',
'src/shared/node-cli-command-resolution',
'src/shared/windows-batch-spawn'
]
const XTERM_PREFIXES = [
'config/patches/xterm-upstream.json',
'config/patches/@xterm',
@@ -256,6 +275,9 @@ function jobDetector(job) {
switch (job) {
case 'git_compatibility':
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
case 'codex_index_heal_contract':
return (files) =>
files.some((file) => matchesPrefix(file, CODEX_INDEX_HEAL_CONTRACT_PREFIXES))
case 'xterm_patch_sync':
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
case 'shell_contracts':
@@ -17,6 +17,7 @@ const expensiveJobs = [
'static_analysis',
'typecheck',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
'shell_contracts',
'test',
@@ -118,6 +119,49 @@ describe('per-job path classification', () => {
})
})
it('runs the Codex index-heal contract only when the heal or its transport changes', () => {
expectClassification(['src/main/codex/codex-session-index-heal.ts'], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
expectClassification(['src/main/sqlite/sync-database.ts'], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
expectClassification(['src/main/codex/codex-app-server-session.ts'], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
expectClassification(['src/main/codex/codex-index-heal-binary-contract.test.ts'], {
codex_index_heal_contract: true
})
// Keep the real-binary gate live when a transport or launch dependency changes.
for (const file of [
'src/main/codex/codex-app-server-capability-signal.ts',
'src/main/codex/codex-process-exit-deadline.ts',
'src/main/codex/codex-session-backfill.ts',
'src/main/codex/codex-session-index-heal-state.ts',
'src/main/codex-cli/command.ts',
'src/main/win32-utils.ts',
'src/shared/node-cli-command-resolution.ts',
'src/shared/windows-batch-spawn.ts'
]) {
expectClassification([file], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
}
// A neighbouring Codex module must not drag the real-binary job in.
expectClassification(['src/main/codex/codex-home-paths.ts'], {
package: true,
package_windows: true
})
})
it('runs xterm patch sync only when xterm inputs change', () => {
expectClassification(['config/patches/xterm-upstream.json'], {
xterm_patch_sync: true
@@ -72,6 +72,7 @@ describe('PR workflow parallelism', () => {
)
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(installStep.with['cache-electron-package']).toBe('true')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
@@ -283,6 +284,11 @@ describe('PR workflow parallelism', () => {
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(installFor('package_windows').with['native-runtime']).toBe('node')
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
expect(
workflow.jobs.package_windows.steps.find(
(step) => step.name === 'Save compiled Node native modules'
).if
).toBe("steps.deps.outputs.native-cache-hit != 'true'")
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
@@ -351,7 +357,9 @@ describe('PR workflow parallelism', () => {
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
expect(cacheStep.with.key).toContain('${{ runner.os }}')
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
expect(cacheStep.with.key).toContain(
'steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version'
)
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
expect(cacheStep.with.key).toContain(
@@ -365,12 +373,31 @@ describe('PR workflow parallelism', () => {
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
expect(cacheStep.with['restore-keys']).toBeUndefined()
}
expect(steps[cacheIndex].id).toBe('native-cache-restore')
expect(restoreOnly.id).toBe('native-cache-restore-only')
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
expect(cacheScope.run).toContain('/etc/os-release')
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
'${{ steps.native-cache-scope.outputs.scope }}'
)
expect(dependencyAction.outputs['native-cache-hit'].value).toContain(
'steps.native-cache-restore.outputs.cache-hit'
)
expect(dependencyAction.outputs['native-cache-hit'].value).toContain(
'steps.native-cache-restore-only.outputs.cache-hit'
)
const electronCache = steps.find((step) => step.name === 'Cache Electron package archive')
const electronCacheResolution = steps.find(
(step) => step.name === 'Resolve Electron package cache'
)
expect(electronCacheResolution.if).toBe(
"inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'"
)
expect(electronCache.if).toBe(electronCacheResolution.if)
expect(electronCache.uses).toBe('actions/cache@v5')
expect(electronCache.with.key).toContain('steps.electron-package-cache.outputs.version')
expect(dependencyAction.inputs['cache-electron-package'].default).toBe('false')
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -381,13 +408,15 @@ describe('PR workflow parallelism', () => {
expect(cacheIndex).toBeGreaterThanOrEqual(0)
expect(cacheIndex).toBeLessThan(checkIndex)
expect(steps[cacheIndex].with.path).toBe('config/*.tsbuildinfo')
// Why restore-keys matter here: an exact-key miss is the normal case (the key is
// per-SHA), so without them the cache would never once be read.
// Why restore-keys matter here: the base SHA key is shared by every commit in a PR,
// but actions/cache keeps the first successful graph until the base or config changes.
expect(steps[cacheIndex].with['restore-keys']).toBeTruthy()
// The buildinfo is only reusable while the compiler options that produced it hold.
expect(steps[cacheIndex].with.key).toContain(
"hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json')"
)
expect(steps[cacheIndex].with.key).toContain('github.event.pull_request.base.sha')
expect(steps[cacheIndex].with['restore-keys']).not.toContain('tsbuildinfo-${{ runner.os }}-\n')
})
it('checks out full history without historical blobs', () => {
@@ -410,6 +439,7 @@ describe('PR workflow parallelism', () => {
'root_directory_guard',
'typecheck',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
'shell_contracts',
'test',
+60 -161
View File
@@ -17,129 +17,19 @@ import {
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertSourceDerivationsAgree,
escapeRegExp,
formatCheckFailure,
normalizePnpmDiff,
pnpmDiffEnvironment
} from './xterm-patch-text.mjs'
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
/**
* Flags pnpm@12 passes to `git diff` in its own `diff_folders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'-c',
'core.quotePath=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color',
'--'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
/** Applies pnpm's git config isolation so local machine settings cannot change the patch. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null'
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The source patch and the emitted patch are the same edits diffed two ways, so
* they must produce the same bytes. A source hunk the emitted patch cannot carry
* would be deleted by the next `--write`, so this fails instead of shipping one.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText) {
const checkout = sourceHunks(checkoutSource)
const emitted = sourceHunks(patchText)
if (checkout === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a source file.',
` from checkout: [${splitPatchEntries(checkout)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(checkout, emitted)}`,
'',
'A hunk the emitted patch cannot name is never installed, so it cannot ship',
'here; upstream .npmignore strips `src/**/*.test.ts`.'
].join('\n')
)
}
export function stampVersionSource(source, version) {
const stamped = source.replace(
/export const XTERM_VERSION = '[^']+';/,
@@ -249,6 +139,11 @@ export function readLockfileResolutionHashes(lockfileText, packageKey) {
)
}
/** False for a key the lockfile has never seen, which is the normal state mid version bump. */
export function lockfileHasPatchEntry(lockfileText, packageKey) {
return lockfilePatchHashPattern(packageKey).test(lockfileText)
}
export function lockfilePatchHashIsStale(lockfileText, packageKey, hash) {
return (
readLockfilePatchHash(lockfileText, packageKey) !== hash ||
@@ -265,35 +160,6 @@ export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
// npm ships as `npm.cmd` on Windows, and execFile applies no PATHEXT and refuses
// a `.cmd` target without a shell (CVE-2024-27980). git and tar are real .exe.
const WINDOWS_SHIM_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn'])
@@ -431,11 +297,13 @@ function buildPackage(upstreamRoot, packageEntry, manifest) {
for (const directory of ['lib', 'out', 'out-esbuild']) {
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
}
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
if (packageEntry.versionStampFile) {
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
}
assertBuildStepsAllowed(manifest)
for (const step of packageEntry.build) {
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
@@ -529,9 +397,17 @@ function regeneratePackage(packageEntry, manifest, context) {
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
const packageDir = toPosix(packageEntry.packageDir)
run(
'git',
[
'apply',
'--whitespace=nowarn',
...(packageDir === '.' ? [] : [`--directory=${packageDir}`]),
path.join(repoRoot, packageEntry.sourcePatch)
],
{ cwd: upstreamRoot }
)
buildPackage(upstreamRoot, packageEntry, manifest)
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
@@ -540,11 +416,20 @@ function regeneratePackage(packageEntry, manifest, context) {
// Leave the checkout diffable: the pinned commit plus the source patch, with
// no publish-time version stamp mixed in, so `git diff` there is the source
// patch and nothing else.
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
if (packageEntry.versionStampFile) {
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
}
const source = diffCheckoutSource(path.join(upstreamRoot, packageEntry.packageDir))
if (source.trim().length === 0) {
throw new Error(
`${packageEntry.name}: applying ${packageEntry.sourcePatch} left the checkout unchanged. ` +
'git apply reports success when it skips every hunk, so this is a path-rooting bug, ' +
'not an empty patch.'
)
}
const patch = diffFolders(pristineDir, patchedDir)
assertSourceDerivationsAgree(source, patch)
return { patch, source }
@@ -566,6 +451,7 @@ export function regenerateXtermPatches({
let lockfileChanged = false
const failures = []
const pendingInstall = []
for (const packageEntry of manifest.packages) {
const shortCommit = manifest.upstream.commit.slice(0, 12)
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
@@ -584,7 +470,10 @@ export function regenerateXtermPatches({
writeFileSync(sourcePatchPath, canonicalSource)
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
if (!lockfileHasPatchEntry(lockfile, packageKey)) {
pendingInstall.push(packageKey)
log(` pnpm-lock.yaml has no entry for ${packageKey} yet; run pnpm install`)
} else if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
lockfileChanged = true
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
@@ -592,7 +481,11 @@ export function regenerateXtermPatches({
continue
}
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
if (!lockfileHasPatchEntry(lockfile, packageKey)) {
failures.push(
`${packageKey}: pnpm-lock.yaml has no patchedDependencies entry. Run pnpm install.`
)
} else if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
const stale = Array.from(
new Set(readLockfileResolutionHashes(lockfile, packageKey).filter((v) => v !== hash))
)
@@ -639,6 +532,12 @@ export function regenerateXtermPatches({
if (lockfileChanged) {
writeFileSync(lockfilePath, lockfile)
}
if (pendingInstall.length > 0) {
log(
`\nRun pnpm install to key the lockfile to the new patches: ${pendingInstall.join(', ')}\n` +
'Then rerun with --check, which is the authority on the lockfile.'
)
}
if (failures.length > 0) {
throw new Error(failures.join('\n\n'))
}
@@ -5,25 +5,29 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertBuildStepsAllowed,
assertPublishedCommit,
assertSourceDerivationsAgree,
assertSourcemapPolicy,
firstDifferenceIndex,
formatCheckFailure,
lockfileHasPatchEntry,
lockfilePatchHashIsStale,
normalizePnpmDiff,
patchHash,
pnpmDiffEnvironment,
readLockfilePatchHash,
readLockfileResolutionHashes,
sourceHunks,
splitPatchEntries,
stampVersionSource,
updateLockfilePatchHash
} from './regenerate-xterm-patches.mjs'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertSourceDerivationsAgree,
firstDifferenceIndex,
formatCheckFailure,
normalizePnpmDiff,
pnpmDiffEnvironment,
selectPatchEntries,
sourceHunks,
splitPatchEntries
} from './xterm-patch-text.mjs'
// Only the tests need to slice the generated half out of a patch; the generator
// reads `generatedPaths` directly where it compares against the pristine build.
@@ -278,10 +282,20 @@ describe('source derivation agreement', () => {
})
it('diffs the checkout with pnpm formatting so the two halves stay comparable', () => {
expect(CHECKOUT_DIFF_FLAGS).toEqual(PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index'))
expect(CHECKOUT_DIFF_FLAGS.filter((flag) => flag !== '--relative')).toEqual(
PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
)
expect(CHECKOUT_DIFF_FLAGS).toContain('--full-index')
expect(CHECKOUT_DIFF_FLAGS).not.toContain('--no-index')
})
it('passes --relative as a flag, not as a pathspec', () => {
// After `--` git reads it as a path, silently leaving addon diffs rooted at the
// repo instead of the package, which drops every source hunk from the patch.
expect(CHECKOUT_DIFF_FLAGS.indexOf('--relative')).toBeLessThan(
CHECKOUT_DIFF_FLAGS.indexOf('--')
)
})
})
describe('manifest guards', () => {
@@ -409,6 +423,13 @@ describe('lockfile coupling', () => {
/no patchedDependencies entry/
)
})
it('reports a missing key without throwing, which is the state mid version bump', () => {
// A bump renames the key, so --write has nothing to rewrite until pnpm install
// creates it. Aborting there would strand the run before the later packages.
expect(lockfileHasPatchEntry(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe(true)
expect(lockfileHasPatchEntry(lockfile, '@xterm/xterm@6.1.0-beta.303')).toBe(false)
})
})
describe('check-mode reporting', () => {
@@ -435,18 +456,20 @@ describe('check-mode reporting', () => {
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
const patch = await readFile(
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'utf8'
)
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
expect(
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
).toHaveLength(3)
expect(
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
).toHaveLength(3)
it('carries the font-weight probe into every WebGL runtime copy', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const webgl = manifest.packages.find((entry) => entry.name === '@xterm/addon-webgl')
const patch = await readFile(path.join(REPO_ROOT, webgl.patch), 'utf8')
// The ESM and CJS bundles mangle locals differently, so anchor on the global the
// renderer diagnostics read; a copy missing it reports a font mismatch as a repaint bug.
for (const file of ['src/TextureAtlas.ts', 'lib/addon-webgl.js', 'lib/addon-webgl.mjs']) {
const stanza = selectPatchEntries(patch, (candidate) => candidate === file)
expect(stanza, file).not.toBe('')
expect(stanza, file).toContain('__orcaAtlasFontProbe')
}
// The probe must compare the rasterized weight against the requested one; comparing
// against a literal '400' would call every non-default weight a mismatch.
expect(sourceHunks(patch)).toContain('actual === desired')
})
it('records the lockfile hash pnpm derives from the patch file', async () => {
+161
View File
@@ -0,0 +1,161 @@
/**
* How an xterm patch is formatted, split and compared.
*
* Split out of regenerate-xterm-patches.mjs, which orchestrates the clone and the
* builds. Everything here is pure text: it needs no upstream checkout, so the unit
* tests exercise it with no network and no build.
*/
/**
* Flags pnpm@12 passes to `git diff` in its own `diff_folders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'-c',
'core.quotePath=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color',
'--'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
* `--relative` scopes paths to the package directory, which is what the published
* tarball is rooted at; it is a no-op for a package whose packageDir is the repo root.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index').flatMap(
// Ahead of the `--` separator, or git reads it as a pathspec and silently keeps
// repo-root-relative paths.
(flag) => (flag === '--' ? ['--relative', '--'] : [flag])
)
/** Applies pnpm's git config isolation so local machine settings cannot change the patch. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null'
}
}
export function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The source patch and the emitted patch are the same edits diffed two ways, so
* they must produce the same bytes. A source hunk the emitted patch cannot carry
* would be deleted by the next `--write`, so this fails instead of shipping one.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText) {
const checkout = sourceHunks(checkoutSource)
const emitted = sourceHunks(patchText)
if (checkout === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a source file.',
` from checkout: [${splitPatchEntries(checkout)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(checkout, emitted)}`,
'',
'A hunk the emitted patch cannot name is never installed, so it cannot ship',
'here; upstream .npmignore strips `src/**/*.test.ts`.'
].join('\n')
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const require = createRequire(import.meta.url)
const readProject = (file) => readFileSync(join(projectDir, file), 'utf8')
const xtermManifest = JSON.parse(readProject('config/patches/xterm-upstream.json'))
const readInstalled = (name, file) =>
readFileSync(join(resolve(require.resolve(`${name}/package.json`), '..'), file), 'utf8')
describe('vendored xterm WebGL runtime contract', () => {
it('keeps shared WebGL atlas invalidation per renderer', () => {
// Orca panes with the same font share one atlas, so a page merge or a clear in one
// pane invalidates cached texture coords in all of them. Recovery has to be observed
// per renderer: a consume-once flag lets whichever pane draws first eat the
// notification and leaves its siblings drawing from a stale model.
//
// Upstream owns this since addon-webgl 0.20.0-beta.299, as a monotonic
// pageLayoutVersion each renderer latches independently, so it is no longer something
// Orca patches in. Assert it on the resolved dependency rather than on the patch.
const atlas = readInstalled('@xterm/addon-webgl', 'src/TextureAtlas.ts')
expect(atlas).toContain('public get pageLayoutVersion(): number')
expect(atlas).toContain('this._pageLayoutVersion++')
const glyphRenderer = readInstalled('@xterm/addon-webgl', 'src/GlyphRenderer.ts')
expect(glyphRenderer).toContain(
'this._atlas.pageLayoutVersion !== this._lastSeenPageLayoutVersion'
)
// Both shipped bundles have to carry it, not just the source beside them.
for (const bundle of ['lib/addon-webgl.js', 'lib/addon-webgl.mjs']) {
const contents = readInstalled('@xterm/addon-webgl', bundle)
expect(contents, bundle).toContain('pageLayoutVersion')
expect(contents, bundle).toContain('_lastSeenPageLayoutVersion')
}
})
it('keeps the Orca-only WebGL hunks in the generated patch', () => {
const webgl = xtermManifest.packages.find((entry) => entry.name === '@xterm/addon-webgl')
const patch = readProject(webgl.patch)
// A v_texpage past the sampler budget must resolve to a defined colour.
expect(patch).toContain('else { outColor = vec4(0.0, 0.0, 0.0, 0.0); }')
// clearTexture must not no-op once a merged page occupies index 0.
expect(patch).toContain('this._pages.every(page => page.glyphs.length === 0')
// The merge retry budget is spent before beginFrame latches the version it saw.
expect(patch).toContain(
'mergeRetries++ < Constants.MERGE_RETRY_LIMIT && this._glyphRenderer.value.beginFrame()'
)
})
})
+76 -28
View File
@@ -24,13 +24,15 @@ truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
This policy covers `@xterm/xterm` only. The addon patches
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
and are tracked separately; see [Known Gaps](#known-gaps).
`@xterm/addon-webgl` and `@xterm/addon-serialize` are generated the same way,
from their own source patches under `config/patches/xterm-src/`. Their entries
differ only in `packageDir` and build steps; everything below applies to all
three. `@xterm/addon-ligatures` is the one patch still written by hand — see
[Known Gaps](#known-gaps).
## Rules
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
1. Never edit `config/patches/@xterm__*@<version>.patch`. Edit the source
patch and regenerate.
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
@@ -51,14 +53,16 @@ and are tracked separately; see [Known Gaps](#known-gaps).
patch hash in two places — `patchedDependencies` and every resolution key that
depends on the patched package — and on a warm store it will leave the
resolution keys at their previous value while reporting success. That installs
locally and drifts on CI's cold store. Always finish on step 4, and if it
reports a stale hash after an install, rerun `--write`.
locally and drifts on CI's cold store. For a version bump, follow the **Version
Bumps** workflow through step 5 (the final `--check`); if it reports a stale hash
after an install, rerun `--write`. For a source-only edit, the four-step workflow
above ends at `--check`.
## Workflow
```sh
# 1. Edit the source hunks.
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
node config/scripts/regenerate-xterm-patches.mjs --write
@@ -77,10 +81,20 @@ any run it is left at the pinned commit with the source patch applied:
```sh
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
```
For an addon, edit under `addons/<name>/` and take the diff from that directory
with `--relative`, so the patch is rooted at the package the way the published
tarball is:
```sh
$EDITOR /tmp/xterm/upstream/addons/addon-webgl/src/TextureAtlas.ts
git -C /tmp/xterm/upstream/addons/addon-webgl diff --relative -- src/ \
> config/patches/xterm-src/@xterm__addon-webgl@0.20.0-beta.299.src.patch
```
`--write` rewrites the source patch into the canonical form it would emit on a
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
than fighting `--check` forever.
@@ -107,6 +121,18 @@ Upstream's publish path is `npm ci` → stamp `Version.ts` → `npm run package`
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
An addon needs three steps, in this order, and the first is easy to miss:
1. **root `npm run build`.** The addon's own `npm run build` is
`tsgo -p .` against a tsconfig whose `files` and `include` are both empty and
which only lists project references. In `-p` mode tsgo does not build
references, so it succeeds while emitting nothing, and the addon's webpack
then fails on a missing `./out/`. The root build is what populates it.
2. **addon `npm run package`** — the addon's own webpack, which emits the CJS
`lib/addon-*.js`. The root `package` script never builds this.
3. **root `npm run esbuild-package`** — `bin/esbuild_all.mjs --prod`, which emits
the ESM `lib/addon-*.mjs` for every addon at once.
**Do not run `npm run setup` after the packaging build.** `setup` is the
development esbuild pass with `minify: false`. Running it afterwards overwrites
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
@@ -171,8 +197,30 @@ error naming it. Recovery is to move the pin to the next upstream commit whose
published bundles, and regenerate. The committed patch keeps working the whole
time — only regeneration is blocked, so this is never an outage.
## Patch Path Rooting
A published tarball is rooted at the package, so an addon's patch names
`src/TextureAtlas.ts`, not `addons/addon-webgl/src/TextureAtlas.ts`. Two places
have to agree with that, and both fail silently if they do not:
- The checkout diff passes `--relative`, which must sit **before** the `--`
separator in `CHECKOUT_DIFF_FLAGS`. After it, git reads it as a pathspec and
keeps repo-root-relative paths, and every source hunk then falls out of the
emitted patch.
- `git apply` runs from the repo root with `--directory=<packageDir>`. Run from
a subdirectory instead, git still resolves patch paths from the repo root,
skips every hunk, and **exits 0**. The generator guards this by failing when
applying a source patch leaves the checkout unchanged.
## Version Bumps
Upstream publishes each package only when its own output changes, so the four
packages carry different beta numbers while sharing one commit — at the time of
writing `@xterm/xterm@6.1.0-beta.303` and `@xterm/headless@6.1.0-beta.302` are
both built from `d3e32b3`. Match on `package.json.commit`, never on the version
string; `xterm-user-scrolling-contract.test.ts` asserts that pairing for
headless and core.
Bumping `@xterm/xterm` is:
1. Update the version in `package.json` and run `pnpm install`.
@@ -182,6 +230,9 @@ Bumping `@xterm/xterm` is:
`package.json`, and `toolchain` to whatever the new `package-lock.json`
resolves.
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
5. `pnpm install`, then `--check`. On a bump the lockfile has no entry under the
new key yet, so `--write` reports the gap and leaves the hash to `pnpm
install`; `--check` is what proves the two agree afterwards.
Step 4 is where a real upstream conflict shows up: `git apply` of the source
patch fails against the new tree. Resolve it in the checkout, re-diff, and
@@ -220,25 +271,22 @@ no build, so they run in the ordinary test shards.
## Known Gaps
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
minified code and parser round-trip artifacts, and neither patch touches its
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
the shipped bundle — the defect `sourcemaps.policy` rules out for `@xterm/xterm`
and which folding them into this manifest would also fix.
`@xterm/addon-ligatures` is still patched by hand, and can stay that way: the
patch is a fifteen-line `package.json` edit that repoints `module` and adds an
`exports` block, touching no bundle and no sourcemap. Nothing about it is
generated, so there is nothing for this harness to verify.
Both addons do build from the pinned commit: the registry stamps
`53a98a720ae4a973e384fa2440880d09537132f3` on `addon-webgl@0.20.0-beta.286` and
`addon-serialize@0.15.0-beta.287` alike, despite the mismatched version numbers.
On 2026-08-17 their published `lib/*.mjs` and `lib/*.mjs.map` reproduced byte for
byte from that commit. That was a one-off measurement, not an invariant: no check
in this repo re-runs it, so treat it as a starting point to re-measure rather than
as something the harness holds true.
The addons were folded into this manifest on 2026-08-29. Before that they were
hand-edited minified bundles carrying a literal `/* PATCH(orca): ... */` comment
inside minified code, parser round-trip artifacts (`!0` printed back as `true`,
locals renamed `i` → `i5`), and no `.map` hunks at all — so both shipped
sourcemaps whose offsets did not match the bundle beside them. All four
`@xterm/addon-webgl` artifacts and all four `@xterm/addon-serialize` artifacts
now reproduce byte for byte from the pinned commit, which is what closed it.
What blocks folding them in is the other half of their published output. Both
also ship a CJS `lib/addon-*.js`, and the root `package` script does not build
it — upstream's webpack entry is core's `Terminal.js` only, and `postpackage`
emits ESM. So a manifest entry for either addon needs a build step this harness
does not have, and the CJS halves are **unverified**: nothing here has yet
reproduced them from source. Until that exists, folding them in would emit a
patch whose CJS stanza came from the current hand-edited bundle.
The one thing still unproven is that this holds across upstream revisions rather
than at this commit. `addon-serialize.js.map` did not reproduce on the first
attempt here; the cause was a stale `out/` from a wrong build order, not
upstream nondeterminism, and it reproduced exactly once the root build ran
first. Treat a future non-reproducing artifact as a build-order bug until proven
otherwise.
-6
View File
@@ -15,12 +15,6 @@
"unicorn/prefer-node-protocol": "off"
},
"overrides": [
{
"files": ["app/h/*/tasks.tsx"],
"rules": {
"max-lines": ["error", { "max": 14682, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/session/*.tsx"],
"rules": {
+83 -454
View File
@@ -5,8 +5,6 @@ import {
Linking,
type AppStateStatus,
BackHandler,
FlatList,
Image,
View,
Text,
ScrollView,
@@ -16,8 +14,7 @@ import {
Platform,
ActivityIndicator,
type KeyboardEvent,
type LayoutChangeEvent,
type ListRenderItem
type LayoutChangeEvent
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -37,12 +34,10 @@ import {
GitBranch,
Globe,
Keyboard as KeyboardIcon,
MessageSquare,
Monitor,
MoreHorizontal,
Plus,
RefreshCw,
Send,
Smartphone,
SquareTerminal,
X
@@ -82,6 +77,8 @@ import {
} from '../../../../src/session/mobile-bulk-close-sheet-actions'
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace'
import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'
import { useMobileSendCompletionGeneration } from '../../../../src/session/use-mobile-send-completion-generation'
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
@@ -130,6 +127,7 @@ import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { useBufferedTerminalDrafts } from '../../../../src/terminal/use-buffered-terminal-drafts'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
@@ -160,7 +158,6 @@ import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
import {
CustomKeyModal,
loadCustomKeys,
@@ -174,12 +171,6 @@ import {
removeDeliveredMobileDiffComments,
removeMobileDiffComments
} from '../../../../src/session/mobile-diff-comments'
import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from '../../../../src/session/mobile-file-syntax'
import {
getTerminalRecordsFromSessionTabs,
hasConnectedTerminalAbsentFromSessionTabs,
@@ -223,7 +214,6 @@ import {
buildMarkdownDiskFallbackDoc,
shouldReadMarkdownFromDiskAfterReadTabFailure
} from '../../../../src/session/mobile-markdown-disk-fallback'
import { MobileHtmlPreview } from '../../../../src/components/MobileHtmlPreview'
import { MobileDictationSetupSheet } from '../../../../src/components/MobileDictationSetupSheet'
import {
fetchDictationSetup,
@@ -290,21 +280,18 @@ import {
import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import { MobileSessionFileReader } from '../../../../src/session/MobileSessionFileReader'
import type { DiffComment } from '../../../../../src/shared/diff-comment-types'
import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types'
import type {
DiffCommentActions,
DiffNotesDelivery,
DiffSyntaxState,
DirtyMarkdownDraft,
FileDocState,
FileSyntaxState,
MarkdownDocState,
MobileDisplayMode,
MobileNewTabAgentLoadState,
MobileSessionTab,
MobileSessionTabType,
RenderableDiffLine,
RuntimeRepoSummary,
SessionTabsResult,
Terminal,
@@ -324,416 +311,6 @@ function getClosedTabTombstoneExpiry(): number {
return Date.now() + CLOSED_TAB_TOMBSTONE_TTL_MS
}
function DiffLineRow({
line,
title,
index,
comments,
activeCommentLine,
commentDraft,
commentsBusy,
onStartComment,
onCancelComment,
onDraftChange,
onSubmitComment,
onDeleteComment
}: {
line: RenderableDiffLine
title: string
index: number
comments: DiffComment[]
activeCommentLine: number | null
commentDraft: string
commentsBusy: boolean
onStartComment: (lineNumber: number) => void
onCancelComment: () => void
onDraftChange: (value: string) => void
onSubmitComment: (lineNumber: number) => void
onDeleteComment: (commentId: string) => void
}) {
const commentLine = line.newLineNumber
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
const canComment = commentLine !== undefined
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
return (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={`${title} diff line ${index + 1}`}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={`Add note on line ${commentLine}`}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder="Add review note"
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
function FileReader({
doc,
title,
relativePath,
language,
diffCommentActions
}: {
doc: FileDocState | undefined
title: string
relativePath: string
language?: string
diffCommentActions?: DiffCommentActions
}) {
const syntaxLanguage = useMemo(
() => resolveMobileSyntaxLanguage(relativePath || title, language),
[language, relativePath, title]
)
const [fileSyntax, setFileSyntax] = useState<FileSyntaxState | null>(null)
const [diffSyntax, setDiffSyntax] = useState<DiffSyntaxState | null>(null)
const [activeCommentLine, setActiveCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const plainDiffLines = useMemo(
() =>
doc?.status === 'ready' && doc.kind === 'diff'
? buildPlainMobileDiffSyntaxLines(doc.lines)
: [],
[doc]
)
const diffCommentsForFile = useMemo(
() =>
diffCommentActions?.comments.filter(
(comment) => comment.filePath === relativePath && comment.source !== 'markdown'
) ?? [],
[diffCommentActions?.comments, relativePath]
)
const diffCommentsByLine = useMemo(() => {
const map = new Map<number, DiffComment[]>()
for (const comment of diffCommentsForFile) {
const list = map.get(comment.lineNumber) ?? []
list.push(comment)
map.set(comment.lineNumber, list)
}
for (const list of map.values()) {
list.sort((a, b) => a.createdAt - b.createdAt)
}
return map
}, [diffCommentsForFile])
const startComment = useCallback((lineNumber: number) => {
setActiveCommentLine(lineNumber)
setCommentDraft('')
}, [])
const cancelComment = useCallback(() => {
setActiveCommentLine(null)
setCommentDraft('')
}, [])
const submitComment = useCallback(
(lineNumber: number) => {
if (!diffCommentActions) {
return
}
void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => {
if (added) {
setActiveCommentLine(null)
setCommentDraft('')
}
})
},
[commentDraft, diffCommentActions, relativePath]
)
const renderDiffLine: ListRenderItem<RenderableDiffLine> = useCallback(
({ item, index }) => (
<DiffLineRow
line={item}
title={title}
index={index}
comments={
item.newLineNumber !== undefined ? (diffCommentsByLine.get(item.newLineNumber) ?? []) : []
}
activeCommentLine={activeCommentLine}
commentDraft={commentDraft}
commentsBusy={diffCommentActions?.busy === true}
onStartComment={startComment}
onCancelComment={cancelComment}
onDraftChange={setCommentDraft}
onSubmitComment={submitComment}
onDeleteComment={(commentId) => {
if (diffCommentActions) {
void diffCommentActions.onDelete(commentId)
}
}}
/>
),
[
activeCommentLine,
cancelComment,
commentDraft,
diffCommentActions,
diffCommentsByLine,
startComment,
submitComment,
title
]
)
useEffect(() => {
if (doc?.status !== 'ready') {
return undefined
}
// Why: defer highlighting one tick so large files show as plain text immediately before colors are applied.
const timer = setTimeout(() => {
// file + html share the syntax-segment source view (html's "Source" toggle).
if (doc.kind === 'file' || doc.kind === 'html') {
setFileSyntax({
doc,
language: syntaxLanguage,
segments: highlightMobileCode(doc.content, syntaxLanguage).segments
})
return
}
if (doc.kind === 'diff') {
setDiffSyntax({
doc,
language: syntaxLanguage,
lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
})
}
// image: no syntax highlighting.
}, 0)
return () => clearTimeout(timer)
}, [doc, syntaxLanguage])
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
</View>
)
}
if (doc.kind === 'diff') {
const activeDiffSyntax =
diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null
const commentCount = diffCommentActions?.comments.length ?? 0
const unsentCommentCount =
diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0
const commentsBusy = diffCommentActions?.busy === true
const canCopyNotes = commentCount > 0 && !commentsBusy
const canSendNotes = unsentCommentCount > 0 && !commentsBusy
return (
<View style={styles.markdownEditor}>
{diffCommentActions ? (
<View style={styles.diffNotesToolbar}>
<View style={styles.diffNotesTitleRow}>
<MessageSquare size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesTitle}>
{commentCount === 0
? 'No review notes'
: `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`}
</Text>
</View>
<View style={styles.diffNotesActions}>
<Pressable
style={[
styles.diffNotesActionButton,
!canCopyNotes && styles.diffCommentButtonDisabled
]}
disabled={!canCopyNotes}
onPress={() => void diffCommentActions.onCopyAll()}
accessibilityLabel="Copy review notes"
>
<Copy size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Copy</Text>
</Pressable>
<Pressable
style={[
styles.diffNotesActionButton,
!canSendNotes && styles.diffCommentButtonDisabled
]}
disabled={!canSendNotes}
onPress={diffCommentActions.onSendAll}
accessibilityLabel="Send review notes to AI"
>
<Send size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Send</Text>
</Pressable>
</View>
</View>
) : null}
<FlatList
data={activeDiffSyntax ?? plainDiffLines}
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
keyExtractor={(line, index) =>
`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`
}
renderItem={renderDiffLine}
initialNumToRender={32}
maxToRenderPerBatch={48}
windowSize={7}
removeClippedSubviews={Platform.OS !== 'web'}
keyboardShouldPersistTaps="handled"
/>
</View>
)
}
if (doc.kind === 'image') {
return (
<View style={styles.imagePreviewContainer}>
<ScrollView
style={styles.imagePreviewScroll}
contentContainerStyle={styles.imagePreviewContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: doc.dataUri }}
style={styles.imagePreview}
resizeMode="contain"
accessibilityLabel={`${title} image`}
/>
</ScrollView>
</View>
)
}
const renderSourceText = (content: string) => (
<View style={styles.markdownEditor}>
<ScrollView
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
>
<Text selectable style={styles.filePreviewText} accessibilityLabel={`${title} preview`}>
<MobileSyntaxSegments
segments={
fileSyntax?.doc === doc && fileSyntax.language === syntaxLanguage
? fileSyntax.segments
: [{ text: content, kind: 'plain' }]
}
/>
</Text>
</ScrollView>
</View>
)
if (doc.kind === 'html') {
return (
<View style={styles.markdownEditor}>
<MobileHtmlPreview html={doc.content} renderSource={() => renderSourceText(doc.content)} />
</View>
)
}
return renderSourceText(doc.content)
}
export default function SessionScreen() {
const {
hostId,
@@ -815,7 +392,6 @@ export default function SessionScreen() {
// Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId)
const [input, setInput] = useState('')
// Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted).
const [terminalTextScale, setTerminalTextScale] = useState(1)
// Why: terminal command-bar autocomplete opt-in, reloaded on focus so a Settings → Terminal toggle takes effect on return.
@@ -951,6 +527,8 @@ export default function SessionScreen() {
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef })
const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs)
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
// Why: survive transient snapshot gaps so the device's own tab pick can re-bind.
@@ -983,6 +561,7 @@ export default function SessionScreen() {
const {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
getLiveInputInteractionGeneration: getLiveInteractionGeneration,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
@@ -1015,12 +594,6 @@ export default function SessionScreen() {
liveInputEnabled,
timerRef: liveInputFocusTimerRef
})
useFocusEffect(
useCallback(() => {
// Expo retains this route while pushed screens are visible.
return resetLiveInputFocus
}, [resetLiveInputFocus])
)
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
// Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel.
const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState<boolean | null>(
@@ -1133,6 +706,11 @@ export default function SessionScreen() {
})
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
nativeChatSendError.bannerMountedRef.current = showNativeChat
const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}`
const getSendCompletionGeneration = useMobileSendCompletionGeneration({
onBlur: resetLiveInputFocus,
surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])
})
const dictation = useMobileDictation({
client,
@@ -1170,7 +748,7 @@ export default function SessionScreen() {
})()
return
}
setInput((current) => appendBufferedDictation(current, route.text))
bufferedTerminalDraftState.setInput((current) => appendBufferedDictation(current, route.text))
showToast('Dictation inserted')
},
onError: (err) => {
@@ -1664,7 +1242,9 @@ export default function SessionScreen() {
// Sweep against the retained set, not the raw list: a chat-covered handle
// keeps its subscription across a graph reload, so erasing its live-input
// preference on the same refresh is the erasure this guard exists to stop.
pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext))
const retainedHandles = resolveRetainedTerminalHandles(pruneContext)
pruneTerminalHandlesFromLiveInput(retainedHandles)
bufferedTerminalDraftState.pruneDrafts(retainedHandles)
defaultTerminalHandlesToLiveInput([...liveHandles])
const shouldPrune = createTerminalPrunePredicate(pruneContext)
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
@@ -1721,6 +1301,7 @@ export default function SessionScreen() {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
nativeChatStream,
bufferedTerminalDraftState.pruneDrafts,
pruneTerminalHandlesFromLiveInput,
subscribeToTerminal,
terminalInventoryRequest,
@@ -1761,6 +1342,9 @@ export default function SessionScreen() {
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {
retainMissingSurfaces: result.tabs.length === 0
})
sessionTabsRef.current = nextTabs
initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
@@ -2663,6 +2247,7 @@ export default function SessionScreen() {
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
bufferedTerminalDraftState.resetDrafts()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
@@ -2682,14 +2267,17 @@ export default function SessionScreen() {
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
bufferedTerminalDraftState.clearPendingRestorations()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
}, [
clearDelayedActionTimers,
bufferedTerminalDraftState.clearPendingRestorations,
clearPendingLiveInputCommit,
clearTerminalCache,
hostId,
bufferedTerminalDraftState.resetDrafts,
worktreeId
])
@@ -3020,6 +2608,20 @@ export default function SessionScreen() {
}
}, [activeSessionTab, fileDocs, readFileTab])
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(
dismissSoftwareKeyboard,
getSendCompletionGeneration
)
async function handleSend() {
// Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713).
if (!client || !activeHandle || sendingRef.current || !canSend) {
@@ -3027,12 +2629,23 @@ export default function SessionScreen() {
}
sendingRef.current = true
const text = normalizeTerminalTextInput(input)
setInput('')
const draft = bufferedTerminalDraftState.input
const text = normalizeTerminalTextInput(draft)
const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(
activeHandle,
draft
)
const sendOrigin = {
handle: activeHandle,
tab: activeSessionTab,
generation: getSendCompletionGeneration()
}
const restoreRejectedDraft = () =>
bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)
try {
// Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap.
await client.sendRequest(
const response = await client.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: activeHandle,
@@ -3042,9 +2655,17 @@ export default function SessionScreen() {
}),
TERMINAL_INPUT_SEND_OPTIONS
)
const accepted = isTerminalSendRpcAccepted(response)
if (!accepted) {
restoreRejectedDraft()
}
const draftUnchanged =
accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)
} catch {
setInput(text)
restoreRejectedDraft()
} finally {
bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
sendingRef.current = false
}
}
@@ -3180,15 +2801,6 @@ export default function SessionScreen() {
]
)
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
client,
@@ -4123,6 +3735,7 @@ export default function SessionScreen() {
})
if (response.ok) {
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)
if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) {
pendingBrowserFocusPageIdRef.current = null
}
@@ -4649,7 +4262,7 @@ export default function SessionScreen() {
</View>
) : activeFileTab ? (
<View style={styles.markdownFrame}>
<FileReader
<MobileSessionFileReader
doc={fileDocs.get(activeFileTab.id)}
title={activeFileTab.title || 'File'}
relativePath={activeFileTab.relativePath}
@@ -4768,6 +4381,8 @@ export default function SessionScreen() {
inputLockReason={nativeChatInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
sendSurfaceId={nativeChatScopeKey ?? ''}
getSendCompletionGeneration={getSendCompletionGeneration}
keyboardInset={keyboardLift}
/>
{toastMessage && (
@@ -5023,7 +4638,20 @@ export default function SessionScreen() {
// marked-text report that says whether this text is still preedit.
onChange={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
onSubmitEditing={() => {
const submit = handleLiveInputSubmit()
const sendOrigin = {
tab: activeSessionTab,
generation: getSendCompletionGeneration(),
interaction: getLiveInteractionGeneration()
}
void submit.then((accepted) =>
dismissKeyboardAfterAgentSend(
sendOrigin,
accepted && sendOrigin.interaction === getLiveInteractionGeneration()
)
)
}}
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
@@ -5052,9 +4680,9 @@ export default function SessionScreen() {
: 'cmd-input'
}
style={styles.textInput}
value={input}
value={bufferedTerminalDraftState.input}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
onChangeText={setInput}
onChangeText={bufferedTerminalDraftState.setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
@@ -5068,6 +4696,7 @@ export default function SessionScreen() {
autocompleteEnabled
)}
returnKeyType="send"
blurOnSubmit={false}
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -21,9 +21,9 @@
"@noble/hashes": "1.8.0",
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
"@xterm/addon-webgl": "0.20.0-beta.284",
"@xterm/xterm": "6.1.0-beta.285",
"@xterm/addon-unicode11": "0.10.0-beta.300",
"@xterm/addon-webgl": "0.20.0-beta.299",
"@xterm/xterm": "6.1.0-beta.303",
"buffer": "^6.0.3",
"expo": "^55.0.30",
"expo-build-properties": "^55.0.18",
+19 -19
View File
@@ -29,14 +29,14 @@ importers:
specifier: ^2.2.0
version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.285
version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)
specifier: 0.10.0-beta.300
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303)
'@xterm/addon-webgl':
specifier: 0.20.0-beta.284
version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)
specifier: 0.20.0-beta.299
version: 0.20.0-beta.299(@xterm/xterm@6.1.0-beta.303)
'@xterm/xterm':
specifier: 6.1.0-beta.285
version: 6.1.0-beta.285
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303
buffer:
specifier: ^6.0.3
version: 6.0.3
@@ -3080,18 +3080,18 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
'@xterm/addon-unicode11@0.10.0-beta.285':
resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==}
'@xterm/addon-unicode11@0.10.0-beta.300':
resolution: {integrity: sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/addon-webgl@0.20.0-beta.284':
resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==}
'@xterm/addon-webgl@0.20.0-beta.299':
resolution: {integrity: sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/xterm@6.1.0-beta.285':
resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==}
'@xterm/xterm@6.1.0-beta.303':
resolution: {integrity: sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
@@ -10561,15 +10561,15 @@ snapshots:
'@xmldom/xmldom@0.9.10': {}
'@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303)':
dependencies:
'@xterm/xterm': 6.1.0-beta.285
'@xterm/xterm': 6.1.0-beta.303
'@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)':
'@xterm/addon-webgl@0.20.0-beta.299(@xterm/xterm@6.1.0-beta.303)':
dependencies:
'@xterm/xterm': 6.1.0-beta.285
'@xterm/xterm': 6.1.0-beta.303
'@xterm/xterm@6.1.0-beta.285': {}
'@xterm/xterm@6.1.0-beta.303': {}
abab@2.0.6: {}
@@ -0,0 +1,147 @@
import { Pressable, Text, TextInput, View } from 'react-native'
import { MessageSquare, Plus, X } from 'lucide-react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { RenderableDiffLine } from './mobile-session-route-types'
export function MobileDiffCommentLineRow({
line,
title,
index,
comments,
activeCommentLine,
commentDraft,
commentsBusy,
onStartComment,
onCancelComment,
onDraftChange,
onSubmitComment,
onDeleteComment
}: {
line: RenderableDiffLine
title: string
index: number
comments: DiffComment[]
activeCommentLine: number | null
commentDraft: string
commentsBusy: boolean
onStartComment: (lineNumber: number) => void
onCancelComment: () => void
onDraftChange: (value: string) => void
onSubmitComment: (lineNumber: number) => void
onDeleteComment: (commentId: string) => void
}) {
const commentLine = line.newLineNumber
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
const canComment = commentLine !== undefined
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
return (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={`${title} diff line ${index + 1}`}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={`Add note on line ${commentLine}`}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder="Add review note"
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
@@ -1,8 +1,20 @@
import { createElement } from 'react'
import { createElement, StrictMode, type ComponentProps } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { Keyboard } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { radii, spacing } from '../theme/mobile-theme'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer'
const getNoComposerEditGeneration = () => 0
function MobileNativeChatComposer({
getComposerEditGeneration = getNoComposerEditGeneration,
...props
}: Omit<ComponentProps<typeof NativeChatComposer>, 'getComposerEditGeneration'> & {
getComposerEditGeneration?: () => number
}): React.JSX.Element {
return createElement(NativeChatComposer, { ...props, getComposerEditGeneration })
}
vi.mock('react-native', async () => {
const React = await import('react')
@@ -45,6 +57,7 @@ vi.mock('../components/BottomDrawer', async () => {
describe('MobileNativeChatComposer', () => {
let renderer: ReactTestRenderer | null = null
const getCurrentSendCompletionGeneration = () => 0
afterEach(() => {
act(() => renderer?.unmount())
@@ -53,15 +66,24 @@ describe('MobileNativeChatComposer', () => {
async function render(
onSend: (text: string) => Promise<boolean>,
onChangeText: () => void,
isAttaching = false
onChangeText: (text: string) => void,
isAttaching = false,
sendSurfaceId = 'tab-a',
getSendCompletionGeneration = () => 0
) {
let composerEditGeneration = 0
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' hello ',
onChangeText,
onChangeText: (text) => {
composerEditGeneration += 1
onChangeText(text)
},
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration: () => composerEditGeneration,
isAttaching
})
)
@@ -117,7 +139,9 @@ describe('MobileNativeChatComposer', () => {
createElement(MobileNativeChatComposer, {
value: ' /clear is prose ',
onChangeText: vi.fn(),
onSend
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration
})
)
})
@@ -164,6 +188,8 @@ describe('MobileNativeChatComposer', () => {
value: 'run the tests',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
sessionOptions: { isWorking: false, controller }
})
)
@@ -196,6 +222,8 @@ describe('MobileNativeChatComposer', () => {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
sessionOptions: {
isWorking: false,
controller: {
@@ -241,6 +269,8 @@ describe('MobileNativeChatComposer', () => {
value: 'half-typed',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
disabled: true
})
)
@@ -262,6 +292,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
attachments: [
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
@@ -290,6 +322,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
})
)
@@ -307,6 +341,8 @@ describe('MobileNativeChatComposer', () => {
value: '/c',
onChangeText,
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
agent: 'claude'
})
)
@@ -345,6 +381,8 @@ describe('MobileNativeChatComposer', () => {
value: '/',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
agent: 'codex'
})
)
@@ -378,6 +416,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
onMicPress,
dictationMode: 'hold',
onMicPressIn,
@@ -396,6 +436,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
onMicPress,
dictationMode: 'toggle',
onMicPressIn,
@@ -408,4 +450,225 @@ describe('MobileNativeChatComposer', () => {
expect(mic().props.onPressIn).toBeUndefined()
expect(mic().props.onPressOut).toBeUndefined()
})
it('dismisses the keyboard once a send is accepted', async () => {
// Why: the reply the user is now waiting on sits behind the keyboard.
vi.mocked(Keyboard.dismiss).mockClear()
await render(vi.fn().mockResolvedValue(true), vi.fn())
await act(async () => sendButton().props.onPress())
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
})
it('dismisses an accepted send after Strict Mode replays mount effects', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
await act(async () => {
renderer = create(
createElement(
StrictMode,
null,
createElement(MobileNativeChatComposer, {
value: 'hello',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration
})
)
)
})
await act(async () => sendButton().props.onPress())
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
})
it('keeps the keyboard up when the send is rejected', async () => {
// A rejected send hands the draft back for editing, so yanking the keyboard
// would make the user re-open it to fix and retry.
vi.mocked(Keyboard.dismiss).mockClear()
await render(vi.fn().mockResolvedValue(false), vi.fn())
await act(async () => sendButton().props.onPress())
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss a newly focused composer when an old accepted send settles', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await render(onSend, vi.fn())
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, {
value: 'new surface draft',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-b',
getSendCompletionGeneration: getCurrentSendCompletionGeneration
})
)
})
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after a newer edit on the same surface', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await render(onSend, vi.fn())
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { onChangeText: (text: string) => void }
}
await act(async () => input.props.onChangeText('newer draft'))
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after autocomplete mutates the same surface', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let editGeneration = 0
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: () => {
editGeneration += 1
},
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
getComposerEditGeneration: () => editGeneration,
agent: 'claude'
})
)
})
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) => void }
}
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }))
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
const suggestion = renderer!.root.findAll(
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => suggestion.props.onPress())
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after dictation mutates the controlled draft', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let editGeneration = 0
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
const props = {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
getComposerEditGeneration: () => editGeneration
}
await act(async () => {
renderer = create(createElement(MobileNativeChatComposer, props))
})
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
editGeneration += 1
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, { ...props, value: 'hello dictated text' })
)
})
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after its retained route loses focus', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let generation = 0
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await render(onSend, vi.fn(), false, 'tab-a', () => generation)
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
generation += 1
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Image,
Keyboard,
Pressable,
ScrollView,
StyleSheet,
@@ -36,6 +37,12 @@ type Props = {
value: string
onChangeText: (text: string) => void
onSend: (text: string) => Promise<boolean>
/** Changes whenever the route focuses a different chat composer surface. */
sendSurfaceId: string
/** Reads the retained route's focus generation without forcing a screen render. */
getSendCompletionGeneration: () => number
/** Reads user draft mutations owned above this renderable composer. */
getComposerEditGeneration: () => number
/** Active tab's agent — the slash autocomplete serves its command catalog. */
agent?: string | null
/** Model/session-option pickers shown in the composer action row; null when
@@ -63,6 +70,9 @@ export function MobileNativeChatComposer({
value,
onChangeText,
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration,
agent,
sessionOptions,
onAttachImage,
@@ -87,6 +97,15 @@ export function MobileNativeChatComposer({
null
)
const sendingRef = useRef(false)
const mountedRef = useRef(true)
const sendSurfaceIdRef = useRef(sendSurfaceId)
const sendSurfaceGenerationRef = useRef(0)
useLayoutEffect(() => {
if (sendSurfaceIdRef.current !== sendSurfaceId) {
sendSurfaceIdRef.current = sendSurfaceId
sendSurfaceGenerationRef.current += 1
}
}, [sendSurfaceId])
const [sending, setSending] = useState(false)
const trimmed = value.trim()
const sessionOptionDispatching = sessionOptions?.controller.pendingId != null
@@ -126,6 +145,14 @@ export function MobileNativeChatComposer({
}
}, [onNeedFiles, trigger?.kind, trigger?.query])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
sendSurfaceGenerationRef.current += 1
}
}, [])
const handleChange = (next: string): void => {
onChangeText(next)
}
@@ -150,12 +177,24 @@ export function MobileNativeChatComposer({
}
sendingRef.current = true
setSending(true)
const sendSurfaceGeneration = sendSurfaceGenerationRef.current
const sendCompletionGeneration = getSendCompletionGeneration()
const composerEditGeneration = getComposerEditGeneration()
try {
// Raw, not trimmed: the send seam owns the wire trim, and a rejection has
// to hand the user back exactly what they typed (#14819).
const accepted = await onSend(value)
if (accepted) {
if (
accepted &&
mountedRef.current &&
sendSurfaceGeneration === sendSurfaceGenerationRef.current &&
sendCompletionGeneration === getSendCompletionGeneration() &&
composerEditGeneration === getComposerEditGeneration()
) {
setCursor(0)
// Why: the turn is now the agent's — the keyboard would cover the reply.
// A rejected send keeps it up so the handed-back draft stays editable.
Keyboard.dismiss()
}
} finally {
sendingRef.current = false
@@ -51,6 +51,8 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
inputLockReason: null,
sendErrorMessage: null,
onClearSendError: vi.fn(),
sendSurfaceId: tick.identity ?? 'tab-a',
getSendCompletionGeneration: () => 0,
keyboardInset: 0
})
}
@@ -24,6 +24,10 @@ type Props = {
sendErrorMessage: string | null
/** Drops that failure once a later send succeeds. */
onClearSendError: () => void
/** Stable host/worktree/tab identity for accepted-send completion fencing. */
sendSurfaceId: string
/** Reads the retained route's focus generation for accepted-send fencing. */
getSendCompletionGeneration: () => number
keyboardInset: number
}
@@ -43,6 +47,8 @@ export function MobileNativeChatOverlay({
inputLockReason,
sendErrorMessage,
onClearSendError,
sendSurfaceId,
getSendCompletionGeneration,
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
@@ -81,6 +87,9 @@ export function MobileNativeChatOverlay({
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
sendSurfaceId={sendSurfaceId}
getSendCompletionGeneration={getSendCompletionGeneration}
getComposerEditGeneration={controller.getChatComposerEditGeneration}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
@@ -85,6 +85,8 @@ function chatViewElement(overrides: Overrides): ReturnType<typeof createElement>
status: 'ready',
streaming: null,
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: () => 0,
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
@@ -58,6 +58,12 @@ type Props = {
loadingEarlier?: boolean
onLoadEarlier?: () => void
onSend: (text: string) => Promise<boolean>
/** Route identity used to fence accepted sends that settle after a tab/view switch. */
sendSurfaceId: string
/** Reads the retained route's focus generation for accepted-send fencing. */
getSendCompletionGeneration: () => number
/** Reads user draft mutations from the route-owned controller. */
getComposerEditGeneration: () => number
/** Accepted user echoes awaiting transcript replacement, including image previews. */
pending: MobileNativeChatPendingItem[]
/** Local photo URIs retained when the authoritative transcript replaces an
@@ -126,6 +132,9 @@ export function MobileNativeChatView({
loadingEarlier,
onLoadEarlier,
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration,
pending,
imagePreviewsByMessageId,
composerText,
@@ -431,6 +440,8 @@ export function MobileNativeChatView({
value={composerText}
onChangeText={onComposerTextChange}
onSend={handleSend}
sendSurfaceId={sendSurfaceId}
{...{ getSendCompletionGeneration, getComposerEditGeneration }}
agent={agent}
sessionOptions={sessionOptions}
onAttachImage={onAttachImage}
@@ -0,0 +1,302 @@
import { useState, useCallback, useEffect, useMemo } from 'react'
import {
ActivityIndicator,
FlatList,
Image,
Platform,
Pressable,
ScrollView,
Text,
View,
type ListRenderItem
} from 'react-native'
import { Copy, MessageSquare, Send } from 'lucide-react-native'
import { MobileHtmlPreview } from '../components/MobileHtmlPreview'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { colors } from '../theme/mobile-theme'
import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from './mobile-file-syntax'
import { styles } from './mobile-session-styles'
import { MobileDiffCommentLineRow } from './MobileDiffCommentLineRow'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type {
DiffCommentActions,
DiffSyntaxState,
FileDocState,
FileSyntaxState,
RenderableDiffLine
} from './mobile-session-route-types'
export function MobileSessionFileReader({
doc,
title,
relativePath,
language,
diffCommentActions
}: {
doc: FileDocState | undefined
title: string
relativePath: string
language?: string
diffCommentActions?: DiffCommentActions
}) {
const syntaxLanguage = useMemo(
() => resolveMobileSyntaxLanguage(relativePath || title, language),
[language, relativePath, title]
)
const [fileSyntax, setFileSyntax] = useState<FileSyntaxState | null>(null)
const [diffSyntax, setDiffSyntax] = useState<DiffSyntaxState | null>(null)
const [activeCommentLine, setActiveCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const plainDiffLines = useMemo(
() =>
doc?.status === 'ready' && doc.kind === 'diff'
? buildPlainMobileDiffSyntaxLines(doc.lines)
: [],
[doc]
)
const diffCommentsForFile = useMemo(
() =>
diffCommentActions?.comments.filter(
(comment) => comment.filePath === relativePath && comment.source !== 'markdown'
) ?? [],
[diffCommentActions?.comments, relativePath]
)
const diffCommentsByLine = useMemo(() => {
const map = new Map<number, DiffComment[]>()
for (const comment of diffCommentsForFile) {
const list = map.get(comment.lineNumber) ?? []
list.push(comment)
map.set(comment.lineNumber, list)
}
for (const list of map.values()) {
list.sort((a, b) => a.createdAt - b.createdAt)
}
return map
}, [diffCommentsForFile])
const startComment = useCallback((lineNumber: number) => {
setActiveCommentLine(lineNumber)
setCommentDraft('')
}, [])
const cancelComment = useCallback(() => {
setActiveCommentLine(null)
setCommentDraft('')
}, [])
const submitComment = useCallback(
(lineNumber: number) => {
if (!diffCommentActions) {
return
}
void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => {
if (added) {
setActiveCommentLine(null)
setCommentDraft('')
}
})
},
[commentDraft, diffCommentActions, relativePath]
)
const renderDiffLine: ListRenderItem<RenderableDiffLine> = useCallback(
({ item, index }) => (
<MobileDiffCommentLineRow
line={item}
title={title}
index={index}
comments={
item.newLineNumber !== undefined ? (diffCommentsByLine.get(item.newLineNumber) ?? []) : []
}
activeCommentLine={activeCommentLine}
commentDraft={commentDraft}
commentsBusy={diffCommentActions?.busy === true}
onStartComment={startComment}
onCancelComment={cancelComment}
onDraftChange={setCommentDraft}
onSubmitComment={submitComment}
onDeleteComment={(commentId) => {
if (diffCommentActions) {
void diffCommentActions.onDelete(commentId)
}
}}
/>
),
[
activeCommentLine,
cancelComment,
commentDraft,
diffCommentActions,
diffCommentsByLine,
startComment,
submitComment,
title
]
)
useEffect(() => {
if (doc?.status !== 'ready') {
return undefined
}
// Why: defer highlighting one tick so large files show as plain text immediately before colors are applied.
const timer = setTimeout(() => {
// file + html share the syntax-segment source view (html's "Source" toggle).
if (doc.kind === 'file' || doc.kind === 'html') {
setFileSyntax({
doc,
language: syntaxLanguage,
segments: highlightMobileCode(doc.content, syntaxLanguage).segments
})
return
}
if (doc.kind === 'diff') {
setDiffSyntax({
doc,
language: syntaxLanguage,
lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
})
}
// image: no syntax highlighting.
}, 0)
return () => clearTimeout(timer)
}, [doc, syntaxLanguage])
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
</View>
)
}
if (doc.kind === 'diff') {
const activeDiffSyntax =
diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null
const commentCount = diffCommentActions?.comments.length ?? 0
const unsentCommentCount =
diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0
const commentsBusy = diffCommentActions?.busy === true
const canCopyNotes = commentCount > 0 && !commentsBusy
const canSendNotes = unsentCommentCount > 0 && !commentsBusy
return (
<View style={styles.markdownEditor}>
{diffCommentActions ? (
<View style={styles.diffNotesToolbar}>
<View style={styles.diffNotesTitleRow}>
<MessageSquare size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesTitle}>
{commentCount === 0
? 'No review notes'
: `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`}
</Text>
</View>
<View style={styles.diffNotesActions}>
<Pressable
style={[
styles.diffNotesActionButton,
!canCopyNotes && styles.diffCommentButtonDisabled
]}
disabled={!canCopyNotes}
onPress={() => void diffCommentActions.onCopyAll()}
accessibilityLabel="Copy review notes"
>
<Copy size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Copy</Text>
</Pressable>
<Pressable
style={[
styles.diffNotesActionButton,
!canSendNotes && styles.diffCommentButtonDisabled
]}
disabled={!canSendNotes}
onPress={diffCommentActions.onSendAll}
accessibilityLabel="Send review notes to AI"
>
<Send size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Send</Text>
</Pressable>
</View>
</View>
) : null}
<FlatList
data={activeDiffSyntax ?? plainDiffLines}
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
keyExtractor={(line, index) =>
`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`
}
renderItem={renderDiffLine}
initialNumToRender={32}
maxToRenderPerBatch={48}
windowSize={7}
removeClippedSubviews={Platform.OS !== 'web'}
keyboardShouldPersistTaps="handled"
/>
</View>
)
}
if (doc.kind === 'image') {
return (
<View style={styles.imagePreviewContainer}>
<ScrollView
style={styles.imagePreviewScroll}
contentContainerStyle={styles.imagePreviewContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: doc.dataUri }}
style={styles.imagePreview}
resizeMode="contain"
accessibilityLabel={`${title} image`}
/>
</ScrollView>
</View>
)
}
const renderSourceText = (content: string) => (
<View style={styles.markdownEditor}>
<ScrollView
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
>
<Text selectable style={styles.filePreviewText} accessibilityLabel={`${title} preview`}>
<MobileSyntaxSegments
segments={
fileSyntax?.doc === doc && fileSyntax.language === syntaxLanguage
? fileSyntax.segments
: [{ text: content, kind: 'plain' }]
}
/>
</Text>
</ScrollView>
</View>
)
if (doc.kind === 'html') {
return (
<View style={styles.markdownEditor}>
<MobileHtmlPreview html={doc.content} renderSource={() => renderSourceText(doc.content)} />
</View>
)
}
return renderSourceText(doc.content)
}
@@ -0,0 +1,175 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const bufferedDraftHookSource = readFileSync(
new URL('../terminal/use-buffered-terminal-drafts.ts', import.meta.url),
'utf8'
)
const keyboardDismissalHookSource = readFileSync(
new URL('./use-agent-send-keyboard-dismissal.ts', import.meta.url),
'utf8'
)
function routeSlice(anchorStart: string, anchorEnd: string): string {
const start = sessionRouteSource.indexOf(anchorStart)
expect(start).toBeGreaterThanOrEqual(0)
// Why: a duplicated start anchor would silently slice the wrong region.
expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1)
const end = sessionRouteSource.indexOf(anchorEnd, start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end + anchorEnd.length)
}
describe('terminal send keyboard dismissal wiring', () => {
it('gates the dismissal on the agent-session predicate', () => {
const slice = routeSlice(
'const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(',
'getSendCompletionGeneration\n )'
)
expect(slice).toContain('dismissSoftwareKeyboard')
expect(keyboardDismissalHookSource).toContain(
'shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)'
)
expect(keyboardDismissalHookSource).toContain(
'origin.generation === getSendCompletionGeneration()'
)
expect(keyboardDismissalHookSource).toContain('dismissSoftwareKeyboard()')
expect(keyboardDismissalHookSource).toContain('return useCallback(')
expect(sessionRouteSource).toContain(
"import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'"
)
})
it('invalidates pending terminal sends when the focused input surface changes', () => {
const slice = routeSlice(
'const getSendCompletionGeneration = useMobileSendCompletionGeneration({',
'})'
)
expect(sessionRouteSource).toContain(
'const routeKey = nativeChatScopeKey ?? `${hostId}\\0${worktreeId}`'
)
expect(slice).toContain(
'surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])'
)
})
it('dismisses after the live input submits, which is the only Enter path', () => {
// terminal-live-input.ts deliberately keeps Enter off the key map, so
// onSubmitEditing is the single send seam for the live field.
const slice = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"')
expect(slice).toContain('generation: getSendCompletionGeneration()')
expect(slice).toContain('const submit = handleLiveInputSubmit()')
expect(slice).toContain('interaction: getLiveInteractionGeneration()')
expect(slice).toContain('sendOrigin.interaction === getLiveInteractionGeneration()')
expect(slice).toContain('dismissKeyboardAfterAgentSend(')
// Explicit dismissal replaces RN's blur, which stays off so a shell send
// does not drop focus.
expect(slice).toContain('blurOnSubmit={false}')
})
it('dismisses the buffered command send only once the write is accepted', () => {
const slice = routeSlice('async function handleSend() {', 'async function handleAccessoryKey(')
const acceptedAt = slice.indexOf('const accepted = isTerminalSendRpcAccepted(response)')
const restoreAt = slice.indexOf('restoreRejectedDraft()', acceptedAt)
const dismissAt = slice.indexOf('dismissKeyboardAfterAgentSend(')
const responseAt = slice.indexOf('const response = await client.sendRequest(')
const catchAt = slice.indexOf('} catch {')
expect(dismissAt).toBeGreaterThan(0)
expect(responseAt).toBeGreaterThan(0)
expect(acceptedAt).toBeGreaterThan(responseAt)
expect(restoreAt).toBeGreaterThan(acceptedAt)
expect(dismissAt).toBeGreaterThan(responseAt)
expect(slice).toContain(
'const draftUnchanged =\n accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)'
)
expect(slice).toContain('dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)')
expect(catchAt).toBeGreaterThan(0)
// Both resolved rejections and transport failures restore the raw draft.
expect(dismissAt).toBeLessThan(catchAt)
expect(slice.slice(catchAt)).not.toContain('dismissKeyboardAfterAgentSend(')
expect(slice.slice(catchAt)).toContain('restoreRejectedDraft()')
})
it('keeps buffered Return focused until accepted-agent dismissal runs', () => {
const slice = routeSlice('ref={commandInputRef}', 'onSubmitEditing={() => void handleSend()}')
expect(slice).toContain('blurOnSubmit={false}')
})
it('restores a rejected buffered draft by origin without generation fencing', () => {
const sendSlice = routeSlice(
'async function handleSend() {',
'async function handleAccessoryKey('
)
const originAt = sendSlice.indexOf('handle: activeHandle')
const requestAt = sendSlice.indexOf('await client.sendRequest(')
const restoreSlice = routeSlice(
'const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(',
'bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)'
)
expect(originAt).toBeGreaterThan(0)
expect(originAt).toBeLessThan(requestAt)
expect(restoreSlice).toContain('activeHandle,\n draft')
expect(bufferedDraftHookSource).not.toContain('getSendCompletionGeneration()')
expect(bufferedDraftHookSource).toContain(
'restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)'
)
expect(sendSlice.match(/restoreRejectedDraft\(\)/g)).toHaveLength(2)
expect(keyboardDismissalHookSource).toContain(
'origin.generation === getSendCompletionGeneration()'
)
})
it('keeps buffered draft callbacks scoped to terminal surfaces and prunes ended tabs', () => {
expect(bufferedDraftHookSource).toContain('const handle = activeHandleRef.current')
expect(bufferedDraftHookSource).toContain('invalidateBufferedTerminalDraftRestoration(')
expect(bufferedDraftHookSource).toContain(
'setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))'
)
expect(bufferedDraftHookSource).toContain('pruneBufferedTerminalDraftRestorations(')
expect(sessionRouteSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)')
expect(sessionRouteSource).toContain(
'reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {'
)
const routeResetSlice = routeSlice(
'// Why: Expo reuses this screen across worktrees;',
'clearDelayedActionTimers()\n }'
)
expect(routeResetSlice).toContain('bufferedTerminalDraftState.resetDrafts()')
expect(routeResetSlice).toContain('bufferedTerminalDraftState.clearPendingRestorations()')
})
it('bounds buffered drafts on the terminal.list sweep, against the retained set', () => {
// The drafts record and the pending-restoration map both live as long as the
// session screen does; this one call is the only thing that bounds either.
const slice = routeSlice(
'const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))',
'setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))'
)
expect(slice).toContain('const retainedHandles = resolveRetainedTerminalHandles(pruneContext)')
expect(slice).toContain('bufferedTerminalDraftState.pruneDrafts(retainedHandles)')
// Not the raw list: terminal.list omits a chat-covered handle while the desktop
// graph reloads, so sweeping drafts against it erases text the user still holds.
expect(slice).not.toContain('pruneDrafts(liveHandles)')
expect(slice.match(/pruneDrafts\(/g)).toHaveLength(1)
expect(bufferedDraftHookSource).toContain(
'setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))'
)
expect(bufferedDraftHookSource).toContain(
'pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)'
)
})
it('leaves the accessory shortcut keys alone, Enter included', () => {
// Why: the accessory bar sits on top of the keyboard — dismissing would
// pull away the very row the user is tapping.
const slice = routeSlice(
'async function handleAccessoryKey(',
'const sendLiveTerminalInput = useCallback('
)
expect(slice).not.toContain('dismissKeyboardAfterAgentSend')
})
})
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
shouldDismissKeyboardAfterTerminalSend,
type AgentSendKeyboardDismissalTab
} from './agent-send-keyboard-dismissal'
function terminalTab(overrides: Partial<AgentSendKeyboardDismissalTab> = {}) {
return { type: 'terminal', title: 'Terminal', ...overrides }
}
describe('shouldDismissKeyboardAfterTerminalSend', () => {
it('dismisses for a live agent session', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: 'claude' } }),
true
)
).toBe(true)
})
it('dismisses off launchAgent before the first agent-status update lands', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(terminalTab({ launchAgent: 'codex' }), true)
).toBe(true)
})
it('keeps the keyboard when an agent send is rejected', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: 'claude' } }),
false
)
).toBe(false)
})
it('keeps the keyboard for a plain shell so back-to-back commands stay typeable', () => {
expect(shouldDismissKeyboardAfterTerminalSend(terminalTab(), true)).toBe(false)
expect(shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: null }), true)).toBe(
false
)
})
it('treats a blank agent label as no agent', () => {
// A truthy-empty agentType would otherwise dismiss on every shell Enter.
expect(
shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: { agentType: '' } }), true)
).toBe(false)
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: ' ' } }),
true
)
).toBe(false)
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
agentStatus: { agentType: null },
launchAgent: null
}),
true
)
).toBe(false)
})
it('falls through to launchAgent only when live status carries no agent', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
agentStatus: { agentType: null },
launchAgent: 'claude'
}),
true
)
).toBe(true)
})
it('never dismisses for non-terminal tabs or a missing tab', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
{
type: 'markdown',
title: 'README.md',
agentStatus: { agentType: 'claude' }
},
true
)
).toBe(false)
expect(shouldDismissKeyboardAfterTerminalSend(null, true)).toBe(false)
expect(shouldDismissKeyboardAfterTerminalSend(undefined, true)).toBe(false)
})
it('does not authorize dismissal from unknown status or a display-only title', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: 'unknown' } }),
true
)
).toBe(false)
expect(
shouldDismissKeyboardAfterTerminalSend(terminalTab({ title: '✦ Gemini CLI' }), true)
).toBe(false)
})
it.each(['zsh', 'bash', 'pwsh'])(
'keeps the keyboard when identity-only done status is stale under %s',
(title) => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
title,
agentStatus: { agentType: 'claude', state: 'done' },
launchAgent: 'claude'
}),
true
)
).toBe(false)
}
)
it('still dismisses for a completed agent under a non-shell title', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
title: 'Terminal',
agentStatus: { agentType: 'claude', state: 'done' }
}),
true
)
).toBe(true)
})
})
@@ -0,0 +1,39 @@
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import { isClaudeManagementTitle } from '../../../src/shared/agent-title-core'
import { isShellProcess } from '../../../src/shared/shell-process-detection'
import { resolveMobileTerminalTabOwnedAgentId } from './mobile-terminal-tab-agent'
/** Minimal session-tab shape needed to tell an agent session from a plain shell. */
export type AgentSendKeyboardDismissalTab = {
readonly type: string
readonly title: string
readonly agentStatus?: {
readonly agentType?: AgentStatusEntry['agentType'] | null
readonly state?: AgentStatusEntry['state']
} | null
readonly launchAgent?: TuiAgent | null
}
/** Whether a send from this tab should drop the software keyboard.
*
* Why: sending to an agent hands the turn over, and the keyboard hides the
* reply the user is now waiting on. A plain shell keeps it — commands come in
* bursts, and re-opening the keyboard between each one costs more than the
* covered rows. `launchAgent` counts before the first agent-status update
* lands, so the very first accepted prompt of a session already dismisses. */
export function shouldDismissKeyboardAfterTerminalSend(
tab: AgentSendKeyboardDismissalTab | null | undefined,
accepted: boolean
): boolean {
if (!accepted || !tab || tab.type !== 'terminal') {
return false
}
if (
tab.agentStatus?.state === 'done' &&
(isShellProcess(tab.title) || isClaudeManagementTitle(tab.title))
) {
return false
}
return resolveMobileTerminalTabOwnedAgentId(tab) !== null
}
@@ -21,6 +21,7 @@ export type MobileNativeChatController = {
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
getChatComposerEditGeneration: () => number
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
@@ -0,0 +1,19 @@
export class MobileNativeChatDraftEditGenerations {
private composerGeneration = 0
private readonly byDraft = new Map<string, number>()
advance(draftKey: string): void {
this.composerGeneration += 1
this.byDraft.set(draftKey, this.readDraft(draftKey) + 1)
}
readonly readComposer = (): number => this.composerGeneration
readDraft(draftKey: string): number {
return this.byDraft.get(draftKey) ?? 0
}
isCurrent(draftKey: string, generation: number): boolean {
return this.readDraft(draftKey) === generation
}
}
@@ -20,6 +20,7 @@ function sendOrigin(
const normalizedText = normalizeReconcileText(text)
return {
draftKey: 'host\0worktree\0tab',
draftEditGeneration: 0,
pendingKey: 'host\0worktree\0tab\0session',
normalizedText,
// Use production's counter so the test cannot mirror its normalization drift.
@@ -16,6 +16,7 @@ export type MobileNativeChatPendingMessage = {
export type MobileNativeChatSendOrigin = {
draftKey: string
draftEditGeneration: number
pendingKey: string | null
normalizedText: string
baselineOccurrences: number
@@ -13,6 +13,7 @@ describe('mobile session last-tab close', () => {
const block = sessionRouteSource.slice(start, end)
expect(block).toContain('} else if (active) {')
expect(block).toContain('retainMissingSurfaces: result.tabs.length === 0')
})
it('clears stale active identity when closing leaves no tabs', () => {
@@ -25,5 +26,8 @@ describe('mobile session last-tab close', () => {
)
expect(block).toContain('activeSessionTabIdRef.current = null')
expect(block).toContain('activeHandleRef.current = null')
expect(block).toContain(
'reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)'
)
})
})
@@ -4,7 +4,8 @@ import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { MobileSessionTab } from './mobile-session-route-types'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
resolveMobileTerminalTabAgentId,
resolveMobileTerminalTabOwnedAgentId
} from './mobile-terminal-tab-agent'
function agentStatus(agentType: string | undefined): AgentStatusEntry {
@@ -75,6 +76,15 @@ describe('resolveMobileTerminalTabAgentId', () => {
})
})
describe('resolveMobileTerminalTabOwnedAgentId', () => {
it('excludes display-only terminal titles from behavioral authority', () => {
expect(resolveMobileTerminalTabOwnedAgentId(terminalTab('✦ Gemini CLI'))).toBeNull()
expect(
resolveMobileTerminalTabOwnedAgentId(terminalTab('Terminal', { launchAgent: 'gemini' }))
).toBe('gemini')
})
})
describe('getMobileSessionTabTitle', () => {
it('strips leading agent decorations when an icon is shown', () => {
expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('Gemini CLI')
@@ -15,11 +15,16 @@ import type { MobileSessionTab } from './mobile-session-route-types'
* Returns null when no agent is identified (plain shell / unknown), so the tab
* keeps its text-only label.
*/
export function resolveMobileTerminalTabAgentId(tab: {
type MobileTerminalTabAgentIdentity = {
title: string
agentStatus?: AgentStatusEntry | null
launchAgent?: TuiAgent
}): string | null {
agentStatus?: { agentType?: AgentStatusEntry['agentType'] | null } | null
launchAgent?: TuiAgent | null
}
/** Agent identity Orca owns, excluding the display-only title fallback. */
export function resolveMobileTerminalTabOwnedAgentId(
tab: MobileTerminalTabAgentIdentity
): string | null {
const hookAgentType = tab.agentStatus?.agentType?.trim()
if (hookAgentType && hookAgentType !== 'unknown') {
return hookAgentType
@@ -27,6 +32,16 @@ export function resolveMobileTerminalTabAgentId(tab: {
if (tab.launchAgent) {
return tab.launchAgent
}
return null
}
export function resolveMobileTerminalTabAgentId(
tab: MobileTerminalTabAgentIdentity
): string | null {
const ownedAgent = resolveMobileTerminalTabOwnedAgentId(tab)
if (ownedAgent) {
return ownedAgent
}
return resolveExplicitTerminalTitleAgentType(tab.title)
}
@@ -0,0 +1,27 @@
import { useCallback } from 'react'
import {
shouldDismissKeyboardAfterTerminalSend,
type AgentSendKeyboardDismissalTab
} from './agent-send-keyboard-dismissal'
type AgentSendOrigin = {
readonly tab: AgentSendKeyboardDismissalTab | null
readonly generation: number
}
export function useAgentSendKeyboardDismissal(
dismissSoftwareKeyboard: () => void,
getSendCompletionGeneration: () => number
) {
return useCallback(
(origin: AgentSendOrigin, accepted: boolean): void => {
if (
origin.generation === getSendCompletionGeneration() &&
shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)
) {
dismissSoftwareKeyboard()
}
},
[dismissSoftwareKeyboard, getSendCompletionGeneration]
)
}
@@ -93,6 +93,7 @@ const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
const ORIGIN = {
draftKey: 'h\0w\0tab-1',
draftEditGeneration: 0,
pendingKey: 'h\0w\0tab-1\0session-1',
normalizedText: 'look',
baselineOccurrences: 0,
@@ -90,6 +90,7 @@ export function useMobileNativeChatController(args: {
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
getComposerEditGeneration: getChatComposerEditGeneration,
pending: chatPending,
imagePreviewsByMessageId: chatImagePreviewsByMessageId,
captureSendOrigin,
@@ -262,6 +263,7 @@ export function useMobileNativeChatController(args: {
nativeChatAgent: activeChatResolution?.agent ?? null,
chatComposerText,
setChatComposerText,
getChatComposerEditGeneration,
chatPending,
chatImagePreviewsByMessageId,
nativeChatSession,
@@ -115,6 +115,20 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.composerText).toBe('')
})
it('tracks every composer mutation with a stable route-owned generation', async () => {
await mount('a')
const getter = state!.getComposerEditGeneration
const initialGeneration = getter()
act(() => state?.setComposerText('typed'))
expect(getter()).toBe(initialGeneration + 1)
await switchTo('b')
expect(state?.getComposerEditGeneration).toBe(getter)
act(() => state?.setComposerText((current) => `${current} dictated`))
expect(getter()).toBe(initialGeneration + 2)
})
it('restores the text on a definite rejection', async () => {
await mount('a')
act(() => state?.setComposerText('ping'))
@@ -146,6 +160,26 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.composerText).toBe('newer edit')
})
it('preserves an intentional clear after a newer edit while a rejection is pending', async () => {
await mount('a')
act(() => state?.setComposerText('ping'))
const origin = state?.captureSendOrigin('ping')
act(() => {
if (origin) {
state?.clearDraftForSend(origin, 'ping')
}
})
act(() => state?.setComposerText('newer edit'))
act(() => state?.setComposerText(''))
act(() => {
if (origin) {
state?.restoreRejectedDraft(origin, 'ping')
}
})
expect(state?.composerText).toBe('')
})
it('restores a rejected send onto its originating tab only', async () => {
await mount('a')
act(() => state?.setComposerText('from a'))
@@ -157,12 +191,13 @@ describe('useMobileNativeChatDrafts', () => {
})
await switchTo('b')
act(() => state?.setComposerText('from b'))
act(() => {
if (originA) {
state?.restoreRejectedDraft(originA, 'from a')
}
})
expect(state?.composerText).toBe('')
expect(state?.composerText).toBe('from b')
await switchTo('a')
expect(state?.composerText).toBe('from a')
@@ -443,6 +478,20 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.composerText).toBe('new edit')
})
it('does not erase a whitespace-only newer edit when an older send clears', async () => {
await mount('a')
act(() => state?.setComposerText('/clear'))
const origin = state?.captureSendOrigin('/clear')
act(() => state?.setComposerText(' /clear'))
act(() => {
if (origin) {
state?.clearDraftForSend(origin, '/clear')
}
})
expect(state?.composerText).toBe(' /clear')
})
it('stays quiet when an unconfirmed send lands in the transcript', async () => {
vi.useFakeTimers()
try {
@@ -22,6 +22,7 @@ import {
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { MobileNativeChatDraftEditGenerations } from './mobile-native-chat-draft-edit-generations'
export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin }
@@ -54,6 +55,7 @@ export function useMobileNativeChatDrafts(args: {
}): {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
getComposerEditGeneration: () => number
pending: MobileNativeChatPendingMessage[]
/** Phone-local previews rebound to the transcript message that replaced the
* optimistic echo, keyed by authoritative message id. */
@@ -100,6 +102,7 @@ export function useMobileNativeChatDrafts(args: {
Record<string, Record<string, string[]>>
>({})
const pendingCounterRef = useRef(0)
const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations())
const messagesRef = useRef(messages)
messagesRef.current = messages
const activeDraftKeyRef = useRef(draftKey)
@@ -123,6 +126,7 @@ export function useMobileNativeChatDrafts(args: {
if (!draftKey) {
return
}
draftEditGenerationsRef.current.advance(draftKey)
setDrafts((previous) => {
const current = previous[draftKey] ?? ''
const next = typeof value === 'function' ? value(current) : value
@@ -131,7 +135,6 @@ export function useMobileNativeChatDrafts(args: {
},
[draftKey]
)
const captureSendOrigin = useCallback(
(text: string) => {
if (!draftKey) {
@@ -140,6 +143,7 @@ export function useMobileNativeChatDrafts(args: {
const normalizedText = normalizeReconcileText(text)
return {
draftKey,
draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey),
pendingKey,
normalizedText,
baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText),
@@ -158,7 +162,8 @@ export function useMobileNativeChatDrafts(args: {
// send". Clear at send time; a definite rejection restores the text below.
const clearDraftForSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
setDrafts((previous) =>
(previous[origin.draftKey] ?? '').trim() === text.trim()
draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) &&
(previous[origin.draftKey] ?? '') === text
? { ...previous, [origin.draftKey]: '' }
: previous
)
@@ -167,7 +172,10 @@ export function useMobileNativeChatDrafts(args: {
const restoreRejectedDraft = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
// Why: never clobber text the user typed while the rejection was in flight.
setDrafts((previous) =>
(previous[origin.draftKey] ?? '') === '' ? { ...previous, [origin.draftKey]: text } : previous
draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) &&
(previous[origin.draftKey] ?? '') === ''
? { ...previous, [origin.draftKey]: text }
: previous
)
}, [])
@@ -269,9 +277,7 @@ export function useMobileNativeChatDrafts(args: {
return
}
const movedIds = new Set(waitingForSession.map((item) => item.id))
setPendingBySession((previous) =>
mergeWaitingSessionPending(previous, pendingKey, waitingForSession)
)
setPendingBySession((state) => mergeWaitingSessionPending(state, pendingKey, waitingForSession))
setPendingWaitingForSession((previous) =>
removeWaitingSessionPending(previous, draftKey, movedIds)
)
@@ -330,6 +336,7 @@ export function useMobileNativeChatDrafts(args: {
return {
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
setComposerText,
getComposerEditGeneration: draftEditGenerationsRef.current.readComposer,
pending,
imagePreviewsByMessageId: pendingKey
? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS)
@@ -0,0 +1,78 @@
import { createElement, Suspense } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation'
let focusCleanup: (() => void) | undefined
vi.mock('expo-router', () => ({
useFocusEffect: (effect: () => void | (() => void)) => {
focusCleanup = effect() ?? undefined
}
}))
describe('mobile send completion generation', () => {
it('invalidates completions on a surface change and retained-route blur', () => {
const onBlur = vi.fn()
let getGeneration: (() => number) | null = null
let renderer: ReactTestRenderer
function Harness({ surfaceKey }: { surfaceKey: string }): null {
getGeneration = useMobileSendCompletionGeneration({ onBlur, surfaceKey })
return null
}
act(() => {
renderer = create(createElement(Harness, { surfaceKey: 'tab-a' }))
})
const initialGeneration = getGeneration!()
act(() => {
renderer.update(createElement(Harness, { surfaceKey: 'tab-b' }))
})
expect(getGeneration!()).toBeGreaterThan(initialGeneration)
const surfaceGeneration = getGeneration!()
act(() => focusCleanup?.())
expect(getGeneration!()).toBeGreaterThan(surfaceGeneration)
expect(onBlur).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('does not invalidate the committed surface during a suspended render', () => {
const never = new Promise<never>(() => undefined)
let getGeneration: (() => number) | null = null
let renderer: ReactTestRenderer
function Harness({ surfaceKey, suspend }: { surfaceKey: string; suspend: boolean }): null {
getGeneration = useMobileSendCompletionGeneration({ onBlur: vi.fn(), surfaceKey })
if (suspend) {
throw never
}
return null
}
act(() => {
renderer = create(
createElement(
Suspense,
{ fallback: null },
createElement(Harness, { surfaceKey: 'tab-a', suspend: false })
)
)
})
const committedGeneration = getGeneration!()
act(() => {
renderer.update(
createElement(
Suspense,
{ fallback: null },
createElement(Harness, { surfaceKey: 'tab-b', suspend: true })
)
)
})
expect(getGeneration!()).toBe(committedGeneration)
act(() => renderer.unmount())
})
})
@@ -0,0 +1,28 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useFocusEffect } from 'expo-router'
type Options = {
readonly onBlur: () => void
readonly surfaceKey: string
}
/** Fences async send completions after a surface change or retained-route blur. */
export function useMobileSendCompletionGeneration({ onBlur, surfaceKey }: Options): () => number {
const generationRef = useRef(0)
const surfaceRef = useRef(surfaceKey)
useLayoutEffect(() => {
if (surfaceRef.current !== surfaceKey) {
surfaceRef.current = surfaceKey
generationRef.current += 1
}
}, [surfaceKey])
useFocusEffect(
useCallback(() => {
return () => {
generationRef.current += 1
onBlur()
}
}, [onBlur])
)
return useCallback(() => generationRef.current, [])
}
@@ -0,0 +1,377 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
SafeAreaView,
View,
Text,
Pressable,
AlertTriangle,
colors,
ActionSheetModal,
GitBranch,
ConfirmModal
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
getHostedReviewMergeMethodLabel,
getHostedMergeConfirmMessage,
getProjectGitHubMergeConfirmMessage,
getHostedStateConfirmTitle,
getHostedStateConfirmMessage,
getHostedStateConfirmLabel
} from './mobile-tasks-legacy-foundation'
import { renderMobileTasksChrome } from './mobile-tasks-screen-chrome'
import { renderMobileTasksListSurface } from './mobile-tasks-list-surface'
import {
renderMobileTasksProviderPicker,
renderMobileTasksRepoPicker,
renderMobileTasksGitHubIssueSourcePicker,
renderMobileTasksGitHubViewPicker,
renderMobileTasksGitHubPresetPicker,
renderMobileTasksPagePicker
} from './mobile-tasks-provider-pickers'
import {
renderMobileTasksGitHubProjectPicker,
renderMobileTasksGitHubProjectViewPicker,
renderMobileTasksGitHubProjectSortPicker
} from './mobile-tasks-github-project-pickers'
import {
renderMobileTasksGitHubProjectFieldsPicker,
renderMobileTasksGitLabViewPicker,
renderMobileTasksGitLabFilterPicker,
renderMobileTasksLinearFilterPicker,
renderMobileTasksLinearWorkspacePicker,
renderMobileTasksLinearTeamPicker,
renderMobileTasksLinearStatusPicker
} from './mobile-tasks-filter-pickers'
import {
renderMobileTasksLinearViewPicker,
renderMobileTasksLinearGroupPicker,
renderMobileTasksLinearOrderPicker,
renderMobileTasksLinearDisplayPicker,
renderMobileTasksSortPicker
} from './mobile-tasks-list-display-pickers'
import {
renderMobileTasksCreateDrawer,
renderMobileTasksCreateTargetPicker,
renderMobileTasksLinearConnectDrawer,
renderMobileTasksWorkspaceCreateTargetPicker
} from './mobile-tasks-create-drawers'
import {
renderMobileTasksWorkspaceCreateDrawer,
renderMobileTasksWorkspaceCreateRepoPicker,
renderMobileTasksWorkspaceAgentPicker
} from './mobile-tasks-workspace-create-drawer'
import {
renderMobileTasksWorkspaceBaseBranchPicker,
renderMobileTasksWorkspaceSparsePicker
} from './mobile-tasks-workspace-option-pickers'
import {
renderMobileTasksWorkspaceSparseDrawer,
renderMobileTasksSetupTrustDrawer,
renderMobileTasksOrcaYamlTrustDrawer
} from './mobile-tasks-workspace-trust-drawers'
import {
renderMobileTasksProjectMissingRepoDrawer,
renderMobileTasksProjectDetailDrawer
} from './mobile-tasks-project-detail-drawer'
import { renderMobileTasksItemDetailDrawer } from './mobile-tasks-item-detail-drawer'
export function MobileTasksLegacySurface({ model }: { model: ConnectionPresentationModel }) {
const {
error,
githubMode,
githubProjectTable,
githubSourceErrors,
githubSourceFallbacks,
loading,
mergeHostedReview,
mergeMethodProjectRow,
mergeMethodTaskItem,
mergeProjectGitHubPullRequest,
mutateProjectRowIssueOrPr,
pendingHostedMerge,
pendingHostedStateChange,
pendingProjectGitHubMerge,
provider,
retryGitHubIssueSourceFetch,
retryingGithubSourceRepoPaths,
setMergeMethodProjectRow,
setMergeMethodTaskItem,
setPendingHostedMerge,
setPendingHostedStateChange,
setPendingProjectGitHubMerge,
taskUiReady,
toggleGitHubStatus,
toggleGitLabStatus
} = model
return (
<SafeAreaView style={styles.container} edges={['top']}>
{renderMobileTasksChrome(model)}
{error ? (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
{!error && provider === 'github' && githubMode === 'items'
? githubSourceFallbacks.map((fallback) => (
<View
key={`github-source-fallback:${fallback.repoId}`}
style={styles.sourceNoticeBanner}
>
<Text style={styles.sourceNoticeText}>
Preferred issue source upstream is unavailable for {fallback.repoLabel}. Using
origin.
</Text>
</View>
))
: null}
{!error && provider === 'github' && githubMode === 'items'
? githubSourceErrors.map((sourceError) => {
const isRetrying = retryingGithubSourceRepoPaths.has(sourceError.repoPath)
return (
<View
key={`github-source-error:${sourceError.repoId}:${sourceError.source.owner}/${sourceError.source.repo}`}
style={styles.sourceErrorBanner}
>
<View style={styles.sourceErrorCopy}>
<Text style={styles.sourceErrorText}>
Couldn't load issues from{' '}
<Text style={styles.sourceErrorSlug}>
{sourceError.source.owner}/{sourceError.source.repo}
</Text>
.
</Text>
<Text style={styles.sourceErrorMessage} numberOfLines={2}>
{sourceError.message}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Retry loading issues from ${sourceError.source.owner}/${sourceError.source.repo}`}
style={styles.sourceErrorRetry}
disabled={loading || isRetrying}
onPress={() => void retryGitHubIssueSourceFetch(sourceError.repoPath)}
>
<Text style={styles.sourceErrorRetryText}>
{isRetrying ? 'Retrying...' : 'Retry'}
</Text>
</Pressable>
</View>
)
})
: null}
{!error &&
provider === 'github' &&
githubMode === 'project' &&
githubProjectTable?.parentFieldDropped === true ? (
<View style={styles.projectDataNotice}>
<AlertTriangle size={15} color={colors.statusAmber} />
<Text style={styles.projectDataNoticeText}>
Sub-issue data is unavailable for your token.
</Text>
</View>
) : null}
{renderMobileTasksListSurface(model)}
{renderMobileTasksProviderPicker(model)}
{renderMobileTasksRepoPicker(model)}
{renderMobileTasksGitHubIssueSourcePicker(model)}
{renderMobileTasksGitHubViewPicker(model)}
{renderMobileTasksGitHubPresetPicker(model)}
{renderMobileTasksPagePicker(model)}
{renderMobileTasksGitHubProjectPicker(model)}
{renderMobileTasksGitHubProjectViewPicker(model)}
{renderMobileTasksGitHubProjectSortPicker(model)}
{renderMobileTasksGitHubProjectFieldsPicker(model)}
{renderMobileTasksGitLabViewPicker(model)}
{renderMobileTasksGitLabFilterPicker(model)}
{renderMobileTasksLinearFilterPicker(model)}
{renderMobileTasksLinearWorkspacePicker(model)}
{renderMobileTasksLinearTeamPicker(model)}
{renderMobileTasksLinearStatusPicker(model)}
{renderMobileTasksLinearViewPicker(model)}
{renderMobileTasksLinearGroupPicker(model)}
{renderMobileTasksLinearOrderPicker(model)}
{renderMobileTasksLinearDisplayPicker(model)}
{renderMobileTasksSortPicker(model)}
{renderMobileTasksCreateDrawer(model)}
{renderMobileTasksCreateTargetPicker(model)}
{renderMobileTasksLinearConnectDrawer(model)}
{renderMobileTasksWorkspaceCreateTargetPicker(model)}
{renderMobileTasksWorkspaceCreateDrawer(model)}
{renderMobileTasksWorkspaceCreateRepoPicker(model)}
{renderMobileTasksWorkspaceAgentPicker(model)}
{renderMobileTasksWorkspaceBaseBranchPicker(model)}
{renderMobileTasksWorkspaceSparsePicker(model)}
{renderMobileTasksWorkspaceSparseDrawer(model)}
{renderMobileTasksSetupTrustDrawer(model)}
{renderMobileTasksOrcaYamlTrustDrawer(model)}
{renderMobileTasksProjectMissingRepoDrawer(model)}
{renderMobileTasksProjectDetailDrawer(model)}
{renderMobileTasksItemDetailDrawer(model)}
<ActionSheetModal
visible={taskUiReady && mergeMethodProjectRow != null}
title="Merge method"
message="Choose how this pull request should be merged."
actions={
mergeMethodProjectRow
? (['squash', 'merge', 'rebase'] as const).map((method) => ({
label: getHostedReviewMergeMethodLabel(method),
icon: GitBranch,
onPress: () => {
setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method })
}
}))
: []
}
onClose={() => setMergeMethodProjectRow(null)}
/>
<ActionSheetModal
visible={taskUiReady && mergeMethodTaskItem != null}
title="Merge method"
message={
mergeMethodTaskItem?.provider === 'gitlab'
? 'Choose how this merge request should be merged.'
: 'Choose how this pull request should be merged.'
}
actions={
mergeMethodTaskItem
? (mergeMethodTaskItem.provider === 'gitlab'
? (['merge', 'squash', 'rebase'] as const)
: (['squash', 'merge', 'rebase'] as const)
).map((method) => ({
label:
mergeMethodTaskItem.provider === 'gitlab' && method === 'merge'
? 'Merge'
: getHostedReviewMergeMethodLabel(method),
icon: GitBranch,
onPress: () => {
const item = mergeMethodTaskItem
setPendingHostedMerge({ item, method })
}
}))
: []
}
onClose={() => setMergeMethodTaskItem(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingHostedMerge != null}
title={
pendingHostedMerge?.item.provider === 'gitlab' ? 'Merge Request' : 'Merge Pull Request'
}
message={pendingHostedMerge ? getHostedMergeConfirmMessage(pendingHostedMerge) : undefined}
confirmLabel={
pendingHostedMerge ? getHostedReviewMergeMethodLabel(pendingHostedMerge.method) : 'Merge'
}
onConfirm={() => {
if (!taskUiReady || !pendingHostedMerge) {
return
}
void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method)
}}
onCancel={() => setPendingHostedMerge(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingProjectGitHubMerge != null}
title="Merge Pull Request"
message={
pendingProjectGitHubMerge
? getProjectGitHubMergeConfirmMessage(pendingProjectGitHubMerge)
: undefined
}
confirmLabel={
pendingProjectGitHubMerge
? getHostedReviewMergeMethodLabel(pendingProjectGitHubMerge.method)
: 'Merge'
}
onConfirm={() => {
if (!taskUiReady || !pendingProjectGitHubMerge) {
return
}
void mergeProjectGitHubPullRequest(
pendingProjectGitHubMerge.row,
pendingProjectGitHubMerge.method
)
}}
onCancel={() => setPendingProjectGitHubMerge(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingHostedStateChange != null}
title={
pendingHostedStateChange
? getHostedStateConfirmTitle(pendingHostedStateChange)
: 'Update Item'
}
message={
pendingHostedStateChange
? getHostedStateConfirmMessage(pendingHostedStateChange)
: undefined
}
confirmLabel={
pendingHostedStateChange
? getHostedStateConfirmLabel(pendingHostedStateChange)
: 'Confirm'
}
destructive={pendingHostedStateChange?.nextState === 'closed'}
onConfirm={() => {
if (!taskUiReady || !pendingHostedStateChange) {
return
}
if (pendingHostedStateChange.source === 'task') {
if (pendingHostedStateChange.item.provider === 'gitlab') {
void toggleGitLabStatus(pendingHostedStateChange.item)
return
}
void toggleGitHubStatus(pendingHostedStateChange.item)
return
}
void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, {
state: pendingHostedStateChange.nextState
})
}}
onCancel={() => setPendingHostedStateChange(null)}
/>
</SafeAreaView>
)
}
+135
View File
@@ -0,0 +1,135 @@
import {
type GitHubPrFileDiffLine,
type ReactNode,
useMemo,
buildGitHubPrFileDiffPreview,
resolveMobileSyntaxLanguage,
highlightMobileDiffLines,
Text,
View,
MobileSyntaxSegments,
TextInput,
colors,
Pressable
} from './mobile-tasks-dependencies'
import { MAX_RENDERED_PR_DIFF_LINES } from './mobile-tasks-options'
import type { GitHubPRFileContents } from './mobile-tasks-provider-detail-types'
import { styles } from './mobile-tasks-legacy-styles'
export function formatDiffLineNumber(value: number | undefined): string {
return value === undefined ? ' ' : value.toString().padStart(4, ' ')
}
export function diffLinePrefix(kind: GitHubPrFileDiffLine['kind']): string {
if (kind === 'added') {
return '+'
}
if (kind === 'removed') {
return '-'
}
return ' '
}
export function GitHubPrFileDiff({
filePath,
contents,
commentDrafts,
disabled,
onCommentDraftChange,
onSubmitComment
}: {
filePath: string
contents: GitHubPRFileContents
commentDrafts: Record<string, string>
disabled: boolean
onCommentDraftChange: (key: string, value: string) => void
onSubmitComment: (line: number) => void
}): ReactNode {
const diffPreview = useMemo(
() =>
buildGitHubPrFileDiffPreview(
contents.original,
contents.modified,
MAX_RENDERED_PR_DIFF_LINES
),
[contents.modified, contents.original]
)
const syntaxLanguage = useMemo(() => resolveMobileSyntaxLanguage(filePath), [filePath])
const visibleDiffLines = useMemo(
() => highlightMobileDiffLines(diffPreview.lines, syntaxLanguage),
[diffPreview.lines, syntaxLanguage]
)
const hiddenDiffLineCount = Math.max(0, diffPreview.totalLineCount - visibleDiffLines.length)
if (diffPreview.totalLineCount === 0) {
return <Text style={styles.detailMuted}>No text changes found.</Text>
}
return (
<View style={styles.fileDiff}>
{hiddenDiffLineCount > 0 ? (
<Text style={styles.detailMuted}>
Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffPreview.totalLineCount} diff lines.
</Text>
) : null}
{visibleDiffLines.map((line) => {
const commentLine = line.kind === 'removed' ? undefined : line.newLineNumber
const draftKey = commentLine === undefined ? '' : `${filePath}:${commentLine}`
return (
<View
key={line.key}
style={[
styles.diffLineBlock,
line.kind === 'added'
? styles.diffLineAdded
: line.kind === 'removed'
? styles.diffLineRemoved
: null
]}
>
<View style={styles.diffCodeRow}>
<Text style={styles.diffLineNumbers}>
{formatDiffLineNumber(line.oldLineNumber)}{' '}
{formatDiffLineNumber(line.newLineNumber)}
</Text>
<Text
style={[
styles.codeLine,
line.kind === 'added'
? styles.diffCodeAdded
: line.kind === 'removed'
? styles.diffCodeRemoved
: null
]}
>
<Text>{diffLinePrefix(line.kind)} </Text>
<MobileSyntaxSegments segments={line.segments} />
{line.text ? null : ' '}
</Text>
</View>
{commentLine !== undefined ? (
<>
<TextInput
style={[styles.input, styles.replyInput]}
value={commentDrafts[draftKey] ?? ''}
onChangeText={(next) => onCommentDraftChange(draftKey, next)}
placeholder="Add review comment"
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={disabled || !(commentDrafts[draftKey] ?? '').trim()}
onPress={() => onSubmitComment(commentLine)}
>
<Text style={styles.inlineSaveText}>Comment on line {commentLine}</Text>
</Pressable>
</>
) : null}
</View>
)
})}
</View>
)
}
@@ -1,7 +1,17 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../../app/h/[hostId]/tasks.tsx', import.meta.url), 'utf8')
const readSource = (path: string): string => readFileSync(new URL(path, import.meta.url), 'utf8')
const source = [
readSource('./use-mobile-tasks-project-loading-actions.tsx'),
readSource('./use-mobile-tasks-project-workspace-comment-actions.tsx'),
readSource('./use-mobile-tasks-project-thread-reply-actions.tsx'),
readSource('./use-mobile-tasks-project-detail-loading.tsx'),
readSource('./use-mobile-tasks-project-metadata-actions.tsx'),
readSource('./use-mobile-tasks-project-metadata-loading.tsx'),
readSource('./use-mobile-tasks-project-review-check-actions.tsx'),
readSource('./use-mobile-tasks-project-file-merge-actions.tsx')
].join('\n')
describe('mobile GitHub Project host routing boundary', () => {
it('host-qualifies every Project RPC request', () => {
@@ -1,25 +1,49 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
/** Wiring assertions only. The repo-list behaviour itself (caching, client
* scoping, stale responses, retry) is covered behaviourally in
* host-repo-list.test.ts and use-host-repo-list.test.tsx. What cannot be
* reached from there is how this 15k-line route consumes the resource, so
* these pin the consumption points the original bug lived in. */
const source = readFileSync(new URL('../../app/h/[hostId]/tasks.tsx', import.meta.url), 'utf8')
/** Wiring assertions only. Keep these anchored to the modules that own each path. */
const readSource = (path: string): string => readFileSync(new URL(path, import.meta.url), 'utf8')
const taskListSource = readSource('./use-mobile-tasks-task-list-loading.tsx')
const projectProjectionSource = readSource('./use-mobile-tasks-project-projection.tsx')
const projectLoadingSource = readSource('./use-mobile-tasks-project-loading-actions.tsx')
const projectEffectsSource = readSource('./use-mobile-tasks-list-and-detail-effects.tsx')
const projectListSource = readSource('./mobile-tasks-github-project-list.tsx')
const providerItemListSource = readSource('./mobile-tasks-provider-item-list.tsx')
const screenChromeSource = readSource('./mobile-tasks-screen-chrome.tsx')
const routeAndItemStateSource = readSource('./use-mobile-tasks-route-and-item-state.tsx')
const repositoryResolutionSource = readSource(
'./use-mobile-tasks-project-repository-resolution.tsx'
)
const clientSettingsSource = readSource('./use-mobile-tasks-client-settings-actions.tsx')
const source = [
taskListSource,
projectProjectionSource,
projectLoadingSource,
projectEffectsSource,
projectListSource,
providerItemListSource,
screenChromeSource,
routeAndItemStateSource,
repositoryResolutionSource,
clientSettingsSource
].join('\n')
/** Why: `slice(start, -1)` silently trims one byte instead of failing, so a
* missing end marker would let every assertion below pass vacuously. */
function block(startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker)
expect(start, `tasks.tsx must contain ${startMarker}`).toBeGreaterThan(-1)
const end = source.indexOf(endMarker, start)
function block(input: string, startMarker: string, endMarker: string): string {
const start = input.indexOf(startMarker)
expect(start, `source must contain ${startMarker}`).toBeGreaterThan(-1)
const end = input.indexOf(endMarker, start)
expect(end, `${startMarker} must be followed by ${endMarker}`).toBeGreaterThan(start)
return source.slice(start, end)
return input.slice(start, end)
}
function loadTasksBody(): string {
return block('const loadTasks = useCallback(', 'const connectLinearAccount = useCallback(')
return block(
taskListSource,
'const loadTasks = useCallback(',
' return Object.assign(model, { loadTasks })'
)
}
describe('mobile GitHub Project repo list loading', () => {
@@ -61,7 +85,11 @@ describe('mobile GitHub Project readiness and refresh', () => {
// `every` is vacuously true on an empty list, so readiness must ask the
// resource whether that list is real yet rather than infer it.
it('derives slug readiness from the resource status', () => {
const body = block('const githubProjectRepoSlugReady = useMemo(', ' )')
const body = block(
projectProjectionSource,
'const githubProjectRepoSlugReady = useMemo(',
' )'
)
expect(body).toContain('hasSettledHostRepoList(repoList.state)')
expect(body).toContain('[githubRepoSlugCache, hostedRepos, repoList.state]')
})
@@ -70,19 +98,21 @@ describe('mobile GitHub Project readiness and refresh', () => {
// this the board renders "No project items" before the repo list arrives, and
// the memo assertion above stays green.
it('gates the empty state on readiness in the renderer', () => {
const gate = block(
"githubMode === 'project' ? (",
'<Text style={styles.emptyText}>No project items</Text>'
)
expect(gate, 'the empty state must sit behind the readiness spinner').toContain(
expect(projectListSource, 'the empty state must sit behind the readiness spinner').toContain(
'githubProjectTable && !githubProjectRepoSlugReady ? ('
)
})
it('re-reads the host and retries failed slug lookups on refresh', () => {
expect(block('const refreshTasks = useCallback(', '}, [')).toContain('repoListReload()')
expect(block(projectLoadingSource, 'const refreshTasks = useCallback(', '}, [')).toContain(
'repoListReload()'
)
const projectBody = block('const refreshGitHubProject = useCallback(', '}, [')
const projectBody = block(
projectEffectsSource,
'const refreshGitHubProject = useCallback(',
'}, ['
)
expect(projectBody).toContain('dropFailedGitHubRepoSlugEntries')
expect(projectBody).toContain('refreshTasks()')
expect(projectBody).toContain('loadGitHubProjectTable(')
@@ -103,7 +133,7 @@ describe('mobile GitHub Project readiness and refresh', () => {
// Regression: Expo reuses this screen for the next host, so an effect-based
// reset runs a render too late and the previous host's rows show through.
it('clears the other client-scoped caches during render', () => {
const body = block('if (boundClient !== client) {', '\n }')
const body = block(clientSettingsSource, 'if (boundClient !== client) {', '\n }')
expect(body).toContain('setItems([])')
expect(body).toContain('setGithubRepoSlugCache({})')
expect(body, 'a ref write here would leak from an abandoned render').not.toContain('.current =')
@@ -111,7 +141,7 @@ describe('mobile GitHub Project readiness and refresh', () => {
// ...and the ref half belongs in the commit phase, for the same reason.
it('resets the selection-hydration ref in the commit phase', () => {
expect(block('clientRef.current = client', '}, [client])')).toContain(
expect(block(clientSettingsSource, 'clientRef.current = client', '}, [client])')).toContain(
'repoSelectionHydratedRef.current = false'
)
})
+12 -31
View File
@@ -1,43 +1,24 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { TUI_AGENT_CONFIG } from '../../../src/shared/tui-agent-config'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import { TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/shared/tui-agent-selection'
import { MOBILE_AGENT_CATALOG } from './mobile-agent-catalog'
import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from './mobile-tui-agents'
const currentDir = import.meta.dirname
function readDesktopSharedFile(relativePath: string): string {
return readFileSync(resolve(currentDir, '../../../src/shared', relativePath), 'utf8')
}
function parseDesktopAutoPickOrder(): string[] {
const source = readDesktopSharedFile('tui-agent-selection.ts')
const match = source.match(/TUI_AGENT_AUTO_PICK_ORDER = \[([\s\S]*?)\] as const/)
expect(match).not.toBeNull()
return Array.from(match?.[1].matchAll(/'([^']+)'/g) ?? [], (entry) => entry[1])
}
function parseDesktopConfiguredAgents(): string[] {
const source = readDesktopSharedFile('tui-agent-config.ts')
const match = source.match(/TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {([\s\S]*?)^}/m)
expect(match).not.toBeNull()
return Array.from(
match?.[1].matchAll(/^ (?:'([^']+)'|([a-z][a-z0-9-]*)): {/gm) ?? [],
(entry) => entry[1] ?? entry[2]
)
}
describe('mobile agent catalog', () => {
it('stays in the same order as desktop auto-pick and covers every configured TUI agent', () => {
const desktopAutoPickOrder = parseDesktopAutoPickOrder()
expect(MOBILE_TUI_AGENT_AUTO_PICK_ORDER).toEqual(desktopAutoPickOrder)
expect(MOBILE_AGENT_CATALOG.map((agent) => agent.id)).toEqual(desktopAutoPickOrder)
it('follows desktop auto-pick order and covers every configured TUI agent', () => {
expect(MOBILE_AGENT_CATALOG.map((agent) => agent.id)).toEqual([...TUI_AGENT_AUTO_PICK_ORDER])
expect(new Set(MOBILE_AGENT_CATALOG.map((agent) => agent.id))).toEqual(
new Set(parseDesktopConfiguredAgents())
new Set(Object.keys(TUI_AGENT_CONFIG))
)
})
it('labels every agent with the desktop display name', () => {
for (const entry of MOBILE_AGENT_CATALOG) {
expect(entry.label).toBe(TUI_AGENT_DISPLAY_NAMES[entry.id])
}
})
it('uses the bundled Claude icon path for Claude Agent Teams', () => {
expect(MOBILE_AGENT_CATALOG.find((agent) => agent.id === 'claude-agent-teams')).toEqual(
expect.not.objectContaining({ faviconDomain: expect.any(String) })
@@ -0,0 +1,219 @@
import { StyleSheet, colors, radii, spacing, typography } from './mobile-tasks-dependencies'
export const mobileTasksChromeStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topChrome: {
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
statusBar: {
minHeight: 38,
paddingTop: spacing.xs,
paddingHorizontal: spacing.lg,
flexDirection: 'row',
alignItems: 'center'
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.xs
},
titleWrap: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
minWidth: 0
},
title: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary
},
iconButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center'
},
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2
},
toolbarScroll: {
borderTopWidth: 1,
borderTopColor: colors.borderSubtle
},
segmentButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs
},
segmentIconButton: {
width: 32,
height: 30,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button
},
segmentCountPill: {
minWidth: 32,
height: 30,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.sm
},
segmentRepoDot: {
width: 8,
height: 8,
borderRadius: 4
},
segmentButtonText: {
fontSize: 12,
fontWeight: '600',
color: colors.textPrimary
},
segmentSecondaryText: {
fontSize: 12,
color: colors.textSecondary
},
searchBar: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
errorBanner: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
errorText: {
color: colors.statusRed,
fontSize: 13
},
sourceErrorBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
sourceErrorCopy: {
flex: 1,
minWidth: 0
},
sourceErrorText: {
color: colors.statusAmber,
fontSize: 13,
fontWeight: '600'
},
sourceErrorSlug: {
fontFamily: typography.monoFamily,
color: colors.textPrimary
},
sourceErrorMessage: {
marginTop: 2,
color: colors.textSecondary,
fontSize: 12
},
sourceErrorRetry: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sourceErrorRetryText: {
color: colors.textPrimary,
fontSize: 12,
fontWeight: '600'
},
sourceNoticeBanner: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
sourceNoticeText: {
color: colors.statusAmber,
fontSize: 13
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
},
centeredHint: {
color: colors.textMuted,
fontSize: 12,
lineHeight: 17,
marginTop: spacing.sm,
maxWidth: 280,
textAlign: 'center'
},
centerActionButton: {
marginTop: spacing.md,
minWidth: 160
},
list: {
paddingTop: spacing.xs
},
repoSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
backgroundColor: colors.bgBase
},
repoSectionDot: {
width: 8,
height: 8,
borderRadius: 4
},
repoSectionTitle: {
flex: 1,
minWidth: 0,
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5
},
separator: {
height: 1,
backgroundColor: colors.borderSubtle,
marginLeft: spacing.lg + 26,
marginRight: spacing.lg
}
})
@@ -0,0 +1,242 @@
import {
StyleSheet,
Platform,
colors,
radii,
spacing,
typography
} from './mobile-tasks-dependencies'
export const mobileTasksComposerActionStyles = StyleSheet.create({
createForm: {
gap: spacing.sm
},
fieldLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary
},
inlineTextLink: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
alignSelf: 'flex-start',
paddingVertical: spacing.xs
},
inlineTextLinkText: {
color: colors.textSecondary,
fontSize: 12,
textDecorationLine: 'underline'
},
securityHintRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs
},
securityHintText: {
flex: 1,
color: colors.textMuted,
fontSize: 11,
lineHeight: 16
},
targetButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2
},
targetButtonText: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize
},
issueSourceBox: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
padding: spacing.sm,
gap: spacing.xs
},
issueSourceHint: {
fontSize: 12,
color: colors.textSecondary
},
issueSourceSegment: {
flexDirection: 'row',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgBase,
padding: 2,
gap: 2
},
issueSourceSegmentButton: {
flex: 1,
borderRadius: radii.input - 2,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
issueSourceSegmentButtonActive: {
backgroundColor: colors.bgRaised
},
issueSourceSegmentText: {
fontSize: 12,
fontWeight: '700',
color: colors.textMuted
},
issueSourceSegmentTextActive: {
color: colors.textPrimary
},
issueSourceSlug: {
marginTop: 1,
fontSize: 10,
color: colors.textMuted
},
drawerLoadingRow: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
input: {
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize
},
bodyInput: {
minHeight: 88
},
monoInput: {
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
commentInput: {
minHeight: 72,
marginTop: spacing.sm
},
commentComposer: {
position: 'relative',
marginTop: spacing.sm
},
commentComposerInput: {
minHeight: 40,
maxHeight: 120,
marginTop: 0,
paddingRight: 44
},
commentComposerSend: {
position: 'absolute',
right: spacing.xs,
bottom: spacing.xs,
width: 32,
height: 32,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
commentComposerSendPressed: {
opacity: 0.75
},
commentComposerSendDisabled: {
opacity: 0.5
},
replyInput: {
minHeight: 48,
marginTop: spacing.xs
},
stackedInput: {
marginTop: spacing.sm
},
inlineSaveButton: {
alignSelf: 'flex-start',
marginTop: spacing.sm,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
inlineSaveButtonCompact: {
alignSelf: 'flex-start',
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
inlineSaveText: {
color: colors.textPrimary,
fontSize: 12,
fontWeight: '600'
},
inlineButtonRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: spacing.xs,
marginTop: spacing.xs
},
drawerActionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
secondaryActionButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingVertical: spacing.sm
},
secondaryActionText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
primaryActionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.sm
},
primaryActionText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
},
inlineDeleteText: {
color: colors.statusRed,
fontSize: 12,
fontWeight: '600'
},
createButton: {
marginTop: spacing.sm,
backgroundColor: colors.textPrimary,
borderRadius: radii.button,
paddingVertical: spacing.sm + 2,
alignItems: 'center'
},
createButtonDisabled: {
opacity: 0.5
},
createButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
}
})
@@ -0,0 +1,338 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
BottomDrawer,
View,
TaskProviderLogo,
colors,
Text,
Pressable,
ChevronDown,
TextInput,
ActivityIndicator,
PickerModal,
Linking,
ExternalLink,
Lock
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
getRepoBadgeColor,
type RepoSummary,
hasGitHubIssueSourceChoice,
issueSourceSlug,
type LinearTeam,
TASK_SECONDARY_DRAWER_Z_INDEX
} from './mobile-tasks-legacy-foundation'
export function renderMobileTasksCreateDrawer(model: ConnectionPresentationModel) {
const {
createBody,
createTask,
createTitle,
creatingTask,
provider,
providerLabel,
selectedCreateGitHubSources,
selectedCreateIssuePreference,
selectedCreateRepo,
selectedCreateTarget,
selectedCreateTargetLabel,
setCreateBody,
setCreateTitle,
setGitHubIssueSourcePreference,
setShowCreateTargetPicker,
setShowCreateTask,
showCreateTask,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && showCreateTask}
onClose={() => {
setShowCreateTargetPicker(false)
setShowCreateTask(false)
}}
>
<View style={styles.sheetHeader}>
<View style={styles.sheetTitleRow}>
<TaskProviderLogo provider={provider} size={16} color={colors.textPrimary} />
<Text style={styles.sheetTitle}>New {providerLabel} Issue</Text>
</View>
<Text style={styles.sheetSubtitle}>
{provider === 'github' || provider === 'gitlab'
? 'Create an issue in the selected repository.'
: 'Create an issue in the selected Linear team.'}
</Text>
</View>
<View style={styles.createForm}>
<Text style={styles.fieldLabel}>
{provider === 'github' || provider === 'gitlab' ? 'Repository' : 'Team'}
</Text>
<Pressable
style={styles.targetButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowCreateTargetPicker(true)
}}
>
{provider === 'github' || provider === 'gitlab' ? (
<View
style={[
styles.pickerRepoDot,
selectedCreateTarget
? {
backgroundColor: getRepoBadgeColor(
selectedCreateTarget as RepoSummary,
(selectedCreateTarget as RepoSummary).displayName
)
}
: undefined
]}
/>
) : null}
<Text style={styles.targetButtonText} numberOfLines={1}>
{selectedCreateTargetLabel}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
{provider === 'github' &&
selectedCreateRepo &&
hasGitHubIssueSourceChoice(selectedCreateGitHubSources) ? (
<View style={styles.issueSourceBox}>
<Text style={styles.fieldLabel}>Issue source</Text>
<Text style={styles.issueSourceHint} numberOfLines={2}>
File in{' '}
{selectedCreateIssuePreference === 'origin'
? issueSourceSlug(selectedCreateGitHubSources?.prs)
: issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate)}
</Text>
<View style={styles.issueSourceSegment}>
{(['upstream', 'origin'] as const).map((preference) => {
const selected = selectedCreateIssuePreference === preference
const slug =
preference === 'upstream'
? issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate)
: issueSourceSlug(selectedCreateGitHubSources?.prs)
return (
<Pressable
key={preference}
style={[
styles.issueSourceSegmentButton,
selected && styles.issueSourceSegmentButtonActive
]}
accessibilityState={{ selected }}
onPress={() =>
void setGitHubIssueSourcePreference(selectedCreateRepo, preference)
}
>
<Text
style={[
styles.issueSourceSegmentText,
selected && styles.issueSourceSegmentTextActive
]}
>
{preference === 'upstream' ? 'Upstream' : 'Origin'}
</Text>
<Text style={styles.issueSourceSlug} numberOfLines={1}>
{slug}
</Text>
</Pressable>
)
})}
</View>
</View>
) : null}
<Text style={styles.fieldLabel}>Title</Text>
<TextInput
style={styles.input}
value={createTitle}
onChangeText={setCreateTitle}
placeholder="Task title"
placeholderTextColor={colors.textMuted}
autoCapitalize="sentences"
returnKeyType="next"
/>
<Text style={styles.fieldLabel}>Description</Text>
<TextInput
style={[styles.input, styles.bodyInput]}
value={createBody}
onChangeText={setCreateBody}
placeholder="Add context"
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<Pressable
style={[
styles.createButton,
(!taskUiReady || !createTitle.trim() || creatingTask) && styles.createButtonDisabled
]}
disabled={!taskUiReady || !createTitle.trim() || creatingTask}
onPress={() => void createTask()}
>
{creatingTask ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.createButtonText}>Create Issue</Text>
)}
</Pressable>
</View>
</BottomDrawer>
)
}
export function renderMobileTasksCreateTargetPicker(model: ConnectionPresentationModel) {
const {
createTargetOptions,
provider,
selectedCreateTarget,
setCreateRepoId,
setCreateTeamId,
setShowCreateTargetPicker,
showCreateTargetPicker,
showCreateTask,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showCreateTask && showCreateTargetPicker}
title={provider === 'linear' ? 'Linear Team' : 'Repository'}
options={createTargetOptions}
selected={
provider === 'github' || provider === 'gitlab'
? ((selectedCreateTarget as RepoSummary | null)?.id ?? '')
: ((selectedCreateTarget as LinearTeam | null)?.id ?? '')
}
onSelect={(value) => {
if (provider === 'github' || provider === 'gitlab') {
setCreateRepoId(value)
} else {
setCreateTeamId(value)
}
}}
onClose={() => setShowCreateTargetPicker(false)}
/>
)
}
export function renderMobileTasksLinearConnectDrawer(model: ConnectionPresentationModel) {
const {
connectLinearAccount,
linearApiKeyDraft,
linearConnectError,
linearConnectState,
setLinearApiKeyDraft,
setLinearConnectError,
setLinearConnectState,
setShowLinearConnect,
showLinearConnect,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && showLinearConnect}
onClose={() => {
if (linearConnectState !== 'connecting') {
setShowLinearConnect(false)
}
}}
>
<View style={styles.sheetHeader}>
<View style={styles.sheetTitleRow}>
<TaskProviderLogo provider="linear" size={16} color={colors.textPrimary} />
<Text style={styles.sheetTitle}>Connect Linear workspace</Text>
</View>
<Text style={styles.sheetSubtitle}>
Paste a Personal API key to browse issues from that workspace.
</Text>
</View>
<View style={styles.createForm}>
<Text style={styles.fieldLabel}>Personal API key</Text>
<TextInput
style={styles.input}
value={linearApiKeyDraft}
onChangeText={(next) => {
setLinearApiKeyDraft(next)
if (linearConnectState === 'error') {
setLinearConnectState('idle')
setLinearConnectError('')
}
}}
placeholder="lin_api_..."
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
editable={linearConnectState !== 'connecting'}
onSubmitEditing={() => void connectLinearAccount()}
/>
{linearConnectState === 'error' && linearConnectError ? (
<Text style={styles.detailError}>{linearConnectError}</Text>
) : null}
<Pressable
style={styles.inlineTextLink}
onPress={() => void Linking.openURL('https://linear.app/settings/account/security')}
>
<ExternalLink size={13} color={colors.textSecondary} />
<Text style={styles.inlineTextLinkText}>Linear Settings / Security / New API key</Text>
</Pressable>
<View style={styles.securityHintRow}>
<Lock size={13} color={colors.textMuted} />
<Text style={styles.securityHintText}>
Your key is encrypted via the host OS keychain and stored locally.
</Text>
</View>
<Pressable
style={[
styles.createButton,
(!linearApiKeyDraft.trim() || linearConnectState === 'connecting') &&
styles.createButtonDisabled
]}
disabled={!linearApiKeyDraft.trim() || linearConnectState === 'connecting'}
onPress={() => void connectLinearAccount()}
>
{linearConnectState === 'connecting' ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.createButtonText}>Connect</Text>
)}
</Pressable>
</View>
</BottomDrawer>
)
}
export function renderMobileTasksWorkspaceCreateTargetPicker(model: ConnectionPresentationModel) {
const {
openWorkspaceCreate,
setWorkspaceRepoPickerItem,
taskUiReady,
workspaceRepoOptions,
workspaceRepoPickerItem,
workspaceRepos
} = model
return (
<PickerModal
visible={taskUiReady && workspaceRepoPickerItem != null}
title="Create Workspace In"
options={workspaceRepoOptions}
selected={workspaceRepos[0]?.id ?? ''}
onSelect={(repoId) => {
if (workspaceRepoPickerItem) {
openWorkspaceCreate(workspaceRepoPickerItem, repoId)
}
setWorkspaceRepoPickerItem(null)
}}
onClose={() => setWorkspaceRepoPickerItem(null)}
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX}
/>
)
}
@@ -0,0 +1,151 @@
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
export type { ReactNode } from 'react'
export {
ActivityIndicator,
FlatList,
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
export { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
export * as Clipboard from 'expo-clipboard'
export { useLocalSearchParams, useRouter } from 'expo-router'
export {
AlertTriangle,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Copy,
ExternalLink,
GitBranch,
Lock,
Pencil,
Plus,
RefreshCw,
Send,
X
} from 'lucide-react-native'
export type { RpcClient } from '../transport/rpc-client'
export type { RpcSuccess } from '../transport/types'
export { useHostClient } from '../transport/client-context'
export {
useLastConnectedAt,
useRelayRecoveryStatus,
useReconnectAttempt
} from '../transport/client-context-connection-metrics'
export { classifyConnection } from '../transport/connection-health'
export { StatusDot } from '../components/StatusDot'
export { ActionSheetModal } from '../components/ActionSheetModal'
export { BottomDrawer } from '../components/BottomDrawer'
export { ConfirmModal } from '../components/ConfirmModal'
export { MobileMarkdown } from '../components/MobileMarkdown'
export { MobileAgentIcon } from '../components/MobileAgentIcon'
export { MobileWorkspaceNameInput } from '../components/MobileWorkspaceNameInput'
export { MobileSearchField } from '../components/MobileSearchField'
export { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
export { PickerModal } from '../components/PickerModal'
export type { PickerOption } from '../components/PickerModal'
export { TaskProviderLogo } from '../components/TaskProviderLogo'
export { buildGitHubPrFileDiffPreview } from './github-pr-file-diff'
export type { GitHubPrFileDiffLine } from './github-pr-file-diff'
export {
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from '../session/mobile-file-syntax'
export { buildGitHubCheckSummary } from './github-check-summary'
export { buildGitLabCheckSummary } from './gitlab-check-summary'
export {
getHostedMergeLabel,
getHostedReviewLabel,
getHostedReviewSignalTone,
getHostedChecksLabel
} from './mobile-hosted-check-status'
export { buildTaskWorkspaceCreateParams } from './workspace-create-params'
export { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
export {
filterWorkspaceAgents,
isWorkspaceAgentEnabled,
pickWorkspaceAgent,
resolveWorkspaceAgentSelection,
workspaceAgentLabel
} from './workspace-agent-selection'
export type { WorkspaceAgentChoice } from './workspace-agent-selection'
export { shouldResolveHostedReviewStartPoint } from './hosted-review-start-point'
export { getLinkedWorkItemSuggestedName } from './mobile-workspace-name'
export {
dropFailedGitHubRepoSlugEntries,
filterGitHubProjectRowsForRepos,
findRepoForGitHubProjectRepository
} from './github-project-repo-match'
export type { GitHubRepoSlugCacheEntry } from './github-project-repo-match'
export { parseGitHubProjectInput as parseProjectInput } from './github-project-reference'
export type {
GitHubProjectOwnerType,
GitHubProjectPartialFailure,
GitHubProjectRef,
GitHubProjectSettings,
GitHubProjectSummary,
GitHubProjectViewSummary
} from './github-project-reference'
export {
extractGitHubIssueSourceFallback,
extractGitHubIssueSourceError
} from './github-work-item-source-errors'
export type {
GitHubIssueSourceFallback,
GitHubIssueSourceError
} from './github-work-item-source-errors'
export { parseSparsePresetDirectories } from './sparse-preset-draft'
export { deriveWorkspaceSshGate, workspaceSshStatusLabel } from './workspace-ssh-gate'
export { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
export {
isSetupHookTrusted,
normalizeSetupHookTrust,
trustedOrcaHooksWithSetupApproval,
wasSetupHookPreviouslyApproved
} from './setup-hook-trust'
export { colors, radii, spacing, typography } from '../theme/mobile-theme'
export { triggerMediumImpact } from '../platform/haptics'
export {
CROSS_REPO_DISPLAY_LIMIT,
isGitHubWorkItemsSshRemoteRequiredError,
PER_REPO_FETCH_LIMIT
} from './mobile-work-items'
export {
filterAvailableTaskProviders,
normalizeVisibleTaskProviders,
resolveVisibleTaskProvider
} from './mobile-task-providers'
export type { TaskProvider } from './mobile-task-providers'
export { hasSettledHostRepoList } from './host-repo-list'
export { useHostRepoList } from './use-host-repo-list'
export { isHostedTaskRepo, reconcileRepoSelection } from './hosted-repo-selection'
export { extractLinearIssueReadItems } from './linear-mobile-issue-read'
export type { LinearMobileIssue } from './linear-mobile-issue-read'
export { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from './mobile-tui-agents'
export { resolveComposerBranchSelection } from './mobile-composer-branch-selection'
export {
clearMobileTaskCopyFeedbackTimer,
scheduleMobileTaskCopyFeedbackReset
} from './mobile-task-copy-feedback-timer'
export type { BaseRefSearchResult } from '../../../src/shared/repo-types'
export type {
GitHubOwnerRepo,
ProviderCheckSummary
} from '../../../src/shared/github/pull-request-types'
export type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
export type { SparsePreset } from '../../../src/shared/worktree/create-types'
export type { TuiAgent } from '../../../src/shared/tui-agent'
export type { SshConnectionState } from '../../../src/shared/ssh-types'
export type { HostedReviewDecision } from '../../../src/shared/hosted-review'
export {
githubProjectHost,
githubProjectIdentityKey as githubProjectKey
} from '../../../src/shared/github/project-identity'
@@ -0,0 +1,169 @@
import { StyleSheet, colors, radii, spacing, typography } from './mobile-tasks-dependencies'
export const mobileTasksDetailStyles = StyleSheet.create({
groupSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
repoPickerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md + 2,
paddingVertical: spacing.md
},
repoPickerTextWrap: {
flex: 1,
minWidth: 0
},
repoPickerTitle: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
repoPickerSubtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 1
},
sheetHeader: {
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
sheetTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
sheetTitle: {
flex: 1,
minWidth: 0,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
lineHeight: 20
},
sheetSubtitle: {
fontSize: 12,
color: colors.textMuted,
marginTop: 2
},
actionGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
detailGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
padding: spacing.md,
marginBottom: spacing.md,
gap: spacing.md
},
detailLoading: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
detailLoadingInline: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
detailError: {
color: colors.statusRed,
fontSize: 13
},
detailMetaGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm
},
detailMetaItem: {
minWidth: 96,
flexGrow: 1
},
detailMetaLabel: {
fontSize: 11,
color: colors.textMuted,
marginBottom: 2
},
detailMetaValue: {
fontSize: 13,
color: colors.textPrimary,
fontWeight: '600'
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs
},
detailChip: {
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 2
},
detailChipSelected: {
borderColor: colors.accentBlue,
backgroundColor: colors.bgRaised
},
detailChipText: {
fontSize: 11,
color: colors.textSecondary
},
issueTypeChipContent: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
issueTypeDot: {
width: 7,
height: 7,
borderRadius: 999
},
detailSection: {
gap: spacing.xs
},
detailSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
detailSectionTitle: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5
},
detailSectionMeta: {
flexShrink: 0,
fontSize: 11,
color: colors.textMuted
},
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle
},
fieldButtonDisabled: {
opacity: 0.55
},
fieldButtonPlaceholder: {
color: colors.textMuted
},
fieldButtonText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
}
})
@@ -0,0 +1,182 @@
import { StyleSheet, colors, radii, spacing, typography } from './mobile-tasks-dependencies'
export const mobileTasksDiffCommentStyles = StyleSheet.create({
filePreview: {
gap: spacing.xs,
marginTop: spacing.xs
},
fileDiff: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.row,
overflow: 'hidden'
},
diffLineBlock: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs,
gap: spacing.xs
},
diffLineAdded: {
borderLeftColor: colors.statusGreen
},
diffLineRemoved: {
borderLeftColor: colors.statusRed
},
diffCodeRow: {
flexDirection: 'row',
gap: spacing.xs,
alignItems: 'flex-start'
},
diffLineNumbers: {
width: 76,
flexShrink: 0,
fontFamily: typography.monoFamily,
fontSize: 10,
lineHeight: 16,
color: colors.textMuted
},
codeLine: {
flex: 1,
fontFamily: typography.monoFamily,
fontSize: 11,
lineHeight: 16,
color: colors.textSecondary
},
diffCodeAdded: {
color: colors.statusGreen
},
diffCodeRemoved: {
color: colors.statusRed
},
detailMuted: {
fontSize: 12,
color: colors.textSecondary
},
commentBlock: {
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
paddingTop: spacing.sm
},
commentThreadGroup: {
gap: spacing.xs
},
commentReplyBlock: {
marginLeft: spacing.md,
paddingLeft: spacing.sm,
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle
},
commentResolvedBlock: {
opacity: 0.6
},
resolvedCommentSummary: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm
},
resolvedCommentTitle: {
flex: 1,
minWidth: 0,
fontSize: 12,
color: colors.textSecondary
},
commentSource: {
fontSize: 11,
lineHeight: 15,
color: colors.textSecondary,
marginBottom: 2
},
commentMeta: {
fontSize: 11,
color: colors.textMuted,
marginBottom: 2
},
commentControls: {
gap: spacing.xs,
marginTop: spacing.sm
},
reactionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginTop: spacing.xs
},
reactionChip: {
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 2
},
reactionText: {
fontSize: 11,
color: colors.textSecondary
},
inlineActionRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: spacing.xs
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md + 2,
paddingVertical: spacing.md
},
actionText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
actionSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
setupPromptBox: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.md,
gap: spacing.xs
},
setupPromptCommand: {
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
linearStatesBlock: {
paddingTop: spacing.sm
},
linearStatesTitle: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: spacing.md + 2,
paddingBottom: spacing.xs
},
emptyInlineText: {
color: colors.textSecondary,
fontSize: 13,
paddingHorizontal: spacing.md + 2,
paddingBottom: spacing.md
}
})
@@ -0,0 +1,194 @@
import ts from 'typescript'
import {
MOBILE_TASKS_SOURCE_FILES,
readMobileTasksSource
} from './mobile-tasks-source-family.test-support'
type FunctionDefinition = {
body: ts.Block
sourceFile: ts.SourceFile
}
const TASKS_ROUTE = '../../app/h/[hostId]/tasks.tsx'
const FOUNDATION_SOURCE = 'mobile-tasks-legacy-foundation.tsx'
const LEGACY_STYLE_SOURCE = 'mobile-tasks-legacy-styles.ts'
function parseSource(relativePath: string): ts.SourceFile {
return ts.createSourceFile(
relativePath,
readMobileTasksSource(relativePath),
ts.ScriptTarget.Latest,
true,
relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
}
function normalized(node: ts.Node, sourceFile: ts.SourceFile): string {
return node
.getText(sourceFile)
.replace(/\/\/ react-doctor-disable-next-line [^\n]*\n/g, '')
.replace(/\s+/g, ' ')
.trim()
}
function functionBody(sourceFile: ts.SourceFile, functionName: string): ts.Block {
let match: ts.Block | null = null
function visit(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name?.text === functionName && node.body) {
match = node.body
return
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
if (!match) {
throw new Error(`Missing Mobile Tasks function: ${functionName}`)
}
return match
}
function hookDefinitions(): Map<string, FunctionDefinition> {
const definitions = new Map<string, FunctionDefinition>()
for (const relativePath of MOBILE_TASKS_SOURCE_FILES) {
const sourceFile = parseSource(relativePath)
function visit(node: ts.Node): void {
if (
ts.isFunctionDeclaration(node) &&
node.name?.text.startsWith('useMobileTasks') &&
node.body
) {
definitions.set(node.name.text, { body: node.body, sourceFile })
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
return definitions
}
function assignedHookName(statement: ts.Statement): string | null {
if (!ts.isVariableStatement(statement) || statement.declarationList.declarations.length !== 1) {
return null
}
const initializer = statement.declarationList.declarations[0]?.initializer
return initializer && ts.isCallExpression(initializer) && ts.isIdentifier(initializer.expression)
? initializer.expression.text
: null
}
function isModelDestructure(statement: ts.Statement): boolean {
if (!ts.isVariableStatement(statement) || statement.declarationList.declarations.length !== 1) {
return false
}
const declaration = statement.declarationList.declarations[0]
return (
declaration != null &&
ts.isObjectBindingPattern(declaration.name) &&
declaration.initializer != null &&
ts.isIdentifier(declaration.initializer) &&
declaration.initializer.text === 'model'
)
}
function flattenStageStatements(
definition: FunctionDefinition,
definitions: ReadonlyMap<string, FunctionDefinition>,
active: ReadonlySet<string>
): string[] {
const signatures: string[] = []
for (const statement of definition.body.statements) {
if (ts.isReturnStatement(statement) || isModelDestructure(statement)) {
continue
}
const nestedName = assignedHookName(statement)
const nestedDefinition = nestedName ? definitions.get(nestedName) : undefined
if (nestedName && nestedDefinition) {
if (active.has(nestedName)) {
throw new Error(`Recursive Mobile Tasks statement stage: ${nestedName}`)
}
signatures.push(
...flattenStageStatements(nestedDefinition, definitions, new Set([...active, nestedName]))
)
continue
}
signatures.push(normalized(statement, definition.sourceFile))
}
return signatures
}
export function readFlattenedMobileTasksCoreStatements(): string[] {
const definitions = hookDefinitions()
const route = parseSource(TASKS_ROUTE)
const signatures: string[] = []
for (const statement of functionBody(route, 'MobileTasksScreen').statements) {
if (ts.isReturnStatement(statement)) {
continue
}
const stageName = assignedHookName(statement)
const definition = stageName ? definitions.get(stageName) : undefined
if (!stageName || !definition) {
throw new Error('Unexpected Mobile Tasks route statement')
}
signatures.push(...flattenStageStatements(definition, definitions, new Set([stageName])))
}
return signatures
}
function foundationSources(): string[] {
const foundation = parseSource(FOUNDATION_SOURCE)
const sources = foundation.statements.flatMap((statement) => {
if (
!ts.isExportDeclaration(statement) ||
!statement.moduleSpecifier ||
!ts.isStringLiteral(statement.moduleSpecifier)
) {
return []
}
const base = statement.moduleSpecifier.text.replace(/^\.\//, '')
return [`${base}.ts`, `${base}.tsx`].filter((candidate) =>
MOBILE_TASKS_SOURCE_FILES.includes(candidate)
)
})
return [...sources, LEGACY_STYLE_SOURCE]
}
function printedDeclaration(node: ts.Node, sourceFile: ts.SourceFile, printer: ts.Printer): string {
return printer
.printNode(ts.EmitHint.Unspecified, node, sourceFile)
.replace(/^export\s+(?:default\s+)?/, '')
.replace(/\s+/g, ' ')
.trim()
}
export function readMobileTasksDeclarationSignatures(): string[] {
const printer = ts.createPrinter({ removeComments: true })
const signatures: string[] = []
for (const relativePath of foundationSources()) {
const sourceFile = parseSource(relativePath)
for (const statement of sourceFile.statements) {
if (
(ts.isFunctionDeclaration(statement) ||
ts.isTypeAliasDeclaration(statement) ||
ts.isInterfaceDeclaration(statement) ||
ts.isEnumDeclaration(statement) ||
ts.isClassDeclaration(statement)) &&
statement.name
) {
signatures.push(
`${statement.name.text}:${printedDeclaration(statement, sourceFile, printer)}`
)
continue
}
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && declaration.name.text !== 'styles') {
signatures.push(
`${declaration.name.text}:${printedDeclaration(declaration, sourceFile, printer)}`
)
}
}
}
}
}
return signatures.sort()
}
@@ -0,0 +1,334 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
BottomDrawer,
View,
Text,
Pressable,
Check,
colors,
PickerModal,
ActivityIndicator
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
GITLAB_VIEW_OPTIONS,
GITLAB_FILTER_OPTIONS,
LINEAR_FILTER_OPTIONS,
TASK_SECONDARY_DRAWER_Z_INDEX
} from './mobile-tasks-legacy-foundation'
export function renderMobileTasksGitHubProjectFieldsPicker(model: ConnectionPresentationModel) {
const {
githubProjectAvailableSummaryFields,
githubProjectHiddenFieldIds,
setShowGitHubProjectFieldsPicker,
showGitHubProjectFieldsPicker,
taskUiReady,
toggleGitHubProjectFieldVisibility
} = model
return (
<BottomDrawer
visible={taskUiReady && showGitHubProjectFieldsPicker}
onClose={() => setShowGitHubProjectFieldsPicker(false)}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Project Fields</Text>
<Text style={styles.sheetSubtitle}>Choose which Project fields appear on item cards.</Text>
</View>
<View style={styles.repoPickerGroup}>
{githubProjectAvailableSummaryFields.length === 0 ? (
<Text style={styles.repoPickerSubtitle}>This view has no extra fields to show.</Text>
) : (
githubProjectAvailableSummaryFields.map((field, index) => {
const visible = !githubProjectHiddenFieldIds.has(field.id)
return (
<View key={field.id}>
{index > 0 ? <View style={styles.actionSeparator} /> : null}
<Pressable
style={styles.repoPickerRow}
onPress={() => toggleGitHubProjectFieldVisibility(field.id)}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle} numberOfLines={1}>
{field.name}
</Text>
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
{visible ? 'Shown on cards' : 'Hidden from cards'}
</Text>
</View>
{visible ? <Check size={15} color={colors.textPrimary} /> : null}
</Pressable>
</View>
)
})
)}
</View>
</BottomDrawer>
)
}
export function renderMobileTasksGitLabViewPicker(model: ConnectionPresentationModel) {
const {
gitlabView,
setAppliedQuery,
setGitlabView,
setQuery,
setShowGitLabViewPicker,
showGitLabViewPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showGitLabViewPicker}
title="GitLab View"
options={GITLAB_VIEW_OPTIONS}
selected={gitlabView}
onSelect={(view) => {
setGitlabView(view)
if (view === 'todos') {
// Why: GitLab Todos is a server-side pending-todos stream; the
// runtime method has no search query, so clear item-search state
// when entering that view instead of carrying an invisible filter.
setQuery('')
setAppliedQuery('')
}
}}
onClose={() => setShowGitLabViewPicker(false)}
/>
)
}
export function renderMobileTasksGitLabFilterPicker(model: ConnectionPresentationModel) {
const {
gitlabFilter,
setGitlabFilter,
setShowGitLabFilterPicker,
showGitLabFilterPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showGitLabFilterPicker}
title="GitLab Filter"
options={GITLAB_FILTER_OPTIONS}
selected={gitlabFilter}
onSelect={setGitlabFilter}
onClose={() => setShowGitLabFilterPicker(false)}
/>
)
}
export function renderMobileTasksLinearFilterPicker(model: ConnectionPresentationModel) {
const {
linearFilter,
persistTaskResumeState,
setAppliedQuery,
setLinearFilter,
setQuery,
setShowLinearFilterPicker,
showLinearFilterPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showLinearFilterPicker}
title="Linear Filter"
options={LINEAR_FILTER_OPTIONS}
selected={linearFilter}
onSelect={(filter) => {
setLinearFilter(filter)
setQuery('')
setAppliedQuery('')
persistTaskResumeState({ linearPreset: filter, linearQuery: '' })
}}
onClose={() => setShowLinearFilterPicker(false)}
/>
)
}
export function renderMobileTasksLinearWorkspacePicker(model: ConnectionPresentationModel) {
const {
client,
linearWorkspaceOptions,
loadLinearContext,
selectedLinearWorkspaceId,
setError,
setSelectedLinearTeamIds,
setSelectedLinearWorkspaceId,
setShowLinearWorkspacePicker,
showLinearWorkspacePicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showLinearWorkspacePicker}
title="Linear Workspace"
options={linearWorkspaceOptions}
selected={selectedLinearWorkspaceId ?? ''}
onSelect={(workspaceId) => {
setSelectedLinearWorkspaceId(workspaceId)
setSelectedLinearTeamIds(new Set())
if (client) {
void client
.sendRequest('linear.selectWorkspace', { workspaceId })
.then(() => loadLinearContext())
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to switch workspace')
})
}
}}
onClose={() => setShowLinearWorkspacePicker(false)}
/>
)
}
export function renderMobileTasksLinearTeamPicker(model: ConnectionPresentationModel) {
const {
linearTeams,
persistLinearTeamSelection,
selectedLinearTeamIds,
setSelectedLinearTeamIds,
setShowLinearTeamPicker,
showLinearTeamPicker,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && showLinearTeamPicker}
onClose={() => setShowLinearTeamPicker(false)}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Linear Teams</Text>
<Text style={styles.sheetSubtitle}>Choose which teams appear in Tasks.</Text>
</View>
<View style={styles.repoPickerGroup}>
<Pressable
style={styles.repoPickerRow}
onPress={() => {
const next = new Set(linearTeams.map((team) => team.id))
setSelectedLinearTeamIds(next)
persistLinearTeamSelection(next, linearTeams)
}}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle}>All teams</Text>
<Text style={styles.repoPickerSubtitle}>{linearTeams.length} teams</Text>
</View>
{selectedLinearTeamIds.size === linearTeams.length ? (
<Check size={15} color={colors.textPrimary} />
) : null}
</Pressable>
{linearTeams.map((team) => {
const selected = selectedLinearTeamIds.has(team.id)
return (
<View key={team.id}>
<View style={styles.actionSeparator} />
<Pressable
style={styles.repoPickerRow}
onPress={() => {
const next = new Set(selectedLinearTeamIds)
if (next.has(team.id)) {
next.delete(team.id)
} else {
next.add(team.id)
}
const normalized =
next.size === 0 || next.size === linearTeams.length
? new Set(linearTeams.map((entry) => entry.id))
: next
setSelectedLinearTeamIds(normalized)
persistLinearTeamSelection(normalized, linearTeams)
}}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle} numberOfLines={1}>
{team.name}
</Text>
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
{team.workspaceName ?? team.key}
</Text>
</View>
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
</Pressable>
</View>
)
})}
</View>
</BottomDrawer>
)
}
export function renderMobileTasksLinearStatusPicker(model: ConnectionPresentationModel) {
const {
linearStates,
linearStatesLoading,
linearStatusPickerItem,
mutatingStatus,
setLinearStatus,
setLinearStatusPickerItem,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && linearStatusPickerItem !== null}
onClose={() => setLinearStatusPickerItem(null)}
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Change Status</Text>
<Text style={styles.sheetSubtitle}>
{linearStatusPickerItem?.source.identifier ?? 'Linear issue'}
</Text>
</View>
<View style={styles.repoPickerGroup}>
{linearStatesLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading states...</Text>
</View>
) : linearStates.length === 0 ? (
<Text style={styles.emptyInlineText}>No states available</Text>
) : (
linearStates.map((state, index) => {
const selected =
state.name === linearStatusPickerItem?.source.state.name &&
state.type === linearStatusPickerItem?.source.state.type
return (
<View key={state.id}>
{index > 0 ? <View style={styles.actionSeparator} /> : null}
<Pressable
style={styles.repoPickerRow}
disabled={mutatingStatus}
onPress={() => {
if (!linearStatusPickerItem) {
return
}
void setLinearStatus(linearStatusPickerItem, state, {
closeDetail: false
}).then(() => setLinearStatusPickerItem(null))
}}
>
<View
style={[
styles.pickerRepoDot,
{ backgroundColor: state.color || colors.textMuted }
]}
/>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle} numberOfLines={1}>
{state.name}
</Text>
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
{state.type}
</Text>
</View>
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
</Pressable>
</View>
)
})
)}
</View>
</BottomDrawer>
)
}
@@ -0,0 +1,188 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
View,
ActivityIndicator,
colors,
Text,
Pressable,
FlatList,
spacing,
ChevronDown,
triggerMediumImpact,
TaskProviderLogo
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
projectGroupMeta,
formatUpdatedAt,
getRepoBadgeColor,
projectFieldDisplayLabel,
projectRowStatusLabel
} from './mobile-tasks-legacy-foundation'
export function renderMobileTasksGitHubProjectList(model: ConnectionPresentationModel) {
const {
activeGitHubProject,
findProjectRowRepo,
githubProjectError,
githubProjectListEntries,
githubProjectLoading,
githubProjectRepoSlugReady,
githubProjectSummaryFields,
githubProjectTable,
insets,
refreshGitHubProject,
setCollapsedGitHubProjectGroups,
setProjectRowItem,
setShowGitHubProjectPicker,
taskUiReady,
visibleGitHubProjectRows
} = model
return githubProjectLoading ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : !activeGitHubProject ? (
<View style={styles.centered}>
<Text style={styles.emptyText}>Choose a GitHub project</Text>
<Pressable
style={[styles.targetButton, styles.centerActionButton]}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubProjectPicker(true)
}}
>
<Text style={styles.targetButtonText}>Browse projects</Text>
</Pressable>
</View>
) : githubProjectError ? (
<View style={styles.centered}>
<Text style={styles.emptyText}>{githubProjectError}</Text>
</View>
) : githubProjectTable && !githubProjectRepoSlugReady ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : !githubProjectTable || visibleGitHubProjectRows.length === 0 ? (
<View style={styles.centered}>
<Text style={styles.emptyText}>No project items</Text>
</View>
) : (
<FlatList
data={githubProjectListEntries}
keyExtractor={(entry) => (entry.type === 'group' ? `group:${entry.group.key}` : entry.row.id)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
refreshing={githubProjectLoading}
onRefresh={refreshGitHubProject}
renderItem={({ item: entry }) => {
if (entry.type === 'group') {
return (
<Pressable
style={styles.projectGroupHeader}
onPress={() =>
setCollapsedGitHubProjectGroups((current) => {
const next = new Set(current)
if (next.has(entry.group.key)) {
next.delete(entry.group.key)
} else {
next.add(entry.group.key)
}
return next
})
}
>
<ChevronDown
size={14}
color={colors.textMuted}
style={entry.collapsed ? styles.projectGroupChevronCollapsed : undefined}
/>
<Text style={styles.projectGroupTitle} numberOfLines={1}>
{entry.group.label || 'Items'}
</Text>
<Text style={styles.projectGroupMeta}>{projectGroupMeta(entry.group)}</Text>
</Pressable>
)
}
const row = entry.row
const repo = findProjectRowRepo(row)
return (
<Pressable
style={({ pressed }) => [styles.taskRow, pressed && styles.taskRowPressed]}
onPress={() => {
triggerMediumImpact()
setProjectRowItem(row)
}}
>
<View style={styles.taskIcon}>
<TaskProviderLogo provider="github" size={15} color={colors.textSecondary} />
</View>
<View style={styles.taskMain}>
<View style={styles.taskTitleRow}>
<Text style={styles.taskTitle} numberOfLines={2}>
{row.content.title}
</Text>
<Text style={styles.updatedAt}>{formatUpdatedAt(row.updatedAt)}</Text>
</View>
<View style={styles.metaRow}>
<View
style={[
styles.repoDot,
{
backgroundColor: getRepoBadgeColor(
repo ?? undefined,
row.content.repository ?? 'project'
)
}
]}
/>
<Text style={styles.subtitle} numberOfLines={1}>
{row.itemType === 'PULL_REQUEST'
? 'Pull request'
: row.itemType === 'ISSUE'
? 'Issue'
: 'Project item'}{' '}
· {row.content.repository ?? githubProjectTable.project.title}
{row.content.number ? ` #${row.content.number}` : ''}
</Text>
</View>
{githubProjectSummaryFields.length > 0 ? (
<View style={styles.projectFieldPillRow}>
{githubProjectSummaryFields.slice(0, 4).map((field) => {
const value = projectFieldDisplayLabel(row, field)
const isEmpty = value === 'Empty'
return (
<View key={field.id} style={styles.projectFieldPill}>
<Text style={styles.projectFieldPillText} numberOfLines={1}>
{field.name}:{' '}
<Text style={isEmpty ? styles.projectFieldPillEmptyText : undefined}>
{value}
</Text>
</Text>
</View>
)
})}
{githubProjectSummaryFields.length > 4 ? (
<View style={styles.projectFieldPill}>
<Text style={styles.projectFieldPillText}>
+{githubProjectSummaryFields.length - 4} fields
</Text>
</View>
) : null}
</View>
) : null}
</View>
<View style={styles.statusPill}>
<Text style={styles.statusText} numberOfLines={1}>
{projectRowStatusLabel(row)}
</Text>
</View>
</Pressable>
)
}}
/>
)
}
@@ -0,0 +1,368 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
BottomDrawer,
View,
Text,
TextInput,
colors,
Pressable,
AlertTriangle,
ActivityIndicator,
githubProjectKey,
Check,
PickerModal
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import { PROJECT_VIEW_DEFAULT_SORT } from './mobile-tasks-legacy-foundation'
export function renderMobileTasksGitHubProjectPicker(model: ConnectionPresentationModel) {
const {
activeGitHubProject,
browseGitHubProjects,
githubProjectError,
githubProjectLoading,
githubProjectPartialFailures,
githubProjectPasteBusy,
githubProjectPasteError,
githubProjectPasteInput,
githubProjectPickerSearch,
githubProjectSettings,
githubProjects,
loadGitHubProjects,
persistGitHubProjectSettings,
pinnedGitHubProjects,
recentGitHubProjects,
resolveGitHubProjectFromInput,
selectGitHubProject,
setGithubProjectError,
setGithubProjectPasteError,
setGithubProjectPasteInput,
setGithubProjectPickerSearch,
setShowGitHubProjectPicker,
showGitHubProjectPicker,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && showGitHubProjectPicker}
onClose={() => setShowGitHubProjectPicker(false)}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>GitHub Projects</Text>
<Text style={styles.sheetSubtitle}>Choose a project view for the Tasks page.</Text>
</View>
<View style={styles.projectPickerControls}>
<TextInput
style={styles.input}
value={githubProjectPickerSearch}
onChangeText={setGithubProjectPickerSearch}
placeholder="Search projects"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.projectPasteRow}>
<TextInput
style={[styles.input, styles.projectPasteInput]}
value={githubProjectPasteInput}
onChangeText={(next) => {
setGithubProjectPasteInput(next)
setGithubProjectPasteError('')
}}
onSubmitEditing={() => void resolveGitHubProjectFromInput()}
placeholder="Add by URL or owner/number"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable
style={[styles.inlineSaveButtonCompact, styles.projectPasteButton]}
disabled={githubProjectPasteBusy || githubProjectPasteInput.trim().length === 0}
onPress={() => void resolveGitHubProjectFromInput()}
>
<Text style={styles.inlineSaveText}>
{githubProjectPasteBusy ? 'Adding...' : 'Add'}
</Text>
</Pressable>
</View>
{githubProjectPasteError ? (
<Text style={styles.detailError}>{githubProjectPasteError}</Text>
) : null}
</View>
{githubProjectError ? <Text style={styles.detailError}>{githubProjectError}</Text> : null}
{githubProjectPartialFailures.length > 0 ? (
<View style={styles.projectWarningBanner}>
<AlertTriangle size={15} color={colors.statusAmber} />
<View style={styles.projectWarningTextWrap}>
<Text style={styles.projectWarningTitle}>
{githubProjectPartialFailures.length === 1 &&
githubProjectPartialFailures[0]!.owner !== '*'
? `Couldn't load projects from ${githubProjectPartialFailures[0]!.owner}.`
: `Some organizations didn't load (${githubProjectPartialFailures.length}).`}
</Text>
<Text style={styles.projectWarningText}>Use Add by URL to reach missing projects.</Text>
<Text style={styles.projectWarningText} numberOfLines={2}>
{githubProjectPartialFailures
.map(
(failure) =>
`${failure.owner === '*' ? 'orgs' : failure.owner}: ${failure.message}`
)
.join(' · ')}
</Text>
</View>
</View>
) : null}
<View style={styles.repoPickerGroup}>
{githubProjectLoading && githubProjects.length === 0 ? (
<View style={styles.drawerLoadingRow}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : githubProjects.length === 0 &&
pinnedGitHubProjects.length === 0 &&
recentGitHubProjects.length === 0 ? (
<Pressable
style={styles.repoPickerRow}
onPress={() =>
void loadGitHubProjects().catch((err) => {
setGithubProjectError(
err instanceof Error ? err.message : 'Failed to load projects'
)
})
}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle}>No projects loaded</Text>
<Text style={styles.repoPickerSubtitle}>Tap to retry.</Text>
</View>
</Pressable>
) : (
<>
{pinnedGitHubProjects.length > 0 ? (
<>
<Text style={styles.linearStatesTitle}>Pinned</Text>
{pinnedGitHubProjects.map((project, index) => {
const key = githubProjectKey(project)
const selected =
activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key
return (
<View key={`pinned:${key}`}>
{index > 0 ? <View style={styles.actionSeparator} /> : null}
<Pressable
style={styles.repoPickerRow}
onPress={() => {
setShowGitHubProjectPicker(false)
void selectGitHubProject(project)
}}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle} numberOfLines={1}>
{project.summary?.title ?? `#${project.number}`}
</Text>
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
{project.owner} · #{project.number}
</Text>
</View>
<Pressable
style={styles.inlineSaveButtonCompact}
onPress={(event) => {
event.stopPropagation()
persistGitHubProjectSettings({
...githubProjectSettings,
pinned: githubProjectSettings.pinned.filter(
(entry) => githubProjectKey(entry) !== key
)
})
}}
>
<Text style={styles.inlineSaveText}>Remove</Text>
</Pressable>
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
</Pressable>
</View>
)
})}
</>
) : null}
{recentGitHubProjects.length > 0 ? (
<>
<Text style={styles.linearStatesTitle}>Recent</Text>
{recentGitHubProjects.map((project, index) => {
const key = githubProjectKey(project)
const selected =
activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key
return (
<View key={`recent:${key}`}>
{index > 0 ? <View style={styles.actionSeparator} /> : null}
<Pressable
style={styles.repoPickerRow}
onPress={() => {
setShowGitHubProjectPicker(false)
void selectGitHubProject(project)
}}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle} numberOfLines={1}>
{project.summary?.title ?? `#${project.number}`}
</Text>
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
{project.owner} · #{project.number}
</Text>
</View>
{githubProjectSettings.lastViewByProject[key]?.viewId ? (
<Pressable
style={styles.inlineSaveButtonCompact}
onPress={(event) => {
event.stopPropagation()
persistGitHubProjectSettings({
...githubProjectSettings,
pinned: [
...githubProjectSettings.pinned,
{
owner: project.owner,
ownerType: project.ownerType,
number: project.number
}
].slice(0, 20)
})
}}
>
<Text style={styles.inlineSaveText}>Pin</Text>
</Pressable>
) : null}
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
</Pressable>
</View>
)
})}
</>
) : null}
<Text style={styles.linearStatesTitle}>
{githubProjectLoading ? 'Browse all (loading...)' : 'Browse all'}
</Text>
{browseGitHubProjects.length === 0 ? (
<Text style={styles.emptyInlineText}>
{githubProjectPickerSearch.trim() ? 'No matching projects.' : 'No more projects.'}
</Text>
) : (
browseGitHubProjects.map((project, index) => {
const selected =
activeGitHubProject !== null &&
githubProjectKey(activeGitHubProject) === githubProjectKey(project)
return (
<View key={project.id}>
{index > 0 ? <View style={styles.actionSeparator} /> : null}
<Pressable
style={styles.repoPickerRow}
onPress={() => {
setShowGitHubProjectPicker(false)
void selectGitHubProject(project)
}}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle} numberOfLines={1}>
{project.title}
</Text>
<Text style={styles.repoPickerSubtitle} numberOfLines={1}>
{project.owner} · #{project.number}
</Text>
</View>
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
</Pressable>
</View>
)
})
)}
</>
)}
</View>
</BottomDrawer>
)
}
export function renderMobileTasksGitHubProjectViewPicker(model: ConnectionPresentationModel) {
const {
activeGitHubProject,
activeGitHubProjectKey,
activeGitHubProjectViewId,
commitGitHubProjectView,
githubProjectViewOptions,
githubProjectViews,
pendingGitHubProjectViewSelection,
setGithubProjectError,
setPendingGitHubProjectViewSelection,
setShowGitHubProjectViewPicker,
showGitHubProjectViewPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showGitHubProjectViewPicker}
title={pendingGitHubProjectViewSelection ? 'Choose Project View' : 'Project View'}
options={githubProjectViewOptions}
selected={pendingGitHubProjectViewSelection ? '' : (activeGitHubProjectViewId ?? '')}
onSelect={(viewId) => {
const view = githubProjectViews.find((candidate) => candidate.id === viewId)
if (view && view.layout !== 'TABLE_LAYOUT') {
setGithubProjectError("Orca doesn't support this GitHub Project layout yet.")
return
}
if (pendingGitHubProjectViewSelection) {
commitGitHubProjectView(pendingGitHubProjectViewSelection, viewId)
setPendingGitHubProjectViewSelection(null)
return
}
if (!activeGitHubProject || !activeGitHubProjectKey) {
return
}
commitGitHubProjectView(activeGitHubProject, viewId)
}}
onClose={() => {
setShowGitHubProjectViewPicker(false)
if (pendingGitHubProjectViewSelection) {
setPendingGitHubProjectViewSelection(null)
}
}}
/>
)
}
export function renderMobileTasksGitHubProjectSortPicker(model: ConnectionPresentationModel) {
const {
githubProjectSortOptions,
githubProjectSortOverride,
setGithubProjectSortOverride,
setShowGitHubProjectSortPicker,
showGitHubProjectSortPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showGitHubProjectSortPicker}
title="Project Sort"
options={githubProjectSortOptions}
selected={githubProjectSortOverride?.fieldId ?? PROJECT_VIEW_DEFAULT_SORT}
onSelect={(fieldId) => {
if (fieldId === PROJECT_VIEW_DEFAULT_SORT) {
setGithubProjectSortOverride(null)
return
}
setGithubProjectSortOverride((current) => {
if (!current || current.fieldId !== fieldId) {
return { fieldId, direction: 'ASC' }
}
if (current.direction === 'ASC') {
return { fieldId, direction: 'DESC' }
}
return null
})
}}
onClose={() => setShowGitHubProjectSortPicker(false)}
/>
)
}
@@ -0,0 +1,156 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import { Pressable, Text, View, Linking, ExternalLink, colors } from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
export function renderMobileTasksGitHubViewControls(model: ConnectionPresentationModel) {
const {
activeGitHubProjectView,
activeProjectLabel,
githubIssueSourceLabel,
githubIssueSourceRows,
githubMode,
githubModeLabel,
githubPresetLabel,
githubProjectAvailableSummaryFields,
githubProjectFieldsLabel,
githubProjectSortLabel,
githubProjectTable,
provider,
selectedGitHubProjectViewUrl,
setShowGitHubIssueSourcePicker,
setShowGitHubKindPicker,
setShowGitHubPresetPicker,
setShowGitHubProjectFieldsPicker,
setShowGitHubProjectPicker,
setShowGitHubProjectSortPicker,
setShowGitHubProjectViewPicker,
taskUiReady,
visibleGitHubProjectRows
} = model
return (
provider === 'github' && (
<>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubKindPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{githubModeLabel}</Text>
</Pressable>
{githubMode === 'items' ? (
<>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubPresetPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{githubPresetLabel}</Text>
</Pressable>
{githubIssueSourceRows.length > 0 ? (
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubIssueSourcePicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>Source: {githubIssueSourceLabel}</Text>
</Pressable>
) : null}
</>
) : (
<>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubProjectPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{activeProjectLabel}</Text>
</Pressable>
{activeGitHubProjectView ? (
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubProjectViewPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{activeGitHubProjectView.name}</Text>
</Pressable>
) : null}
{githubProjectTable ? (
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubProjectSortPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>Sort: {githubProjectSortLabel}</Text>
</Pressable>
) : null}
{githubProjectAvailableSummaryFields.length > 0 ? (
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowGitHubProjectFieldsPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>Fields: {githubProjectFieldsLabel}</Text>
</Pressable>
) : null}
{githubProjectTable ? (
<View style={styles.segmentCountPill}>
<Text style={styles.segmentSecondaryText}>{visibleGitHubProjectRows.length}</Text>
</View>
) : null}
{selectedGitHubProjectViewUrl ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Open view in GitHub"
style={styles.segmentIconButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
void Linking.openURL(selectedGitHubProjectViewUrl)
}}
>
<ExternalLink size={14} color={colors.textSecondary} />
</Pressable>
) : null}
</>
)}
</>
)
)
}
@@ -0,0 +1,235 @@
import { projectRowType } from './mobile-tasks-item-mapping'
import type {
HostedReviewItem,
HostedReviewMergeMethod,
PendingHostedMerge,
PendingHostedStateChange,
PendingProjectGitHubMerge,
TaskItem
} from './mobile-tasks-project-workspace-types'
import type {
GitHubAssignableUser,
GitHubPRReviewSummary,
GitHubPRReviewerRow,
GitHubWorkItem
} from './mobile-tasks-provider-detail-types'
export const LINEAR_PRIORITY_LABELS: Record<number, string> = {
0: 'None',
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low'
}
export function getLinearPriorityLabel(priority: number): string {
return LINEAR_PRIORITY_LABELS[priority] ?? `P${priority}`
}
export function getLinearPriorityRank(priority: number): number {
return priority === 0 ? 5 : priority
}
export function formatGitHubReviewState(state: string | null | undefined): string {
switch (state) {
case 'APPROVED':
return 'Approved'
case 'CHANGES_REQUESTED':
return 'Changes requested'
case 'COMMENTED':
return 'Commented'
case 'DISMISSED':
return 'Dismissed'
case 'PENDING':
return 'Pending'
default:
return 'Reviewed'
}
}
export function getGitHubReviewerRows(item: {
reviewRequests?: GitHubAssignableUser[]
latestReviews?: GitHubPRReviewSummary[]
}): GitHubPRReviewerRow[] {
const byLogin = new Map<string, GitHubPRReviewerRow>()
for (const user of item.reviewRequests ?? []) {
const login = user.login.trim()
if (!login) {
continue
}
byLogin.set(login.toLowerCase(), {
login,
name: user.name,
avatarUrl: user.avatarUrl,
stateLabel: 'Requested'
})
}
for (const review of item.latestReviews ?? []) {
const login = review.login.trim()
const key = login.toLowerCase()
if (!login || byLogin.has(key)) {
continue
}
byLogin.set(key, {
login,
name: null,
avatarUrl: review.avatarUrl,
stateLabel: formatGitHubReviewState(review.state)
})
}
return Array.from(byLogin.values())
}
export function getGitHubReviewSummary(item: {
reviewDecision?: string | null
reviewRequests?: GitHubAssignableUser[]
latestReviews?: GitHubPRReviewSummary[]
}): string {
if (item.reviewDecision === 'APPROVED') {
return 'Approved'
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return 'Changes requested'
}
const rows = getGitHubReviewerRows(item)
if (rows.length === 0) {
return 'No reviewers'
}
if (rows.length === 1) {
return `${rows[0]!.login} - ${rows[0]!.stateLabel}`
}
return `${rows[0]!.login} +${rows.length - 1}`
}
export function formatGitHubPRDelta(item: GitHubWorkItem): string | null {
const parts: string[] = []
if (typeof item.additions === 'number') {
parts.push(`+${item.additions}`)
}
if (typeof item.deletions === 'number') {
parts.push(`-${item.deletions}`)
}
if (typeof item.changedFiles === 'number') {
parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`)
}
return parts.length > 0 ? parts.join(' ') : null
}
export function hostedBranchSummary(item: TaskItem): { head: string; base: string } | null {
if (item.provider === 'github' && item.source.type === 'pr') {
return {
head: item.source.branchName?.trim() || 'unknown head',
base: item.source.baseRefName?.trim() || 'base'
}
}
if (item.provider === 'gitlab' && item.source.type === 'mr') {
return {
head: item.source.branchName?.trim() || 'unknown head',
base: item.source.baseRefName?.trim() || 'base'
}
}
return null
}
export function getGitHubMergeLabel(item: GitHubWorkItem): string {
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
return 'Merge'
}
if (item.state === 'merged') {
return 'Merged'
}
if (item.state === 'closed') {
return 'Closed'
}
if (item.mergeable === 'CONFLICTING') {
return 'Conflicts'
}
if (item.mergeStateStatus === 'BEHIND') {
return 'Behind'
}
if (item.mergeStateStatus === 'BLOCKED') {
return 'Blocked'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'Able to merge'
}
return 'Unknown'
}
export function getHostedReviewMergeMethodLabel(method: HostedReviewMergeMethod): string {
if (method === 'squash') {
return 'Squash and merge'
}
if (method === 'rebase') {
return 'Rebase and merge'
}
return 'Create merge commit'
}
export function hostedReviewMergeTargetLabel(item: HostedReviewItem): string {
return item.provider === 'gitlab' ? 'merge request' : 'PR'
}
export function getHostedMergeConfirmMessage(pending: PendingHostedMerge): string {
const target = hostedReviewMergeTargetLabel(pending.item)
if (pending.method === 'squash') {
return `Squash and merge ${target} #${pending.item.source.number}?`
}
const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge'
return `${action} ${target} #${pending.item.source.number}?`
}
export function getProjectGitHubMergeConfirmMessage(pending: PendingProjectGitHubMerge): string {
const number = pending.row.content.number
if (pending.method === 'squash') {
return `Squash and merge PR #${number}?`
}
const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge'
return `${action} PR #${number}?`
}
export function hostedStateChangeAction(nextState: PendingHostedStateChange['nextState']): string {
return nextState === 'closed' ? 'Close' : 'Reopen'
}
export function hostedStateChangeTarget(pending: PendingHostedStateChange): {
titleTarget: string
labelTarget: string
number: number | null
} {
if (pending.source === 'project') {
const type = projectRowType(pending.row)
return {
titleTarget: type === 'pr' ? 'Pull Request' : 'Issue',
labelTarget: type === 'pr' ? 'PR' : 'Issue',
number: pending.row.content.number
}
}
if (pending.item.provider === 'gitlab') {
return {
titleTarget: pending.item.source.type === 'mr' ? 'Merge Request' : 'Issue',
labelTarget: pending.item.source.type === 'mr' ? 'MR' : 'Issue',
number: pending.item.source.number
}
}
return {
titleTarget: pending.item.source.type === 'pr' ? 'Pull Request' : 'Issue',
labelTarget: pending.item.source.type === 'pr' ? 'PR' : 'Issue',
number: pending.item.source.number
}
}
export function getHostedStateConfirmTitle(pending: PendingHostedStateChange): string {
const target = hostedStateChangeTarget(pending)
return `${hostedStateChangeAction(pending.nextState)} ${target.titleTarget}`
}
export function getHostedStateConfirmMessage(pending: PendingHostedStateChange): string {
const target = hostedStateChangeTarget(pending)
return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget} #${target.number}?`
}
export function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string {
const target = hostedStateChangeTarget(pending)
return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}`
}
@@ -0,0 +1,209 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
View,
Pressable,
Plus,
colors,
Text,
Linking,
ExternalLink,
Copy,
RefreshCw,
X,
GitBranch
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
taskExternalOpenLabel,
type TaskItem,
taskStatusActionLabel,
isGitHubPrMergeBlocked
} from './mobile-tasks-legacy-foundation'
export function renderMobileTasksItemActions(model: ConnectionPresentationModel) {
const {
actionItem,
copiedLinkKey,
copyTaskLink,
copyTextToClipboard,
creatingKey,
mutatingStatus,
openWorkspaceCreate,
setLinearStatusPickerItem,
setMergeMethodTaskItem,
setPendingHostedStateChange,
setWorkspaceRepoPickerItem,
workspaceRepos
} = model
if (!actionItem) {
return null
}
return (
<View style={styles.actionGroup}>
<Pressable
style={styles.actionRow}
disabled={creatingKey === actionItem.key}
onPress={() => {
if (actionItem.provider === 'linear' && workspaceRepos.length > 1) {
setWorkspaceRepoPickerItem(actionItem)
return
}
openWorkspaceCreate(actionItem)
}}
>
<Plus size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>
{creatingKey === actionItem.key ? 'Creating...' : 'Create Workspace'}
</Text>
</Pressable>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
onPress={() => void Linking.openURL(actionItem.source.url)}
>
<ExternalLink size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>{taskExternalOpenLabel(actionItem)}</Text>
</Pressable>
{actionItem.provider === 'linear' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
onPress={() =>
void copyTextToClipboard(`linear-url:${actionItem.key}`, actionItem.source.url)
}
>
<Copy size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>
{copiedLinkKey === `linear-url:${actionItem.key}` ? 'Copied' : 'Copy Linear link'}
</Text>
</Pressable>
</>
) : null}
{actionItem.provider === 'github' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
onPress={() => void copyTaskLink(`task:${actionItem.key}`, actionItem.source.url)}
>
<Copy size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>
{copiedLinkKey === `task:${actionItem.key}` ? 'Copied' : 'Copy GitHub link'}
</Text>
</Pressable>
</>
) : null}
{actionItem.provider === 'github' && actionItem.source.state !== 'merged' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
disabled={mutatingStatus}
onPress={() => {
const githubItem = actionItem as Extract<TaskItem, { provider: 'github' }>
setPendingHostedStateChange({
source: 'task',
item: githubItem,
nextState: githubItem.source.state === 'closed' ? 'open' : 'closed'
})
}}
>
{actionItem.source.state === 'closed' ? (
<RefreshCw size={16} color={colors.textPrimary} />
) : (
<X size={16} color={colors.textPrimary} />
)}
<Text style={styles.actionText}>{taskStatusActionLabel(actionItem)}</Text>
</Pressable>
</>
) : null}
{actionItem.provider === 'github' &&
actionItem.source.type === 'pr' &&
actionItem.source.state === 'open' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
disabled={mutatingStatus || isGitHubPrMergeBlocked(actionItem)}
onPress={() =>
setMergeMethodTaskItem(actionItem as Extract<TaskItem, { provider: 'github' }>)
}
>
<GitBranch size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>Merge pull request</Text>
</Pressable>
{isGitHubPrMergeBlocked(actionItem) ? (
<Text style={styles.emptyInlineText}>GitHub reports merge conflicts.</Text>
) : null}
</>
) : null}
{actionItem.provider === 'gitlab' &&
actionItem.source.state !== 'merged' &&
actionItem.source.state !== 'locked' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
disabled={mutatingStatus}
onPress={() => {
const gitlabItem = actionItem as Extract<TaskItem, { provider: 'gitlab' }>
setPendingHostedStateChange({
source: 'task',
item: gitlabItem,
nextState: gitlabItem.source.state === 'closed' ? 'opened' : 'closed'
})
}}
>
{actionItem.source.state === 'closed' ? (
<RefreshCw size={16} color={colors.textPrimary} />
) : (
<X size={16} color={colors.textPrimary} />
)}
<Text style={styles.actionText}>{taskStatusActionLabel(actionItem)}</Text>
</Pressable>
</>
) : null}
{actionItem.provider === 'gitlab' &&
actionItem.source.type === 'mr' &&
actionItem.source.state === 'opened' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
disabled={mutatingStatus}
onPress={() =>
setMergeMethodTaskItem(actionItem as Extract<TaskItem, { provider: 'gitlab' }>)
}
>
<GitBranch size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>Merge merge request</Text>
</Pressable>
</>
) : null}
{actionItem.provider === 'linear' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
disabled={mutatingStatus}
onPress={() => {
setLinearStatusPickerItem(actionItem as Extract<TaskItem, { provider: 'linear' }>)
}}
>
<GitBranch size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>Change status</Text>
</Pressable>
</>
) : null}
</View>
)
}
@@ -0,0 +1,160 @@
import { type ReactNode, View, Text } from './mobile-tasks-dependencies'
import { gitLabTodoTargetLabel } from './mobile-tasks-item-mapping'
import { COMMENT_REACTION_EMOJI } from './mobile-tasks-options'
import type { DetailCommentGroup } from './mobile-tasks-view-state-types'
import type { TaskItem } from './mobile-tasks-project-workspace-types'
import type { DetailComment } from './mobile-tasks-provider-detail-types'
import { styles } from './mobile-tasks-legacy-styles'
export function taskKindLabel(item: TaskItem): string {
if (item.provider === 'github') {
return item.source.type === 'pr' ? 'Pull request' : 'Issue'
}
if (item.provider === 'gitlab') {
return item.source.type === 'mr' ? 'Merge request' : 'Issue'
}
if (item.provider === 'gitlabTodo') {
return `${gitLabTodoTargetLabel(item.source)} todo`
}
return 'Linear ticket'
}
export function taskExternalOpenLabel(item: TaskItem): string {
if (item.provider === 'github') {
return 'Open in GitHub'
}
if (item.provider === 'gitlab' || item.provider === 'gitlabTodo') {
return 'Open in GitLab'
}
return 'Open in Linear'
}
export function taskStatusActionLabel(item: TaskItem): string {
const verb =
item.provider === 'github' || item.provider === 'gitlab'
? item.source.state === 'closed'
? 'Reopen'
: 'Close'
: ''
return verb ? `${verb} ${taskKindLabel(item).toLowerCase()}` : ''
}
export function isGitHubPrMergeBlocked(item: Extract<TaskItem, { provider: 'github' }>): boolean {
return item.source.type === 'pr' && item.source.mergeable === 'CONFLICTING'
}
export function commentAuthor(comment: DetailComment): string {
return comment.author ?? comment.user?.displayName ?? 'unknown'
}
export function commentDate(value: string | undefined): string {
if (!value) {
return ''
}
const time = Date.parse(value)
return Number.isFinite(time) ? new Date(time).toLocaleDateString() : ''
}
export function formatDurationSeconds(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return ''
}
const seconds = Math.max(0, Math.floor(value))
if (seconds >= 60) {
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
}
return `${seconds}s`
}
export function commentSourceLabel(comment: DetailComment): string {
if (comment.path) {
const line =
typeof comment.line === 'number'
? typeof comment.startLine === 'number' && comment.startLine !== comment.line
? `${comment.startLine}-${comment.line}`
: String(comment.line)
: ''
const location = line ? `${comment.path}:${line}` : comment.path
return `${comment.isResolved ? 'Resolved review' : 'Review'} · ${location}`
}
if (comment.threadId) {
return comment.isResolved ? 'Resolved review thread' : 'Review thread'
}
return 'Top-level comment'
}
export function groupDetailComments(comments: DetailComment[]): DetailCommentGroup[] {
const threads = new Map<string, { root: DetailComment; replies: DetailComment[] }>()
const groups: DetailCommentGroup[] = []
const emittedThreads = new Set<string>()
for (const comment of comments) {
if (!comment.threadId) {
continue
}
const existing = threads.get(comment.threadId)
if (existing) {
existing.replies.push(comment)
} else {
threads.set(comment.threadId, { root: comment, replies: [] })
}
}
for (const comment of comments) {
if (!comment.threadId) {
groups.push({ kind: 'standalone', comment })
continue
}
if (emittedThreads.has(comment.threadId)) {
continue
}
emittedThreads.add(comment.threadId)
const thread = threads.get(comment.threadId)
if (thread) {
groups.push({ kind: 'thread', threadId: comment.threadId, ...thread })
}
}
return groups
}
export function detailCommentGroupId(group: DetailCommentGroup): string {
return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}`
}
export function detailCommentGroupRoot(group: DetailCommentGroup): DetailComment {
return group.kind === 'thread' ? group.root : group.comment
}
export function detailCommentGroupCount(group: DetailCommentGroup): number {
return group.kind === 'thread' ? 1 + group.replies.length : 1
}
export function isResolvedDetailCommentGroup(group: DetailCommentGroup): boolean {
return detailCommentGroupRoot(group).isResolved === true
}
export function discussionSummary(count: number): string {
if (count === 0) {
return 'No comments yet'
}
return `${count} ${count === 1 ? 'comment' : 'comments'}`
}
export function renderCommentReactions(comment: DetailComment): ReactNode {
const reactions = (comment.reactions ?? []).filter((reaction) => reaction.count > 0)
if (reactions.length === 0) {
return null
}
return (
<View style={styles.reactionRow}>
{reactions.map((reaction) => (
<View key={reaction.content} style={styles.reactionChip}>
<Text style={styles.reactionText}>
{COMMENT_REACTION_EMOJI[reaction.content]} {reaction.count}
</Text>
</View>
))}
</View>
)
}
@@ -0,0 +1,292 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
View,
ActivityIndicator,
colors,
Text,
Pressable,
Linking,
ExternalLink,
TextInput
} from './mobile-tasks-dependencies'
import { styles, getGitLabPipelineStatusStyle } from './mobile-tasks-legacy-styles'
import {
taskKindLabel,
SHOW_MOBILE_DETAIL_LABEL_CHIPS,
SHOW_MOBILE_DETAIL_REVIEW_PANELS,
isFailedGitHubCheck,
formatDurationSeconds,
SHOW_MOBILE_LINEAR_DETAIL_TOOLS,
discussionSummary
} from './mobile-tasks-legacy-foundation'
import {
renderMobileTasksItemBodyEditor,
renderMobileTasksItemReviewPanel,
renderMobileTasksItemFiles
} from './mobile-tasks-item-review-sections'
import { renderMobileTasksItemFieldEditors } from './mobile-tasks-item-field-editors'
export function renderMobileTasksItemDetailContent(model: ConnectionPresentationModel) {
const {
actionItem,
addHostedItemComment,
addLinearComment,
createLinearSubIssue,
detailCommentGroups,
detailError,
detailLoading,
detailPayload,
itemCommentDraft,
linearCommentDraft,
linearSubIssueTitle,
mutatingStatus,
openLinearSubIssue,
refreshGitHubChecks,
renderCommentComposer,
renderDetailCommentGroup,
rerunGitHubChecks,
setItemCommentDraft,
setLinearCommentDraft,
setLinearSubIssueTitle
} = model
if (!actionItem) {
return null
}
return detailLoading ? (
<View style={styles.detailLoading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : detailError ? (
<Text style={styles.detailError}>{detailError}</Text>
) : detailPayload ? (
<>
<View style={styles.detailMetaGrid}>
<View style={styles.detailMetaItem}>
<Text style={styles.detailMetaLabel}>Type</Text>
<Text style={styles.detailMetaValue}>{taskKindLabel(actionItem)}</Text>
</View>
<View style={styles.detailMetaItem}>
<Text style={styles.detailMetaLabel}>Status</Text>
<Text style={styles.detailMetaValue}>{actionItem.status}</Text>
</View>
{detailPayload.provider === 'linear' && detailPayload.assignee ? (
<View style={styles.detailMetaItem}>
<Text style={styles.detailMetaLabel}>Assignee</Text>
<Text style={styles.detailMetaValue}>{detailPayload.assignee}</Text>
</View>
) : null}
{detailPayload.provider === 'linear' && detailPayload.project ? (
<View style={styles.detailMetaItem}>
<Text style={styles.detailMetaLabel}>Project</Text>
<Text style={styles.detailMetaValue}>{detailPayload.project.name}</Text>
</View>
) : null}
{(detailPayload.provider === 'github' || detailPayload.provider === 'gitlab') &&
detailPayload.assignees.length > 0 ? (
<View style={styles.detailMetaItem}>
<Text style={styles.detailMetaLabel}>Assignees</Text>
<Text style={styles.detailMetaValue}>{detailPayload.assignees.join(', ')}</Text>
</View>
) : null}
</View>
{SHOW_MOBILE_DETAIL_LABEL_CHIPS && detailPayload.labels.length > 0 ? (
<View style={styles.chipRow}>
{detailPayload.labels.map((label) => (
<View key={label} style={styles.detailChip}>
<Text style={styles.detailChipText}>{label}</Text>
</View>
))}
</View>
) : null}
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Description</Text>
{renderMobileTasksItemBodyEditor(model)}
</View>
{renderMobileTasksItemFieldEditors(model)}
{renderMobileTasksItemReviewPanel(model)}
{SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
detailPayload.provider === 'github' &&
actionItem.provider === 'github' &&
actionItem.source.type === 'pr' ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Checks</Text>
<View style={styles.inlineActionRow}>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={mutatingStatus}
onPress={() => void refreshGitHubChecks(actionItem)}
>
<Text style={styles.inlineSaveText}>Refresh</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={mutatingStatus || !detailPayload.checks.some(isFailedGitHubCheck)}
onPress={() => void rerunGitHubChecks(actionItem, true)}
>
<Text style={styles.inlineSaveText}>Rerun failed</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={mutatingStatus || detailPayload.checks.length === 0}
onPress={() => void rerunGitHubChecks(actionItem, false)}
>
<Text style={styles.inlineSaveText}>Rerun all</Text>
</Pressable>
</View>
</View>
{detailPayload.checks.length === 0 ? (
<Text style={styles.detailMuted}>No checks found.</Text>
) : (
detailPayload.checks.map((check) => (
<Pressable
key={`${check.name}:${check.status}:${check.url ?? ''}`}
style={styles.fileActionRow}
disabled={!check.url}
onPress={() => {
if (check.url) {
void Linking.openURL(check.url)
}
}}
>
<Text style={styles.detailLine} numberOfLines={2}>
{check.name} · {check.conclusion ?? check.status}
</Text>
{check.url ? <ExternalLink size={14} color={colors.textSecondary} /> : null}
</Pressable>
))
)}
</View>
) : null}
{renderMobileTasksItemFiles(model)}
{SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
detailPayload.provider === 'gitlab' &&
actionItem.provider === 'gitlab' &&
actionItem.source.type === 'mr' ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Pipeline</Text>
<Text style={styles.detailSectionMeta}>
{detailPayload.pipelineJobs.length
? `${detailPayload.pipelineJobs.length} jobs`
: 'None'}
</Text>
</View>
{detailPayload.pipelineJobs.length === 0 ? (
<Text style={styles.detailMuted}>No pipeline runs for this MR.</Text>
) : (
detailPayload.pipelineJobs.map((job) => {
const duration = formatDurationSeconds(job.duration)
return (
<Pressable
key={`${job.id ?? job.stage}:${job.name}`}
style={styles.fileCard}
disabled={!job.webUrl}
onPress={() => {
if (job.webUrl) {
void Linking.openURL(job.webUrl)
}
}}
>
<View style={styles.fileActionRow}>
<Text style={styles.detailLine} numberOfLines={2}>
{job.name}
</Text>
<View
style={[styles.pipelineStatusChip, getGitLabPipelineStatusStyle(job.status)]}
>
<Text style={styles.pipelineStatusText}>{job.status}</Text>
</View>
</View>
<Text style={styles.detailMuted}>
{[job.stage, duration].filter(Boolean).join(' · ')}
</Text>
</Pressable>
)
})
)}
</View>
) : null}
{SHOW_MOBILE_LINEAR_DETAIL_TOOLS &&
detailPayload.provider === 'linear' &&
actionItem.provider === 'linear' ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Sub-issues</Text>
<Text style={styles.detailSectionMeta}>{detailPayload.children.length || 'None'}</Text>
</View>
{detailPayload.children.length === 0 ? (
<Text style={styles.detailMuted}>No sub-issues.</Text>
) : (
detailPayload.children.map((child) => (
<Pressable
key={child.id}
style={styles.fileActionRow}
disabled={mutatingStatus}
onPress={() => void openLinearSubIssue(child, actionItem.source.workspaceId)}
>
<Text style={styles.detailLine}>
{child.identifier} · {child.title}
</Text>
<Text style={styles.detailSectionMeta}>Open</Text>
</Pressable>
))
)}
<TextInput
style={[styles.input, styles.stackedInput]}
value={linearSubIssueTitle}
onChangeText={setLinearSubIssueTitle}
placeholder="Sub-issue title"
placeholderTextColor={colors.textMuted}
/>
<Pressable
style={styles.inlineSaveButton}
disabled={mutatingStatus || linearSubIssueTitle.trim().length === 0}
onPress={() => void createLinearSubIssue(actionItem)}
>
<Text style={styles.inlineSaveText}>Add sub-issue</Text>
</Pressable>
</View>
) : null}
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Discussion</Text>
<Text style={styles.detailSectionMeta}>
{discussionSummary(detailPayload.comments.length)}
</Text>
</View>
{detailPayload.comments.length === 0 ? (
<Text style={styles.detailMuted}>No comments.</Text>
) : (
detailCommentGroups.map(renderDetailCommentGroup)
)}
{(detailPayload.provider === 'github' && actionItem.provider === 'github') ||
(detailPayload.provider === 'gitlab' && actionItem.provider === 'gitlab')
? renderCommentComposer({
value: itemCommentDraft,
onChangeText: setItemCommentDraft,
disabled: mutatingStatus,
onSubmit: () => void addHostedItemComment(actionItem)
})
: null}
{detailPayload.provider === 'linear' && actionItem.provider === 'linear'
? renderCommentComposer({
value: linearCommentDraft,
onChangeText: setLinearCommentDraft,
disabled: mutatingStatus,
onSubmit: () => void addLinearComment(actionItem)
})
: null}
</View>
</>
) : null
}
@@ -0,0 +1,56 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
BottomDrawer,
View,
TaskProviderLogo,
colors,
Text,
Pressable,
RefreshCw
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import { taskKindLabel } from './mobile-tasks-legacy-foundation'
import { renderMobileTasksItemDetailContent } from './mobile-tasks-item-detail-content'
import { renderMobileTasksItemActions } from './mobile-tasks-item-actions'
export function renderMobileTasksItemDetailDrawer(model: ConnectionPresentationModel) {
const { actionItem, detailLoading, setActionItem, setDetailRefreshSeq, taskUiReady } = model
return (
<BottomDrawer visible={taskUiReady && actionItem != null} onClose={() => setActionItem(null)}>
{actionItem ? (
<View>
<View style={styles.sheetHeader}>
<View style={styles.sheetTitleRow}>
<TaskProviderLogo
provider={actionItem.provider}
size={16}
color={colors.textPrimary}
/>
<Text style={styles.sheetTitle} numberOfLines={2}>
{actionItem.title}
</Text>
<Pressable
style={styles.iconButton}
disabled={detailLoading}
accessibilityLabel="Refresh details"
onPress={() => setDetailRefreshSeq((current) => current + 1)}
>
<RefreshCw
size={16}
color={detailLoading ? colors.textMuted : colors.textSecondary}
/>
</Pressable>
</View>
<Text style={styles.sheetSubtitle}>
{taskKindLabel(actionItem)} · {actionItem.subtitle}
</Text>
</View>
<View style={styles.detailGroup}>{renderMobileTasksItemDetailContent(model)}</View>
{renderMobileTasksItemActions(model)}
</View>
) : null}
</BottomDrawer>
)
}
@@ -0,0 +1,263 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
SHOW_MOBILE_DETAIL_METADATA_EDITORS,
splitCommaList
} from './mobile-tasks-legacy-foundation'
import {
View,
Text,
TextInput,
colors,
Pressable,
ActivityIndicator,
Check
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
export function renderMobileTasksItemFieldEditors(model: ConnectionPresentationModel) {
const {
actionItem,
detailPayload,
itemAddAssigneesDraft,
itemAddLabelsDraft,
itemAssignableUsers,
itemAssignableUsersError,
itemAssignableUsersLoading,
itemAvailableLabels,
itemLabelsError,
itemLabelsLoading,
itemRemoveAssigneesDraft,
itemRemoveLabelsDraft,
itemTitleDraft,
mutatingStatus,
setItemAddAssigneesDraft,
setItemAddLabelsDraft,
setItemRemoveAssigneesDraft,
setItemRemoveLabelsDraft,
setItemTitleDraft,
updateGitHubIssueMetadata,
updateGitHubPullRequestMetadata,
updateGitLabIssueMetadata
} = model
if (!actionItem || !detailPayload) {
return null
}
return SHOW_MOBILE_DETAIL_METADATA_EDITORS &&
((actionItem.provider === 'github' &&
detailPayload.provider === 'github' &&
(actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) ||
(actionItem.provider === 'gitlab' &&
(actionItem.source.type === 'issue' || actionItem.source.type === 'mr') &&
detailPayload.provider === 'gitlab')) ? (
<>
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Title</Text>
<TextInput
style={styles.input}
value={itemTitleDraft}
onChangeText={setItemTitleDraft}
placeholder="Title"
placeholderTextColor={colors.textMuted}
/>
<Pressable
style={styles.inlineSaveButton}
disabled={
mutatingStatus ||
itemTitleDraft.trim().length === 0 ||
itemTitleDraft.trim() === actionItem.title
}
onPress={() => {
if (actionItem.provider === 'github' && actionItem.source.type === 'pr') {
void updateGitHubPullRequestMetadata(actionItem, {
title: itemTitleDraft.trim()
})
return
}
if (actionItem.provider === 'github') {
void updateGitHubIssueMetadata(actionItem, {
title: itemTitleDraft.trim()
})
return
}
if (actionItem.provider === 'gitlab') {
void updateGitLabIssueMetadata(actionItem, {
title: itemTitleDraft.trim()
})
}
}}
>
<Text style={styles.inlineSaveText}>Save title</Text>
</Pressable>
</View>
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Labels</Text>
<Text style={styles.detailSectionMeta}>{detailPayload.labels.length || 'None'}</Text>
</View>
{actionItem.provider === 'github' ? (
itemLabelsLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading labels...</Text>
</View>
) : itemLabelsError ? (
<Text style={styles.detailError}>{itemLabelsError}</Text>
) : itemAvailableLabels.length === 0 ? (
<Text style={styles.detailMuted}>No labels in this repository.</Text>
) : (
<View style={styles.chipRow}>
{[...new Set([...itemAvailableLabels, ...detailPayload.labels])].map((label) => {
const selected = detailPayload.labels.includes(label)
return (
<Pressable
key={label}
style={[styles.detailChip, selected ? styles.detailChipSelected : undefined]}
disabled={mutatingStatus}
onPress={() =>
void updateGitHubIssueMetadata(
actionItem,
selected ? { removeLabels: [label] } : { addLabels: [label] }
)
}
>
<View style={styles.issueTypeChipContent}>
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
<Text style={styles.detailChipText}>{label}</Text>
</View>
</Pressable>
)
})}
</View>
)
) : (
<>
<TextInput
style={styles.input}
value={itemAddLabelsDraft}
onChangeText={setItemAddLabelsDraft}
placeholder="Add labels, comma separated"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
<TextInput
style={[styles.input, styles.stackedInput]}
value={itemRemoveLabelsDraft}
onChangeText={setItemRemoveLabelsDraft}
placeholder="Remove labels, comma separated"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
<Pressable
style={styles.inlineSaveButton}
disabled={
mutatingStatus ||
(splitCommaList(itemAddLabelsDraft).length === 0 &&
splitCommaList(itemRemoveLabelsDraft).length === 0)
}
onPress={() =>
void updateGitLabIssueMetadata(actionItem, {
addLabels: splitCommaList(itemAddLabelsDraft),
removeLabels: splitCommaList(itemRemoveLabelsDraft)
})
}
>
<Text style={styles.inlineSaveText}>Update labels</Text>
</Pressable>
</>
)}
</View>
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Assignees</Text>
<Text style={styles.detailSectionMeta}>{detailPayload.assignees.length || 'None'}</Text>
</View>
{actionItem.provider === 'github' ? (
itemAssignableUsersLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading assignees...</Text>
</View>
) : itemAssignableUsersError ? (
<Text style={styles.detailError}>{itemAssignableUsersError}</Text>
) : itemAssignableUsers.length === 0 ? (
<Text style={styles.detailMuted}>No assignable users found for this repository.</Text>
) : (
<View style={styles.chipRow}>
{[
...new Map(
[
...itemAssignableUsers,
...detailPayload.assignees.map((login) => ({
login,
name: null,
avatarUrl: null
}))
].map((user) => [user.login, user])
).values()
].map((user) => {
const selected = detailPayload.assignees.includes(user.login)
return (
<Pressable
key={user.login}
style={[styles.detailChip, selected ? styles.detailChipSelected : undefined]}
disabled={mutatingStatus}
onPress={() =>
void updateGitHubIssueMetadata(
actionItem,
selected
? { removeAssignees: [user.login] }
: { addAssignees: [user.login] }
)
}
>
<View style={styles.issueTypeChipContent}>
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
<Text style={styles.detailChipText}>{user.login}</Text>
</View>
</Pressable>
)
})}
</View>
)
) : actionItem.source.type === 'issue' ? (
<>
<TextInput
style={styles.input}
value={itemAddAssigneesDraft}
onChangeText={setItemAddAssigneesDraft}
placeholder="Add usernames, comma separated"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
<TextInput
style={[styles.input, styles.stackedInput]}
value={itemRemoveAssigneesDraft}
onChangeText={setItemRemoveAssigneesDraft}
placeholder="Remove usernames, comma separated"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
<Pressable
style={styles.inlineSaveButton}
disabled={
mutatingStatus ||
(splitCommaList(itemAddAssigneesDraft).length === 0 &&
splitCommaList(itemRemoveAssigneesDraft).length === 0)
}
onPress={() =>
void updateGitLabIssueMetadata(actionItem, {
addAssignees: splitCommaList(itemAddAssigneesDraft),
removeAssignees: splitCommaList(itemRemoveAssigneesDraft)
})
}
>
<Text style={styles.inlineSaveText}>Update assignees</Text>
</Pressable>
</>
) : null}
</View>
</>
) : null
}
@@ -0,0 +1,301 @@
import {
type RpcSuccess,
type TaskProvider,
type GitHubOwnerRepo,
colors
} from './mobile-tasks-dependencies'
import type {
GitHubPreset,
GitHubProjectRow,
GitHubTaskKind,
LinearFilter
} from './mobile-tasks-view-state-types'
import type { TaskItem } from './mobile-tasks-project-workspace-types'
import type {
GitHubWorkItem,
GitLabTodo,
GitLabWorkItem,
LinearIssue,
RepoSummary
} from './mobile-tasks-provider-detail-types'
export function isSuccess(response: unknown): response is RpcSuccess {
return Boolean(response && typeof response === 'object' && (response as RpcSuccess).ok)
}
export function taskTime(value: string): number {
const time = Date.parse(value)
return Number.isFinite(time) ? time : 0
}
export function formatUpdatedAt(value: string): string {
const time = taskTime(value)
if (!time) {
return ''
}
const minutes = Math.max(0, Math.floor((Date.now() - time) / 60_000))
if (minutes < 60) {
return `${minutes}m`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h`
}
return `${Math.floor(hours / 24)}d`
}
export function getTaskPresetQuery(preset: GitHubPreset): string {
switch (preset) {
case 'my-issues':
return 'assignee:@me is:issue is:open'
case 'prs':
return 'is:pr is:open'
case 'my-prs':
return 'author:@me is:pr is:open'
case 'review':
return 'review-requested:@me is:pr is:open'
case 'issues':
default:
return 'is:issue is:open'
}
}
export function isTaskProvider(value: unknown): value is TaskProvider {
return value === 'github' || value === 'gitlab' || value === 'linear'
}
export function normalizeGitHubPreset(value: unknown): GitHubPreset {
return value === 'my-issues' ||
value === 'prs' ||
value === 'my-prs' ||
value === 'review' ||
value === 'issues'
? value
: 'issues'
}
export function normalizeLinearFilter(value: unknown): LinearFilter {
return value === 'assigned' || value === 'created' || value === 'completed' || value === 'all'
? value
: 'all'
}
export function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHubTaskKind {
if (/\bis:pr\b/i.test(query)) {
return 'prs'
}
if (/\bis:issue\b/i.test(query)) {
return 'issues'
}
return fallbackPreset === 'prs' || fallbackPreset === 'my-prs' || fallbackPreset === 'review'
? 'prs'
: 'issues'
}
export function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null {
if (row.itemType === 'ISSUE') {
return 'issue'
}
if (row.itemType === 'PULL_REQUEST') {
return 'pr'
}
return null
}
export function canCreateWorkspaceFromProjectRow(row: GitHubProjectRow): boolean {
// Why: desktop only exposes Project "Start work" for backed issue/PR rows
// with enough GitHub identity to build the linked work item.
return projectRowType(row) !== null && row.content.number != null && Boolean(row.content.url)
}
export function splitRepositorySlug(slug: string | null): { owner: string; repo: string } | null {
const [owner, repo] = slug?.split('/') ?? []
return owner && repo ? { owner, repo } : null
}
export function projectRowGitHubRepository(
row: GitHubProjectRow,
host: string
): GitHubOwnerRepo | null {
const slug = splitRepositorySlug(row.content.repository)
return slug ? { ...slug, host } : null
}
export const GITHUB_PROJECT_OPTION_COLORS: Record<string, string> = {
GRAY: '#8b949e',
RED: '#f85149',
ORANGE: '#db6d28',
YELLOW: '#d29922',
GREEN: '#3fb950',
BLUE: '#58a6ff',
PURPLE: '#bc8cff',
PINK: '#db61a2'
}
export function githubProjectOptionColor(color: string | null | undefined): string {
if (!color) {
return colors.textMuted
}
const upper = color.toUpperCase()
const mapped = GITHUB_PROJECT_OPTION_COLORS[upper]
if (mapped) {
return mapped
}
const hex = color.startsWith('#') ? color : `#${color}`
return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : colors.textMuted
}
export function projectRowStatusLabel(row: GitHubProjectRow): string {
if (row.itemType === 'DRAFT_ISSUE') {
return 'Draft'
}
if (row.itemType === 'REDACTED') {
return 'Redacted'
}
if (row.content.isDraft) {
return 'Draft'
}
if (row.content.state === 'MERGED') {
return 'Merged'
}
if (row.content.state === 'CLOSED') {
return 'Closed'
}
return 'Open'
}
export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
const trimmed = query.trim()
if (!trimmed) {
return getTaskPresetQuery(kind === 'prs' ? 'prs' : 'issues')
}
if (/\bis:(?:issue|pr)\b/i.test(trimmed)) {
return trimmed
}
return `${kind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
}
export function gitHubStatusLabel(item: GitHubWorkItem): string {
if (item.state === 'merged') {
return 'Merged'
}
if (item.state === 'draft') {
return 'Draft'
}
return item.state === 'closed' ? 'Closed' : 'Open'
}
export function gitHubTaskSubtitle(item: GitHubWorkItem): string {
return `${item.repoName} ${item.type === 'pr' ? '#' : '#'}${item.number}`
}
export function createGitHubTask(
repo: RepoSummary,
item: Omit<GitHubWorkItem, 'repoId' | 'repoName'>
) {
const source: GitHubWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName }
return {
key: `github:${repo.id}:${item.type}:${item.number}`,
provider: 'github' as const,
title: item.title,
subtitle: gitHubTaskSubtitle(source),
status: gitHubStatusLabel(source),
updatedAt: item.updatedAt,
source
}
}
export function gitLabStatusLabel(item: GitLabWorkItem): string {
if (item.state === 'opened') {
return 'Open'
}
if (item.state === 'merged') {
return 'Merged'
}
if (item.state === 'draft') {
return 'Draft'
}
return item.state === 'closed' ? 'Closed' : 'Locked'
}
export function createGitLabTask(
repo: RepoSummary,
item: Omit<GitLabWorkItem, 'repoId' | 'repoName'>
) {
const source: GitLabWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName }
return {
key: `gitlab:${repo.id}:${item.type}:${item.number}`,
provider: 'gitlab' as const,
title: item.title,
subtitle: `${repo.displayName} ${item.type === 'mr' ? '!' : '#'}${item.number}`,
status: gitLabStatusLabel(source),
updatedAt: item.updatedAt,
source
}
}
export function gitLabTodoTargetLabel(todo: Pick<GitLabTodo, 'targetType'>): string {
if (todo.targetType === 'MergeRequest') {
return 'Merge request'
}
if (todo.targetType === 'Issue') {
return 'Issue'
}
return 'GitLab todo'
}
export function gitLabTodoTargetRef(todo: Pick<GitLabTodo, 'targetType' | 'targetIid'>): string {
if (!todo.targetIid) {
return ''
}
if (todo.targetType === 'MergeRequest') {
return `!${todo.targetIid}`
}
if (todo.targetType === 'Issue') {
return `#${todo.targetIid}`
}
return String(todo.targetIid)
}
export function createGitLabTodoTask(todo: GitLabTodo): TaskItem {
const targetRef = gitLabTodoTargetRef(todo)
return {
key: `gitlab-todo:${todo.id}`,
provider: 'gitlabTodo',
title: todo.targetTitle || todo.targetUrl,
subtitle: `${todo.projectPath}${targetRef ? ` ${targetRef}` : ''}`,
status: todo.actionName.replace(/_/g, ' ') || 'Todo',
updatedAt: todo.updatedAt,
source: todo
}
}
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
async function run(): Promise<void> {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
results[index] = await worker(items[index]!)
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()))
return results
}
export function createLinearTask(issue: LinearIssue): TaskItem {
return {
key: `linear:${issue.workspaceId ?? 'workspace'}:${issue.id}`,
provider: 'linear',
title: issue.title,
subtitle: `${issue.identifier} · ${issue.team.name}`,
status: issue.state.name,
updatedAt: issue.updatedAt,
source: issue
}
}
@@ -0,0 +1,280 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
SHOW_MOBILE_DETAIL_METADATA_EDITORS,
SHOW_MOBILE_DETAIL_REVIEW_PANELS,
getGitHubReviewSummary,
getGitHubReviewerRows,
splitReviewerList,
GitHubPrFileDiff
} from './mobile-tasks-legacy-foundation'
import {
TextInput,
colors,
Pressable,
Text,
MobileMarkdown,
View,
ActivityIndicator,
Check
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
export function renderMobileTasksItemBodyEditor(model: ConnectionPresentationModel) {
const {
actionItem,
detailPayload,
itemBodyDraft,
mutatingStatus,
setItemBodyDraft,
updateGitHubIssueMetadata,
updateGitHubPullRequestMetadata,
updateGitLabIssueMetadata
} = model
if (!actionItem || !detailPayload) {
return null
}
return SHOW_MOBILE_DETAIL_METADATA_EDITORS &&
((actionItem.provider === 'github' &&
detailPayload.provider === 'github' &&
(actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) ||
(actionItem.provider === 'gitlab' &&
detailPayload.provider === 'gitlab' &&
(actionItem.source.type === 'issue' || actionItem.source.type === 'mr'))) ? (
<>
<TextInput
style={[styles.input, styles.bodyInput]}
value={itemBodyDraft}
onChangeText={setItemBodyDraft}
placeholder="Description"
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<Pressable
style={styles.inlineSaveButton}
disabled={mutatingStatus || itemBodyDraft === detailPayload.body}
onPress={() => {
if (actionItem.provider === 'github' && actionItem.source.type === 'pr') {
void updateGitHubPullRequestMetadata(actionItem, {
body: itemBodyDraft
})
return
}
if (actionItem.provider === 'github' && actionItem.source.type === 'issue') {
void updateGitHubIssueMetadata(actionItem, {
body: itemBodyDraft
})
return
}
if (
actionItem.provider === 'gitlab' &&
(actionItem.source.type === 'issue' || actionItem.source.type === 'mr')
) {
void updateGitLabIssueMetadata(actionItem, {
body: itemBodyDraft
})
}
}}
>
<Text style={styles.inlineSaveText}>Save description</Text>
</Pressable>
<MobileMarkdown content={itemBodyDraft} fallback="No description." />
</>
) : (
<MobileMarkdown
content={detailPayload.provider === 'linear' ? detailPayload.description : detailPayload.body}
fallback="No description."
/>
)
}
export function renderMobileTasksItemReviewPanel(model: ConnectionPresentationModel) {
const {
actionItem,
detailPayload,
itemAssignableUsersError,
itemAssignableUsersLoading,
itemReviewerCandidates,
itemReviewersDraft,
itemSelectedReviewerLogins,
mutatingStatus,
requestGitHubReviewers,
setItemReviewersDraft
} = model
if (!actionItem || !detailPayload) {
return null
}
return SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
actionItem.provider === 'github' &&
actionItem.source.type === 'pr' ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Reviewers</Text>
{detailPayload.provider === 'github' ? (
<Text style={styles.detailSectionMeta}>{getGitHubReviewSummary(detailPayload)}</Text>
) : null}
</View>
{detailPayload.provider === 'github' ? (
getGitHubReviewerRows(detailPayload).length === 0 ? (
<Text style={styles.detailMuted}>No reviewers requested.</Text>
) : (
getGitHubReviewerRows(detailPayload).map((reviewer) => (
<View key={reviewer.login} style={styles.reviewerRow}>
<View style={styles.reviewerAvatar}>
<Text style={styles.reviewerAvatarText}>
{reviewer.login.slice(0, 1).toUpperCase()}
</Text>
</View>
<View style={styles.reviewerInfo}>
<Text style={styles.reviewerName} numberOfLines={1}>
{reviewer.login}
</Text>
{reviewer.name ? (
<Text style={styles.reviewerMeta} numberOfLines={1}>
{reviewer.name}
</Text>
) : null}
</View>
<Text style={styles.reviewerState}>{reviewer.stateLabel}</Text>
</View>
))
)
) : null}
{itemAssignableUsersLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading reviewers...</Text>
</View>
) : itemAssignableUsersError ? (
<Text style={styles.detailError}>{itemAssignableUsersError}</Text>
) : itemReviewerCandidates.length === 0 ? (
<Text style={styles.detailMuted}>No reviewer suggestions found.</Text>
) : (
<View style={styles.chipRow}>
{itemReviewerCandidates.map((user) => {
const selected = itemSelectedReviewerLogins.has(user.login.trim().toLowerCase())
return (
<Pressable
key={user.login}
style={[styles.detailChip, selected ? styles.detailChipSelected : undefined]}
disabled={mutatingStatus || selected}
onPress={() => void requestGitHubReviewers(actionItem, [user.login])}
>
<View style={styles.issueTypeChipContent}>
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
<Text style={styles.detailChipText}>{user.login}</Text>
</View>
</Pressable>
)
})}
</View>
)}
<TextInput
style={styles.input}
value={itemReviewersDraft}
onChangeText={setItemReviewersDraft}
placeholder="Request reviewers"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
<Pressable
style={styles.inlineSaveButton}
disabled={mutatingStatus || splitReviewerList(itemReviewersDraft).length === 0}
onPress={() => void requestGitHubReviewers(actionItem)}
>
<Text style={styles.inlineSaveText}>Request review</Text>
</Pressable>
</View>
) : null
}
export function renderMobileTasksItemFiles(model: ConnectionPresentationModel) {
const {
actionItem,
addGitHubFileReviewComment,
detailPayload,
expandedPrFilePath,
mutatingStatus,
prFileCommentDrafts,
prFileContents,
prFileLoadingPath,
setPrFileCommentDrafts,
toggleGitHubFileExpansion,
toggleGitHubFileViewed
} = model
if (!actionItem || !detailPayload) {
return null
}
return SHOW_MOBILE_DETAIL_REVIEW_PANELS &&
detailPayload.provider === 'github' &&
detailPayload.files.length > 0 ? (
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Changed files</Text>
{detailPayload.files.map((file) =>
actionItem.provider === 'github' && actionItem.source.type === 'pr' ? (
<View key={file.path} style={styles.fileCard}>
<Pressable
style={styles.fileActionRow}
disabled={mutatingStatus}
onPress={() => void toggleGitHubFileExpansion(actionItem, file)}
>
<Text style={styles.detailLine}>
{file.path}
{typeof file.additions === 'number' || typeof file.deletions === 'number'
? ` · +${file.additions ?? 0} -${file.deletions ?? 0}`
: ''}
</Text>
<Text style={styles.detailSectionMeta}>
{expandedPrFilePath === file.path ? 'Hide' : 'View'}
</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={mutatingStatus || !detailPayload.pullRequestId}
onPress={() => void toggleGitHubFileViewed(actionItem, file)}
>
<Text style={styles.inlineSaveText}>
{file.viewerViewedState === 'VIEWED' ? 'Mark unviewed' : 'Mark viewed'}
</Text>
</Pressable>
{expandedPrFilePath === file.path ? (
<View style={styles.filePreview}>
{prFileLoadingPath === file.path ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : prFileContents[file.path]?.originalIsBinary ||
prFileContents[file.path]?.modifiedIsBinary ? (
<Text style={styles.detailMuted}>Binary file.</Text>
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]}
commentDrafts={prFileCommentDrafts}
disabled={mutatingStatus}
onCommentDraftChange={(draftKey, next) =>
setPrFileCommentDrafts((current) => ({
...current,
[draftKey]: next
}))
}
onSubmitComment={(line) =>
void addGitHubFileReviewComment(actionItem, file, line)
}
/>
) : (
<Text style={styles.detailMuted}>File contents unavailable.</Text>
)}
</View>
) : null}
</View>
) : (
<Text key={file.path} style={styles.detailLine}>
{file.path}
{typeof file.additions === 'number' || typeof file.deletions === 'number'
? ` · +${file.additions ?? 0} -${file.deletions ?? 0}`
: ''}
</Text>
)
)}
</View>
) : null
}
@@ -0,0 +1,11 @@
export * from './mobile-tasks-provider-detail-types'
export * from './mobile-tasks-view-state-types'
export * from './mobile-tasks-project-workspace-types'
export * from './mobile-tasks-options'
export * from './mobile-tasks-item-mapping'
export * from './mobile-tasks-hosted-review'
export * from './mobile-tasks-reviewer-linear'
export * from './mobile-tasks-project-fields'
export * from './mobile-tasks-item-comments'
export * from './MobileTasksPrFileDiff'
export * from './mobile-tasks-repository-presentation'
@@ -0,0 +1,52 @@
import { mobileTasksChromeStyles } from './mobile-tasks-chrome-styles'
import { mobileTasksListStyles } from './mobile-tasks-list-styles'
import { mobileTasksProjectPickerStyles } from './mobile-tasks-project-picker-styles'
import { mobileTasksDetailStyles } from './mobile-tasks-detail-styles'
import { mobileTasksWorkspaceReviewStyles } from './mobile-tasks-workspace-review-styles'
import { mobileTasksDiffCommentStyles } from './mobile-tasks-diff-comment-styles'
import { mobileTasksComposerActionStyles } from './mobile-tasks-composer-action-styles'
export const styles = {
...mobileTasksChromeStyles,
...mobileTasksListStyles,
...mobileTasksProjectPickerStyles,
...mobileTasksDetailStyles,
...mobileTasksWorkspaceReviewStyles,
...mobileTasksDiffCommentStyles,
...mobileTasksComposerActionStyles
}
export function getPrSignalToneStyle(tone: 'neutral' | 'success' | 'warning' | 'danger') {
if (tone === 'success') {
return styles.prSignalSuccess
}
if (tone === 'warning') {
return styles.prSignalWarning
}
if (tone === 'danger') {
return styles.prSignalDanger
}
return null
}
export function getGitLabPipelineStatusStyle(status: string) {
switch (status) {
case 'success':
return styles.pipelineStatusSuccess
case 'failed':
return styles.pipelineStatusDanger
case 'manual':
return styles.pipelineStatusWarning
case 'running':
case 'pending':
case 'created':
case 'preparing':
case 'waiting_for_resource':
case 'scheduled':
return styles.pipelineStatusActive
case 'canceled':
case 'skipped':
default:
return null
}
}
@@ -0,0 +1,198 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
View,
ActivityIndicator,
colors,
Text,
ScrollView,
spacing,
Pressable,
triggerMediumImpact,
ChevronDown,
FlatList,
TaskProviderLogo
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
createLinearTask,
type TaskItem,
linearIssueSecondaryParts,
formatUpdatedAt
} from './mobile-tasks-legacy-foundation'
export function renderMobileTasksLinearList(model: ConnectionPresentationModel) {
const {
effectiveLinearDisplayProperties,
emptyLabel,
insets,
linearBoardSections,
linearIssuesForView,
linearListEntries,
linearViewMode,
loading,
mutatingStatus,
refreshTasks,
refreshing,
setActionItem,
setLinearStatusPickerItem
} = model
return loading ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : linearIssuesForView.length === 0 ? (
<View style={styles.centered}>
<Text style={styles.emptyText}>{emptyLabel}</Text>
</View>
) : linearViewMode === 'board' ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.boardContainer, { paddingBottom: spacing.lg + insets.bottom }]}
>
{linearBoardSections.map((section) => (
<View key={section.key} style={styles.boardColumn}>
<View style={styles.boardHeader}>
<View style={[styles.repoSectionDot, { backgroundColor: section.color }]} />
<Text style={styles.boardTitle} numberOfLines={1}>
{section.label}
</Text>
<Text style={styles.boardCount}>{section.issues.length}</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{section.issues.map((issue) => (
<Pressable
key={issue.id}
style={({ pressed }) => [styles.boardCard, pressed && styles.taskRowPressed]}
onPress={() => {
triggerMediumImpact()
setActionItem(
createLinearTask(issue) as Extract<TaskItem, { provider: 'linear' }>
)
}}
>
<Text style={styles.taskTitle} numberOfLines={3}>
{issue.title}
</Text>
<Text style={styles.subtitle} numberOfLines={2}>
{linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join(' · ')}
</Text>
{effectiveLinearDisplayProperties.has('state') ? (
<Pressable
style={[styles.statusPillSelf, styles.linearStatePill]}
disabled={mutatingStatus}
accessibilityRole="button"
accessibilityLabel={`Change status from ${issue.state.name}`}
onPress={(event) => {
event.stopPropagation()
triggerMediumImpact()
setLinearStatusPickerItem(
createLinearTask(issue) as Extract<TaskItem, { provider: 'linear' }>
)
}}
>
<View
style={[
styles.linearStateDot,
{ backgroundColor: issue.state.color || colors.textMuted }
]}
/>
<Text style={[styles.statusText, styles.statusTextFlex]} numberOfLines={1}>
{issue.state.name}
</Text>
<ChevronDown size={12} color={colors.textSecondary} />
</Pressable>
) : null}
</Pressable>
))}
</ScrollView>
</View>
))}
</ScrollView>
) : (
<FlatList
data={linearListEntries}
keyExtractor={(entry) =>
entry.type === 'section' ? `linear-section:${entry.section.key}` : entry.issue.id
}
ItemSeparatorComponent={({ leadingItem, trailingItem }) =>
leadingItem?.type === 'section' || trailingItem?.type === 'section' ? null : (
<View style={styles.separator} />
)
}
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
refreshing={refreshing}
onRefresh={refreshTasks}
renderItem={({ item: entry }) => {
if (entry.type === 'section') {
return (
<View style={styles.repoSectionHeader}>
<View style={[styles.repoSectionDot, { backgroundColor: entry.section.color }]} />
<Text style={styles.repoSectionTitle} numberOfLines={1}>
{entry.section.label}
</Text>
<Text style={styles.boardCount}>{entry.section.issues.length}</Text>
</View>
)
}
const issue = entry.issue
const linearTask = createLinearTask(issue) as Extract<TaskItem, { provider: 'linear' }>
return (
<Pressable
style={({ pressed }) => [styles.taskRow, pressed && styles.taskRowPressed]}
onPress={() => {
triggerMediumImpact()
setActionItem(linearTask)
}}
>
<View style={styles.taskIcon}>
<TaskProviderLogo provider="linear" size={15} color={colors.textSecondary} />
</View>
<View style={styles.taskMain}>
<View style={styles.taskTitleRow}>
<Text style={styles.taskTitle} numberOfLines={2}>
{issue.title}
</Text>
{effectiveLinearDisplayProperties.has('updated') ? (
<Text style={styles.updatedAt}>{formatUpdatedAt(issue.updatedAt)}</Text>
) : null}
</View>
<View style={styles.metaRow}>
<View style={[styles.repoDot, { backgroundColor: issue.state.color }]} />
<Text style={styles.subtitle} numberOfLines={1}>
{linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join(' · ')}
</Text>
</View>
</View>
<View style={styles.linearListTrailing}>
{effectiveLinearDisplayProperties.has('state') ? (
<Pressable
style={[styles.statusPill, styles.linearStatePill]}
disabled={mutatingStatus}
accessibilityRole="button"
accessibilityLabel={`Change status from ${issue.state.name}`}
onPress={(event) => {
event.stopPropagation()
triggerMediumImpact()
setLinearStatusPickerItem(linearTask)
}}
>
<View
style={[
styles.linearStateDot,
{ backgroundColor: issue.state.color || colors.textMuted }
]}
/>
<Text style={[styles.statusText, styles.statusTextFlex]} numberOfLines={1}>
{issue.state.name}
</Text>
<ChevronDown size={12} color={colors.textSecondary} />
</Pressable>
) : null}
</View>
</Pressable>
)
}}
/>
)
}
@@ -0,0 +1,118 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import { Pressable, Text } from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
export function renderMobileTasksLinearViewControls(model: ConnectionPresentationModel) {
const {
linearConnected,
linearFilterLabel,
linearGroupLabel,
linearOrderLabel,
linearTeamLabel,
linearViewLabel,
linearWorkspaceLabel,
linearWorkspaces,
provider,
setShowLinearDisplayPicker,
setShowLinearFilterPicker,
setShowLinearGroupPicker,
setShowLinearOrderPicker,
setShowLinearTeamPicker,
setShowLinearViewPicker,
setShowLinearWorkspacePicker,
taskUiReady
} = model
return (
provider === 'linear' &&
linearConnected && (
<>
{linearWorkspaces.length > 1 ? (
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowLinearWorkspacePicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{linearWorkspaceLabel}</Text>
</Pressable>
) : null}
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowLinearTeamPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{linearTeamLabel}</Text>
</Pressable>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowLinearFilterPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{linearFilterLabel}</Text>
</Pressable>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowLinearViewPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>{linearViewLabel}</Text>
</Pressable>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowLinearGroupPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>Group: {linearGroupLabel}</Text>
</Pressable>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowLinearOrderPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>Order: {linearOrderLabel}</Text>
</Pressable>
<Pressable
style={styles.segmentButton}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setShowLinearDisplayPicker(true)
}}
>
<Text style={styles.segmentSecondaryText}>Display</Text>
</Pressable>
</>
)
)
}
@@ -0,0 +1,145 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
BottomDrawer,
View,
Text,
Pressable,
Check,
colors,
PickerModal
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
LINEAR_VIEW_OPTIONS,
LINEAR_GROUP_OPTIONS,
LINEAR_ORDER_OPTIONS,
LINEAR_DISPLAY_OPTIONS,
SORT_OPTIONS
} from './mobile-tasks-legacy-foundation'
export function renderMobileTasksLinearViewPicker(model: ConnectionPresentationModel) {
const {
linearViewMode,
setLinearViewMode,
setShowLinearViewPicker,
showLinearViewPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showLinearViewPicker}
title="Linear View"
options={LINEAR_VIEW_OPTIONS}
selected={linearViewMode}
onSelect={setLinearViewMode}
onClose={() => setShowLinearViewPicker(false)}
/>
)
}
export function renderMobileTasksLinearGroupPicker(model: ConnectionPresentationModel) {
const {
linearGroupBy,
setLinearGroupBy,
setShowLinearGroupPicker,
showLinearGroupPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showLinearGroupPicker}
title="Group Linear Issues"
options={LINEAR_GROUP_OPTIONS}
selected={linearGroupBy}
onSelect={setLinearGroupBy}
onClose={() => setShowLinearGroupPicker(false)}
/>
)
}
export function renderMobileTasksLinearOrderPicker(model: ConnectionPresentationModel) {
const {
linearOrderBy,
setLinearOrderBy,
setShowLinearOrderPicker,
showLinearOrderPicker,
taskUiReady
} = model
return (
<PickerModal
visible={taskUiReady && showLinearOrderPicker}
title="Order Linear Issues"
options={LINEAR_ORDER_OPTIONS}
selected={linearOrderBy}
onSelect={setLinearOrderBy}
onClose={() => setShowLinearOrderPicker(false)}
/>
)
}
export function renderMobileTasksLinearDisplayPicker(model: ConnectionPresentationModel) {
const {
effectiveLinearDisplayProperties,
setLinearDisplayProperties,
setLinearTeamPropertyTouched,
setShowLinearDisplayPicker,
showLinearDisplayPicker,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && showLinearDisplayPicker}
onClose={() => setShowLinearDisplayPicker(false)}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Display Properties</Text>
</View>
<View style={styles.repoPickerGroup}>
{LINEAR_DISPLAY_OPTIONS.map((property, index) => {
const selected = effectiveLinearDisplayProperties.has(property.value)
return (
<View key={property.value}>
{index > 0 ? <View style={styles.actionSeparator} /> : null}
<Pressable
style={styles.repoPickerRow}
onPress={() => {
if (property.value === 'team') {
setLinearTeamPropertyTouched(true)
}
setLinearDisplayProperties((current) => {
const next = new Set(current)
if (next.has(property.value)) {
next.delete(property.value)
} else {
next.add(property.value)
}
return next
})
}}
>
<View style={styles.repoPickerTextWrap}>
<Text style={styles.repoPickerTitle}>{property.label}</Text>
</View>
{selected ? <Check size={15} color={colors.textPrimary} /> : null}
</Pressable>
</View>
)
})}
</View>
</BottomDrawer>
)
}
export function renderMobileTasksSortPicker(model: ConnectionPresentationModel) {
const { setShowSortPicker, setTaskSort, showSortPicker, taskSort, taskUiReady } = model
return (
<PickerModal
visible={taskUiReady && showSortPicker}
title="Sort Tasks"
options={SORT_OPTIONS}
selected={taskSort}
onSelect={setTaskSort}
onClose={() => setShowSortPicker(false)}
/>
)
}
@@ -0,0 +1,191 @@
import { StyleSheet, colors, radii, spacing } from './mobile-tasks-dependencies'
export const mobileTasksListStyles = StyleSheet.create({
taskRow: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2
},
taskRowPressed: {
backgroundColor: colors.bgRaised
},
taskIcon: {
width: 20,
paddingTop: 3,
marginRight: spacing.sm,
alignItems: 'center'
},
taskMain: {
flex: 1,
minWidth: 0,
marginRight: spacing.sm
},
taskTitleRow: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'flex-start'
},
taskTitle: {
flex: 1,
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
lineHeight: 18
},
updatedAt: {
fontSize: 11,
color: colors.textMuted,
paddingTop: 2
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
gap: spacing.xs
},
repoDot: {
width: 7,
height: 7,
borderRadius: 4
},
pickerRepoDot: {
width: 9,
height: 9,
borderRadius: 4.5
},
subtitle: {
flex: 1,
fontSize: 11,
color: colors.textSecondary
},
branchMetaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: 3,
minWidth: 0
},
branchMetaText: {
flexShrink: 1,
minWidth: 0,
maxWidth: 180,
fontSize: 11,
color: colors.textPrimary
},
branchMetaBase: {
flexShrink: 1,
minWidth: 0,
fontSize: 10,
color: colors.textMuted
},
prSignalRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginTop: spacing.xs + 1
},
prSignalChip: {
alignSelf: 'flex-start',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.xs + 2,
paddingVertical: 2
},
prSignalSuccess: {
borderColor: colors.statusGreen
},
prSignalWarning: {
borderColor: colors.statusAmber
},
prSignalDanger: {
borderColor: colors.statusRed
},
prSignalText: {
fontSize: 10,
color: colors.textSecondary,
fontWeight: '600'
},
statusPill: {
maxWidth: 112,
backgroundColor: colors.bgRaised,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderWidth: 1,
borderColor: colors.borderSubtle
},
statusPillSelf: {
alignSelf: 'flex-start',
backgroundColor: colors.bgRaised,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginTop: spacing.sm
},
linearStatePill: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
linearStateDot: {
width: 7,
height: 7,
borderRadius: 4
},
linearListTrailing: {
alignItems: 'flex-end',
gap: spacing.xs
},
taskRowTrailing: {
alignItems: 'flex-end',
gap: spacing.xs
},
statusText: {
fontSize: 11,
color: colors.textSecondary
},
statusTextFlex: {
flex: 1,
minWidth: 0
},
paginationFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.sm
},
paginationButton: {
width: 44,
minHeight: 38,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingVertical: spacing.sm
},
paginationButtonDisabled: {
opacity: 0.45
},
paginationLabel: {
color: colors.textSecondary,
fontSize: 12,
textAlign: 'center'
},
paginationLabelButton: {
flex: 1,
alignItems: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.xs,
paddingVertical: spacing.sm
}
})
@@ -0,0 +1,76 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
View,
Text,
ActivityIndicator,
colors,
TaskProviderLogo,
Pressable
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import { renderMobileTasksGitHubProjectList } from './mobile-tasks-github-project-list'
import { renderMobileTasksLinearList } from './mobile-tasks-linear-list'
import { renderMobileTasksProviderItemList } from './mobile-tasks-provider-item-list'
export function renderMobileTasksListSurface(model: ConnectionPresentationModel) {
const {
githubMode,
linearConnected,
provider,
setLinearApiKeyDraft,
setLinearConnectError,
setLinearConnectState,
setShowLinearConnect,
taskUiReady,
tasksSupported,
tasksUnsupported
} = model
return !tasksSupported ? (
tasksUnsupported ? (
<View style={styles.centered}>
<Text style={styles.emptyText}>Update Orca desktop</Text>
<Text style={styles.centeredHint}>
This mobile Tasks view needs a newer desktop runtime.
</Text>
</View>
) : (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
) : provider === 'linear' && !linearConnected ? (
<View style={styles.centered}>
<TaskProviderLogo provider="linear" size={32} color={colors.textSecondary} />
<Text style={styles.emptyText}>Connect your Linear account</Text>
<Text style={styles.centeredHint}>
Browse and start work on your assigned Linear issues directly from Tasks.
</Text>
<Pressable
style={[styles.targetButton, styles.centerActionButton]}
disabled={!taskUiReady}
onPress={() => {
if (!taskUiReady) {
return
}
setLinearApiKeyDraft('')
setLinearConnectState('idle')
setLinearConnectError('')
setShowLinearConnect(true)
}}
>
<Text style={styles.targetButtonText}>Connect Linear</Text>
</Pressable>
</View>
) : provider === 'github' && githubMode === 'project' ? (
renderMobileTasksGitHubProjectList(model)
) : (
renderMobileTasksNonProjectLists(model)
)
}
export function renderMobileTasksNonProjectLists(model: ConnectionPresentationModel) {
const { provider } = model
return provider === 'linear'
? renderMobileTasksLinearList(model)
: renderMobileTasksProviderItemList(model)
}
+225
View File
@@ -0,0 +1,225 @@
import {
type PickerOption,
type TaskProvider,
TaskProviderLogo,
colors,
getLinkedWorkItemSuggestedName,
type GitHubProjectSettings
} from './mobile-tasks-dependencies'
import type { GitHubProjectSortDirection } from '../../../src/shared/github/project-types'
import type { ProjectGroup } from '../../../src/shared/github/project-group-sort'
import type {
GitHubMode,
GitHubPreset,
GitHubProjectRow,
GitLabFilter,
GitLabView,
LinearDisplayProperty,
LinearFilter,
LinearGroupBy,
LinearOrderBy,
LinearViewMode,
TaskSort
} from './mobile-tasks-view-state-types'
import type { ActionableTaskItem } from './mobile-tasks-project-workspace-types'
import type { DetailComment, LinearIssue } from './mobile-tasks-provider-detail-types'
export const PROVIDER_OPTIONS: PickerOption<TaskProvider>[] = [
{
value: 'github',
label: 'GitHub',
subtitle: 'Issues and pull requests',
renderIcon: (selected) => (
<TaskProviderLogo
provider="github"
size={16}
color={selected ? colors.textPrimary : colors.textSecondary}
/>
)
},
{
value: 'gitlab',
label: 'GitLab',
subtitle: 'Issues and merge requests',
renderIcon: (selected) => (
<TaskProviderLogo
provider="gitlab"
size={16}
color={selected ? colors.textPrimary : colors.textSecondary}
/>
)
},
{
value: 'linear',
label: 'Linear',
subtitle: 'Assigned and team issues',
renderIcon: (selected) => (
<TaskProviderLogo
provider="linear"
size={16}
color={selected ? colors.textPrimary : colors.textSecondary}
/>
)
}
]
export const GITLAB_FILTER_OPTIONS: PickerOption<GitLabFilter>[] = [
{ value: 'opened', label: 'Open', subtitle: 'Open issues and merge requests' },
{ value: 'merged', label: 'Merged', subtitle: 'Merged merge requests' },
{ value: 'closed', label: 'Closed', subtitle: 'Closed issues and merge requests' },
{ value: 'all', label: 'All', subtitle: 'Any GitLab state' }
]
export const LINEAR_FILTER_OPTIONS: PickerOption<LinearFilter>[] = [
{ value: 'all', label: 'All', subtitle: 'Open issues across connected workspaces' },
{ value: 'assigned', label: 'My Issues', subtitle: 'Issues assigned to you' },
{ value: 'created', label: 'Created', subtitle: 'Issues created by you' },
{ value: 'completed', label: 'Completed', subtitle: 'Recently completed issues' }
]
export const LINEAR_VIEW_OPTIONS: PickerOption<LinearViewMode>[] = [
{ value: 'list', label: 'List', subtitle: 'Compact issue rows' },
{ value: 'board', label: 'Board', subtitle: 'Grouped columns' }
]
export function taskWorkspaceFallback(item: ActionableTaskItem): string {
if (item.provider === 'github' || item.provider === 'gitlab') {
return `${item.source.type}-${item.source.number}`
}
return item.source.identifier.toLowerCase()
}
export function taskWorkspaceSuggestedName(item: ActionableTaskItem): string {
return getLinkedWorkItemSuggestedName(item) || taskWorkspaceFallback(item)
}
export const COMMENT_REACTION_EMOJI: Record<
NonNullable<DetailComment['reactions']>[number]['content'],
string
> = {
thumbs_up: '+1',
thumbs_down: '-1',
laugh: 'laugh',
confused: 'confused',
heart: 'heart',
hooray: 'hooray',
rocket: 'rocket',
eyes: 'eyes'
}
export const LINEAR_GROUP_OPTIONS: PickerOption<LinearGroupBy>[] = [
{ value: 'none', label: 'No grouping' },
{ value: 'status', label: 'Status' },
{ value: 'assignee', label: 'Assignee' },
{ value: 'priority', label: 'Priority' },
{ value: 'team', label: 'Team' }
]
export const LINEAR_ORDER_OPTIONS: PickerOption<LinearOrderBy>[] = [
{ value: 'priority', label: 'Priority' },
{ value: 'updated', label: 'Updated' },
{ value: 'identifier', label: 'Identifier' }
]
export const LINEAR_DISPLAY_OPTIONS: PickerOption<LinearDisplayProperty>[] = [
{ value: 'state', label: 'Status' },
{ value: 'priority', label: 'Priority' },
{ value: 'assignee', label: 'Assignee' },
{ value: 'team', label: 'Team' },
{ value: 'labels', label: 'Labels' },
{ value: 'updated', label: 'Updated' }
]
export const DEFAULT_LINEAR_DISPLAY_PROPERTIES: LinearDisplayProperty[] = [
'state',
'priority',
'assignee',
'team',
'labels',
'updated'
]
export const GITHUB_KIND_OPTIONS: PickerOption<GitHubMode>[] = [
{ value: 'issues', label: 'Issues', subtitle: 'GitHub issues' },
{ value: 'prs', label: 'PRs', subtitle: 'GitHub pull requests' },
{ value: 'project', label: 'Projects', subtitle: 'GitHub Projects views' }
]
export const ISSUE_PRESETS: PickerOption<GitHubPreset>[] = [
{ value: 'issues', label: 'Open', subtitle: 'Open GitHub issues' },
{ value: 'my-issues', label: 'Assigned to me', subtitle: 'Open issues assigned to you' }
]
export const PR_PRESETS: PickerOption<GitHubPreset>[] = [
{ value: 'prs', label: 'Open', subtitle: 'Open pull requests' },
{ value: 'my-prs', label: 'Mine', subtitle: 'Pull requests authored by you' },
{ value: 'review', label: 'Needs review', subtitle: 'Review requests assigned to you' }
]
export const GITLAB_VIEW_OPTIONS: PickerOption<GitLabView>[] = [
{ value: 'project', label: 'Project MRs', subtitle: 'Merge requests and issues by repository' },
{ value: 'todos', label: 'My Todos', subtitle: 'Pending GitLab todos' }
]
export const SORT_OPTIONS: PickerOption<TaskSort>[] = [
{ value: 'updated', label: 'Updated', subtitle: 'Newest activity first' },
{
value: 'repository',
label: 'Repository',
subtitle: 'Group by repository, then newest activity'
}
]
export type ProjectSortOverride = { fieldId: string; direction: GitHubProjectSortDirection }
export type ProjectListEntry =
| { type: 'group'; group: ProjectGroup; collapsed: boolean }
| { type: 'row'; row: GitHubProjectRow }
export type LinearIssueSection = {
key: string
label: string
color: string
issues: LinearIssue[]
}
export type LinearListEntry =
| { type: 'section'; section: LinearIssueSection }
| { type: 'issue'; issue: LinearIssue }
export const PROJECT_VIEW_DEFAULT_SORT = '__view_default__'
export const GITHUB_REPO_CONCURRENCY = 3
export const MAX_RENDERED_PR_DIFF_LINES = 400
export const GITLAB_PER_PAGE = 50
export const LINEAR_LIMIT = 50
// Why: task detail drawers can launch child sheets; children must layer above
// the still-mounted parent while its dismissal animation/state remains alive.
export const TASK_SECONDARY_DRAWER_Z_INDEX = 1100
// Why: the mobile detail drawer should support quick triage and core actions.
// Desktop keeps the broad metadata editing surface for dense issue/PR work.
export const SHOW_MOBILE_DETAIL_LABEL_CHIPS = false
export const SHOW_MOBILE_DETAIL_METADATA_EDITORS = false
export const SHOW_MOBILE_DETAIL_REVIEW_PANELS = false
export const SHOW_MOBILE_LINEAR_DETAIL_TOOLS = false
export const SHOW_MOBILE_COMMENT_THREAD_TOOLS = false
export const SHOW_MOBILE_PROJECT_METADATA_EDITORS = false
export const SHOW_MOBILE_PROJECT_REVIEW_PANELS = false
export const EMPTY_GITHUB_PROJECT_SETTINGS: GitHubProjectSettings = {
pinned: [],
recent: [],
lastViewByProject: {},
activeProject: null
}
@@ -0,0 +1,233 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
View,
Text,
Pressable,
type ReactNode,
TextInput,
colors,
MobileMarkdown
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
discussionSummary,
detailCommentGroupId,
detailCommentGroupRoot,
detailCommentGroupCount,
isResolvedDetailCommentGroup,
commentAuthor,
type DetailComment,
SHOW_MOBILE_COMMENT_THREAD_TOOLS,
commentSourceLabel,
commentDate,
renderCommentReactions,
projectRowType
} from './mobile-tasks-legacy-foundation'
export function renderMobileTasksProjectComments(model: ConnectionPresentationModel) {
const {
addProjectRowComment,
deleteProjectRowComment,
expandedResolvedCommentGroups,
itemReplyDrafts,
projectCommentDraft,
projectDetailCommentGroups,
projectEditingCommentDraft,
projectEditingCommentId,
projectMutating,
projectRowDetail,
projectRowItem,
renderCommentComposer,
replyToProjectGitHubComment,
setExpandedResolvedCommentGroups,
setItemReplyDrafts,
setProjectCommentDraft,
setProjectEditingCommentDraft,
setProjectEditingCommentId,
toggleProjectGitHubReviewThread,
updateProjectRowComment
} = model
if (!projectRowItem) {
return null
}
return projectRowDetail?.provider === 'github' ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Discussion</Text>
<Text style={styles.detailSectionMeta}>
{discussionSummary(projectRowDetail.comments.length)}
</Text>
</View>
{projectRowDetail.comments.length === 0 ? (
<Text style={styles.detailMuted}>No comments.</Text>
) : (
projectDetailCommentGroups.map((group) => {
const groupId = detailCommentGroupId(group)
const root = detailCommentGroupRoot(group)
const count = detailCommentGroupCount(group)
const isCollapsedResolved =
isResolvedDetailCommentGroup(group) && !expandedResolvedCommentGroups.has(groupId)
if (isCollapsedResolved) {
return (
<Pressable
key={groupId}
style={styles.resolvedCommentSummary}
onPress={() =>
setExpandedResolvedCommentGroups((current) => {
const next = new Set(current)
next.add(groupId)
return next
})
}
>
<Text style={styles.resolvedCommentTitle} numberOfLines={1}>
Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by{' ' as string}
{commentAuthor(root)}
</Text>
<Text style={styles.detailSectionMeta}>
{count > 1 ? `${count} comments` : 'Show'}
</Text>
</Pressable>
)
}
const renderProjectComment = (
comment: DetailComment,
options: { nested?: boolean } = {}
): ReactNode => {
const commentId = String(comment.id)
const isEditingComment =
SHOW_MOBILE_COMMENT_THREAD_TOOLS && projectEditingCommentId === commentId
return (
<View
key={commentId}
style={[
styles.commentBlock,
options.nested && styles.commentReplyBlock,
comment.isResolved && styles.commentResolvedBlock
]}
>
<Text style={styles.commentSource} numberOfLines={1}>
{commentSourceLabel(comment)}
</Text>
<Text style={styles.commentMeta}>
{commentAuthor(comment)}
{commentDate(comment.createdAt) ? ` · ${commentDate(comment.createdAt)}` : ''}
</Text>
{isEditingComment ? (
<>
<TextInput
style={[styles.input, styles.commentInput]}
value={projectEditingCommentDraft}
onChangeText={setProjectEditingCommentDraft}
placeholder="Edit comment"
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<View style={styles.inlineActionRow}>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating || projectEditingCommentDraft.trim().length === 0}
onPress={() => void updateProjectRowComment(projectRowItem, comment)}
>
<Text style={styles.inlineSaveText}>Save</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating}
onPress={() => {
setProjectEditingCommentId(null)
setProjectEditingCommentDraft('')
}}
>
<Text style={styles.inlineSaveText}>Cancel</Text>
</Pressable>
</View>
</>
) : (
<>
<MobileMarkdown content={comment.body} />
{renderCommentReactions(comment)}
{SHOW_MOBILE_COMMENT_THREAD_TOOLS ? (
<View style={styles.inlineActionRow}>
{projectRowType(projectRowItem) === 'pr' && comment.threadId ? (
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating}
onPress={() =>
void toggleProjectGitHubReviewThread(projectRowItem, comment)
}
>
<Text style={styles.inlineSaveText}>
{comment.isResolved ? 'Reopen thread' : 'Resolve thread'}
</Text>
</Pressable>
) : null}
<TextInput
style={[styles.input, styles.replyInput]}
value={itemReplyDrafts[commentId] ?? ''}
onChangeText={(next) =>
setItemReplyDrafts((current) => ({
...current,
[commentId]: next
}))
}
placeholder="Reply"
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating || !(itemReplyDrafts[commentId] ?? '').trim()}
onPress={() => void replyToProjectGitHubComment(projectRowItem, comment)}
>
<Text style={styles.inlineSaveText}>Reply</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating}
onPress={() => {
setProjectEditingCommentId(commentId)
setProjectEditingCommentDraft(comment.body)
}}
>
<Text style={styles.inlineSaveText}>Edit</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating}
onPress={() => void deleteProjectRowComment(projectRowItem, comment)}
>
<Text style={styles.inlineDeleteText}>Delete</Text>
</Pressable>
</View>
) : null}
</>
)}
</View>
)
}
return (
<View key={groupId} style={styles.commentThreadGroup}>
{group.kind === 'thread'
? [
renderProjectComment(group.root),
...group.replies.map((reply) => renderProjectComment(reply, { nested: true }))
]
: renderProjectComment(group.comment)}
</View>
)
})
)}
{renderCommentComposer({
value: projectCommentDraft,
onChangeText: setProjectCommentDraft,
disabled: projectMutating,
onSubmit: () => void addProjectRowComment(projectRowItem)
})}
</View>
) : null
}
@@ -0,0 +1,23 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import { View, ActivityIndicator, colors, Text } from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import { renderMobileTasksProjectIssueMetadata } from './mobile-tasks-project-metadata-editors'
import { renderMobileTasksProjectReviewPanels } from './mobile-tasks-project-review-panels'
import { renderMobileTasksProjectComments } from './mobile-tasks-project-comments'
export function renderMobileTasksProjectLoadedDetail(model: ConnectionPresentationModel) {
const { projectRowDetailError, projectRowDetailLoading } = model
return projectRowDetailLoading ? (
<View style={styles.detailLoading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : projectRowDetailError ? (
<Text style={styles.detailError}>{projectRowDetailError}</Text>
) : (
<>
{renderMobileTasksProjectIssueMetadata(model)}
{renderMobileTasksProjectReviewPanels(model)}
{renderMobileTasksProjectComments(model)}
</>
)
}
@@ -0,0 +1,353 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
BottomDrawer,
View,
Text,
Pressable,
Linking,
ExternalLink,
colors,
Copy,
TaskProviderLogo,
ActivityIndicator,
Plus,
RefreshCw,
X,
GitBranch
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
import {
projectRowStatusLabel,
SHOW_MOBILE_DETAIL_LABEL_CHIPS,
SHOW_MOBILE_PROJECT_METADATA_EDITORS,
githubProjectOptionColor,
canCreateWorkspaceFromProjectRow,
projectRowType
} from './mobile-tasks-legacy-foundation'
import { renderMobileTasksProjectFieldEditors } from './mobile-tasks-project-field-editors'
import {
renderMobileTasksProjectLabelsEditor,
renderMobileTasksProjectAssigneesEditor
} from './mobile-tasks-project-metadata-editors'
import { renderMobileTasksProjectLoadedDetail } from './mobile-tasks-project-detail-content'
export function renderMobileTasksProjectMissingRepoDrawer(model: ConnectionPresentationModel) {
const {
copiedLinkKey,
copyTextToClipboard,
projectRepoNotInOrca,
setProjectRepoNotInOrca,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && projectRepoNotInOrca != null}
onClose={() => {
setProjectRepoNotInOrca(null)
}}
>
{projectRepoNotInOrca ? (
<View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Repository not in Orca</Text>
<Text style={styles.sheetSubtitle}>
{projectRepoNotInOrca.owner}/{projectRepoNotInOrca.repo} is not added to Orca. Add
this repository from the desktop app, then refresh mobile Tasks.
</Text>
</View>
<View style={styles.actionGroup}>
{projectRepoNotInOrca.url ? (
<Pressable
style={styles.actionRow}
onPress={() => {
if (projectRepoNotInOrca.url) {
void Linking.openURL(projectRepoNotInOrca.url)
}
}}
>
<ExternalLink size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>Open in GitHub</Text>
</Pressable>
) : null}
{projectRepoNotInOrca.url ? <View style={styles.actionSeparator} /> : null}
<Pressable
style={styles.actionRow}
onPress={() =>
void copyTextToClipboard(
`project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`,
`${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`
)
}
>
<Copy size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>
{copiedLinkKey ===
`project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`
? 'Copied'
: 'Copy repository'}
</Text>
</Pressable>
</View>
</View>
) : null}
</BottomDrawer>
)
}
export function renderMobileTasksProjectDetailDrawer(model: ConnectionPresentationModel) {
const {
activeProjectLabel,
copiedLinkKey,
copyTaskLink,
createWorkspaceFromProjectRow,
creatingKey,
mutateProjectRowIssueType,
projectIssueTypes,
projectIssueTypesError,
projectIssueTypesLoading,
projectMutating,
projectRowDetail,
projectRowHostedRepo,
projectRowItem,
setMergeMethodProjectRow,
setPendingHostedStateChange,
setProjectRowItem,
taskUiReady
} = model
return (
<BottomDrawer
visible={taskUiReady && projectRowItem != null}
onClose={() => setProjectRowItem(null)}
>
{projectRowItem ? (
<View>
<View style={styles.sheetHeader}>
<View style={styles.sheetTitleRow}>
<TaskProviderLogo provider="github" size={16} color={colors.textPrimary} />
<Text style={styles.sheetTitle} numberOfLines={2}>
{projectRowItem.content.title}
</Text>
</View>
<Text style={styles.sheetSubtitle}>
GitHub Project · {projectRowItem.content.repository ?? activeProjectLabel}
{projectRowItem.content.number ? ` #${projectRowItem.content.number}` : ''}
</Text>
</View>
<View style={styles.detailGroup}>
<View style={styles.detailMetaGrid}>
<View style={styles.detailMetaItem}>
<Text style={styles.detailMetaLabel}>Type</Text>
<Text style={styles.detailMetaValue}>
{projectRowItem.itemType === 'PULL_REQUEST'
? 'Pull request'
: projectRowItem.itemType === 'ISSUE'
? 'Issue'
: projectRowItem.itemType === 'DRAFT_ISSUE'
? 'Draft issue'
: 'Project item'}
</Text>
</View>
<View style={styles.detailMetaItem}>
<Text style={styles.detailMetaLabel}>Status</Text>
<Text style={styles.detailMetaValue}>{projectRowStatusLabel(projectRowItem)}</Text>
</View>
</View>
{SHOW_MOBILE_DETAIL_LABEL_CHIPS &&
(projectRowDetail?.provider === 'github'
? projectRowDetail.labels
: projectRowItem.content.labels.map((label) => label.name)
).length > 0 ? (
<View style={styles.chipRow}>
{(projectRowDetail?.provider === 'github'
? projectRowDetail.labels
: projectRowItem.content.labels.map((label) => label.name)
)
.slice(0, 6)
.map((label) => (
<View key={label} style={styles.detailChip}>
<Text style={styles.detailChipText}>{label}</Text>
</View>
))}
</View>
) : null}
{SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowItem.itemType === 'ISSUE' ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Issue type</Text>
<Text style={styles.detailSectionMeta}>
{projectRowItem.content.issueType?.name ?? 'No type'}
</Text>
</View>
{projectIssueTypesLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading issue types...</Text>
</View>
) : projectIssueTypesError ? (
<Text style={styles.detailError}>{projectIssueTypesError}</Text>
) : projectIssueTypes.length === 0 ? (
<Text style={styles.detailMuted}>
No issue types configured for this repository.
</Text>
) : (
<View style={styles.chipRow}>
{projectIssueTypes.map((issueType) => {
const selected = projectRowItem.content.issueType?.id === issueType.id
return (
<Pressable
key={issueType.id}
style={[
styles.detailChip,
selected ? styles.detailChipSelected : undefined
]}
disabled={projectMutating || selected}
onPress={() => void mutateProjectRowIssueType(projectRowItem, issueType)}
>
<View style={styles.issueTypeChipContent}>
<View
style={[
styles.issueTypeDot,
{ backgroundColor: githubProjectOptionColor(issueType.color) }
]}
/>
<Text style={styles.detailChipText}>{issueType.name}</Text>
</View>
</Pressable>
)
})}
{projectRowItem.content.issueType ? (
<Pressable
style={styles.detailChip}
disabled={projectMutating}
onPress={() => void mutateProjectRowIssueType(projectRowItem, null)}
>
<Text style={styles.detailChipText}>Clear type</Text>
</Pressable>
) : null}
</View>
)}
</View>
) : null}
{renderMobileTasksProjectFieldEditors(model)}
{renderMobileTasksProjectLabelsEditor(model)}
{renderMobileTasksProjectAssigneesEditor(model)}
{renderMobileTasksProjectLoadedDetail(model)}
</View>
<View style={styles.actionGroup}>
{canCreateWorkspaceFromProjectRow(projectRowItem) ? (
<Pressable
style={styles.actionRow}
disabled={creatingKey === `github-project:${projectRowItem.id}`}
onPress={() => void createWorkspaceFromProjectRow(projectRowItem)}
>
<Plus size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>Create Workspace</Text>
</Pressable>
) : (
<Text style={styles.emptyInlineText}>
Workspaces can only be created from GitHub issues and pull requests.
</Text>
)}
{projectRowItem.content.url ? (
<>
{canCreateWorkspaceFromProjectRow(projectRowItem) ? (
<View style={styles.actionSeparator} />
) : null}
<Pressable
style={styles.actionRow}
onPress={() => {
if (projectRowItem.content.url) {
void Linking.openURL(projectRowItem.content.url)
}
}}
>
<ExternalLink size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>Open in GitHub</Text>
</Pressable>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
onPress={() =>
projectRowItem.content.url
? void copyTaskLink(
`github-project:${projectRowItem.id}`,
projectRowItem.content.url
)
: undefined
}
>
<Copy size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>
{copiedLinkKey === `github-project:${projectRowItem.id}`
? 'Copied'
: 'Copy GitHub link'}
</Text>
</Pressable>
</>
) : null}
{projectRowType(projectRowItem) &&
projectRowItem.content.state !== 'MERGED' &&
projectRowItem.itemType !== 'DRAFT_ISSUE' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
disabled={projectMutating}
onPress={() => {
const nextState = projectRowItem.content.state === 'CLOSED' ? 'open' : 'closed'
if (projectRowItem.itemType === 'PULL_REQUEST') {
setPendingHostedStateChange({
source: 'project',
row: projectRowItem,
nextState
})
return
}
setPendingHostedStateChange({
source: 'project',
row: projectRowItem,
nextState
})
}}
>
{projectRowItem.content.state === 'CLOSED' ? (
<RefreshCw size={16} color={colors.textPrimary} />
) : (
<X size={16} color={colors.textPrimary} />
)}
<Text style={styles.actionText}>
{projectRowItem.content.state === 'CLOSED' ? 'Reopen item' : 'Close item'}
</Text>
</Pressable>
</>
) : null}
{projectRowItem.itemType === 'PULL_REQUEST' &&
projectRowItem.content.state !== 'CLOSED' &&
projectRowItem.content.state !== 'MERGED' ? (
<>
<View style={styles.actionSeparator} />
<Pressable
style={styles.actionRow}
disabled={projectMutating || !projectRowHostedRepo}
onPress={() => setMergeMethodProjectRow(projectRowItem)}
>
<GitBranch size={16} color={colors.textPrimary} />
<Text style={styles.actionText}>Merge pull request</Text>
</Pressable>
{!projectRowHostedRepo ? (
<Text style={styles.emptyInlineText}>
Merge requires this repository in Orca.
</Text>
) : null}
</>
) : null}
</View>
</View>
) : null}
</BottomDrawer>
)
}
@@ -0,0 +1,170 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
SHOW_MOBILE_PROJECT_METADATA_EDITORS,
editableProjectFields,
projectFieldValueLabel
} from './mobile-tasks-legacy-foundation'
import { View, Text, Pressable, Check, colors, TextInput } from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
export function renderMobileTasksProjectFieldEditors(model: ConnectionPresentationModel) {
const {
githubProjectTable,
mutateProjectRowField,
projectFieldDrafts,
projectMutating,
projectRowItem,
setProjectFieldDrafts,
setProjectRowDetailError
} = model
if (!projectRowItem) {
return null
}
return SHOW_MOBILE_PROJECT_METADATA_EDITORS &&
editableProjectFields(githubProjectTable).length > 0 ? (
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Project fields</Text>
{editableProjectFields(githubProjectTable).map((field) => {
const currentLabel = projectFieldValueLabel(projectRowItem, field)
const draftValue = projectFieldDrafts[field.id] ?? ''
const saveTextField = (): void => {
if (field.dataType === 'NUMBER') {
const number = Number(draftValue)
if (!Number.isFinite(number)) {
setProjectRowDetailError('Enter a valid number.')
return
}
void mutateProjectRowField(projectRowItem, field, {
kind: 'number',
number
})
return
}
if (field.dataType === 'DATE') {
const date = draftValue.trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
setProjectRowDetailError('Enter a date as YYYY-MM-DD.')
return
}
void mutateProjectRowField(projectRowItem, field, { kind: 'date', date })
return
}
void mutateProjectRowField(projectRowItem, field, {
kind: 'text',
text: draftValue
})
}
return (
<View key={field.id} style={styles.projectFieldCard}>
<View style={styles.detailSectionHeader}>
<Text style={styles.projectFieldName}>{field.name}</Text>
<Text style={styles.projectFieldValue} numberOfLines={1}>
{currentLabel}
</Text>
</View>
{field.dataType === 'SINGLE_SELECT' && field.kind === 'single-select' ? (
<View style={styles.chipRow}>
{field.options.map((option) => {
const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id]
const selected =
fieldValue?.kind === 'single-select' && fieldValue.optionId === option.id
return (
<Pressable
key={option.id}
style={[styles.detailChip, selected ? styles.detailChipSelected : undefined]}
disabled={projectMutating}
accessibilityState={{ selected }}
onPress={() =>
void mutateProjectRowField(projectRowItem, field, {
kind: 'single-select',
optionId: option.id
})
}
>
<View style={styles.issueTypeChipContent}>
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
<Text style={styles.detailChipText}>{option.name}</Text>
</View>
</Pressable>
)
})}
</View>
) : field.dataType === 'ITERATION' && field.kind === 'iteration' ? (
<View style={styles.projectIterationList}>
{field.iterations.length === 0 ? (
<Text style={styles.detailMuted}>No iterations available.</Text>
) : (
field.iterations.map((iteration) => {
const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id]
const selected =
fieldValue?.kind === 'iteration' && fieldValue.iterationId === iteration.id
return (
<Pressable
key={iteration.id}
style={styles.actionRow}
disabled={projectMutating}
accessibilityState={{ selected }}
onPress={() =>
void mutateProjectRowField(projectRowItem, field, {
kind: 'iteration',
iterationId: iteration.id
})
}
>
<View style={styles.projectIterationCopy}>
<Text style={styles.actionText}>{iteration.title}</Text>
<Text style={styles.detailMuted}>
{iteration.completed ? 'Completed' : 'Current & upcoming'} ·{' '}
{iteration.startDate} · {iteration.duration}d
</Text>
</View>
{selected ? <Check size={14} color={colors.textSecondary} /> : null}
</Pressable>
)
})
)}
</View>
) : (
<>
<TextInput
style={styles.input}
value={draftValue}
onChangeText={(next) =>
setProjectFieldDrafts((current) => ({
...current,
[field.id]: next
}))
}
placeholder={
field.dataType === 'DATE'
? 'YYYY-MM-DD'
: field.dataType === 'NUMBER'
? 'Number'
: 'Text'
}
placeholderTextColor={colors.textMuted}
keyboardType={field.dataType === 'NUMBER' ? 'numeric' : 'default'}
autoCapitalize="none"
/>
<Pressable
style={styles.inlineSaveButton}
disabled={projectMutating}
onPress={saveTextField}
>
<Text style={styles.inlineSaveText}>Save field</Text>
</Pressable>
</>
)}
<Pressable
style={styles.inlineSaveButton}
disabled={projectMutating || currentLabel === 'Empty'}
onPress={() => void mutateProjectRowField(projectRowItem, field, null)}
>
<Text style={styles.inlineSaveText}>Clear field</Text>
</Pressable>
</View>
)
})}
</View>
) : null
}
@@ -0,0 +1,190 @@
import type { GitHubProjectTable as SharedGitHubProjectTable } from '../../../src/shared/github/project-types'
import { isIterationCurrent } from '../../../src/shared/github/project-group-sort'
import type { ProjectGroup } from '../../../src/shared/github/project-group-sort'
import type { ProjectSortOverride } from './mobile-tasks-options'
import type {
GitHubProjectField,
GitHubProjectFieldMutationValue,
GitHubProjectFieldValue,
GitHubProjectRow,
GitHubProjectTable
} from './mobile-tasks-view-state-types'
export function editableProjectFields(table: GitHubProjectTable | null): GitHubProjectField[] {
return (
table?.selectedView.fields?.filter((field) =>
['TEXT', 'NUMBER', 'DATE', 'SINGLE_SELECT', 'ITERATION'].includes(field.dataType)
) ?? []
)
}
export function projectFieldValueLabel(row: GitHubProjectRow, field: GitHubProjectField): string {
const value = row.fieldValuesByFieldId?.[field.id]
if (!value) {
return 'Empty'
}
if (value.kind === 'single-select') {
return value.name
}
if (value.kind === 'iteration') {
return value.title
}
if (value.kind === 'text') {
return value.text || 'Empty'
}
if (value.kind === 'number') {
return String(value.number)
}
if (value.kind === 'date') {
return value.date
}
if (value.kind === 'labels') {
return value.labels.map((label) => label.name).join(', ') || 'Empty'
}
if (value.kind === 'users') {
return value.users.map((user) => user.login).join(', ') || 'Empty'
}
return 'Empty'
}
export function projectFieldDisplayLabel(row: GitHubProjectRow, field: GitHubProjectField): string {
if (field.dataType === 'ASSIGNEES') {
return row.content.assignees.map((user) => user.login).join(', ') || 'Empty'
}
if (field.dataType === 'LABELS') {
return row.content.labels.map((label) => label.name).join(', ') || 'Empty'
}
if (field.dataType === 'REPOSITORY') {
return row.content.repository ?? 'Empty'
}
if (field.dataType === 'PARENT_ISSUE') {
return row.content.parentIssue ? `#${row.content.parentIssue.number}` : 'Empty'
}
if (field.dataType === 'ISSUE_TYPE') {
return row.content.issueType?.name ?? 'Empty'
}
if (field.dataType === 'TITLE') {
return row.content.title
}
return projectFieldValueLabel(row, field)
}
export function projectSummaryFields(table: GitHubProjectTable | null): GitHubProjectField[] {
return (
table?.selectedView.fields?.filter(
(field) => field.dataType !== 'TITLE' && field.dataType !== 'REPOSITORY'
) ?? []
)
}
export function projectFieldVisibilityKey(table: GitHubProjectTable | null): string | null {
if (!table) {
return null
}
// Why: desktop scopes column visibility to project + view; matching that
// avoids hiding fields across unrelated Project views with colliding IDs.
return `${table.project.id}:${table.selectedView.id}`
}
export function projectFieldDraftValue(row: GitHubProjectRow, field: GitHubProjectField): string {
const value = row.fieldValuesByFieldId?.[field.id]
if (!value) {
return ''
}
if (value.kind === 'text') {
return value.text
}
if (value.kind === 'number') {
return String(value.number)
}
if (value.kind === 'date') {
return value.date
}
return ''
}
export function normalizeProjectTableForMobileSort(
table: GitHubProjectTable,
rows: GitHubProjectRow[],
sortOverride: ProjectSortOverride | null
): SharedGitHubProjectTable {
const fields = table.selectedView.fields ?? []
const overrideField = sortOverride
? fields.find((field) => field.id === sortOverride.fieldId)
: undefined
const normalizedRows = rows.map((row, index) => ({
...row,
content: {
...row.content,
stateReason: row.content.stateReason ?? null,
parentIssue: row.content.parentIssue ?? null,
issueType: row.content.issueType ?? null
},
fieldValuesByFieldId: row.fieldValuesByFieldId ?? {},
position: row.position ?? index
}))
return {
...table,
selectedView: {
...table.selectedView,
fields,
groupByFields: table.selectedView.groupByFields ?? [],
sortByFields:
sortOverride && overrideField
? [{ field: overrideField, direction: sortOverride.direction }]
: (table.selectedView.sortByFields ?? [])
},
rows: normalizedRows,
parentFieldDropped: table.parentFieldDropped === true
} as unknown as SharedGitHubProjectTable
}
export function projectGroupMeta(group: ProjectGroup): string {
const parts = [`${group.rows.length}`]
if (group.iteration) {
const endDate = new Date(`${group.iteration.startDate}T00:00:00Z`)
if (!Number.isNaN(endDate.getTime())) {
endDate.setUTCDate(endDate.getUTCDate() + group.iteration.duration - 1)
parts.push(`${group.iteration.startDate} - ${endDate.toISOString().slice(0, 10)}`)
}
if (isIterationCurrent(group.iteration)) {
parts.push('Current')
}
}
return parts.join(' · ')
}
export function optimisticProjectFieldValue(
field: GitHubProjectField,
value: GitHubProjectFieldMutationValue
): GitHubProjectFieldValue {
if (value.kind === 'single-select' && field.kind === 'single-select') {
const option = field.options.find((entry) => entry.id === value.optionId)
return {
kind: 'single-select',
fieldId: field.id,
optionId: value.optionId,
name: option?.name ?? 'Selected',
color: option?.color ?? 'GRAY'
}
}
if (value.kind === 'iteration' && field.kind === 'iteration') {
const iteration = field.iterations.find((entry) => entry.id === value.iterationId)
return {
kind: 'iteration',
fieldId: field.id,
iterationId: value.iterationId,
title: iteration?.title ?? 'Iteration',
startDate: iteration?.startDate ?? '',
duration: iteration?.duration ?? 0
}
}
if (value.kind === 'number') {
return { kind: 'number', fieldId: field.id, number: value.number }
}
if (value.kind === 'date') {
return { kind: 'date', fieldId: field.id, date: value.date }
}
return { kind: 'text', fieldId: field.id, text: value.kind === 'text' ? value.text : '' }
}
@@ -0,0 +1,251 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
SHOW_MOBILE_PROJECT_METADATA_EDITORS,
projectRowType
} from './mobile-tasks-legacy-foundation'
import {
View,
Text,
ActivityIndicator,
colors,
Pressable,
Check,
TextInput,
MobileMarkdown
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
export function renderMobileTasksProjectLabelsEditor(model: ConnectionPresentationModel) {
const {
mutateProjectRowMetadata,
projectAvailableLabels,
projectLabelsError,
projectLabelsLoading,
projectMutating,
projectRowDetail,
projectRowItem
} = model
if (!projectRowItem) {
return null
}
return SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Labels</Text>
<Text style={styles.detailSectionMeta}>
{(projectRowDetail?.provider === 'github'
? projectRowDetail.labels
: projectRowItem.content.labels.map((label) => label.name)
).length || 'None'}
</Text>
</View>
{projectLabelsLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading labels...</Text>
</View>
) : projectLabelsError ? (
<Text style={styles.detailError}>{projectLabelsError}</Text>
) : projectAvailableLabels.length === 0 ? (
<Text style={styles.detailMuted}>No labels in this repository.</Text>
) : (
<View style={styles.chipRow}>
{[
...new Set([
...projectAvailableLabels,
...(projectRowDetail?.provider === 'github'
? projectRowDetail.labels
: projectRowItem.content.labels.map((label) => label.name))
])
].map((label) => {
const selected = (
projectRowDetail?.provider === 'github'
? projectRowDetail.labels
: projectRowItem.content.labels.map((entry) => entry.name)
).includes(label)
return (
<Pressable
key={label}
style={[styles.detailChip, selected ? styles.detailChipSelected : undefined]}
disabled={projectMutating}
onPress={() =>
void mutateProjectRowMetadata(
projectRowItem,
selected ? { removeLabels: [label] } : { addLabels: [label] }
)
}
>
<View style={styles.issueTypeChipContent}>
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
<Text style={styles.detailChipText}>{label}</Text>
</View>
</Pressable>
)
})}
</View>
)}
</View>
) : null
}
export function renderMobileTasksProjectAssigneesEditor(model: ConnectionPresentationModel) {
const {
mutateProjectRowMetadata,
projectAssignableUsers,
projectAssignableUsersError,
projectAssignableUsersLoading,
projectMutating,
projectRowDetail,
projectRowItem
} = model
if (!projectRowItem) {
return null
}
return SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Assignees</Text>
<Text style={styles.detailSectionMeta}>
{(projectRowDetail?.provider === 'github'
? projectRowDetail.assignees
: projectRowItem.content.assignees.map((assignee) => assignee.login)
).length || 'None'}
</Text>
</View>
{projectAssignableUsersLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading assignees...</Text>
</View>
) : projectAssignableUsersError ? (
<Text style={styles.detailError}>{projectAssignableUsersError}</Text>
) : projectAssignableUsers.length === 0 ? (
<Text style={styles.detailMuted}>No assignable users found for this repository.</Text>
) : (
<View style={styles.chipRow}>
{[
...new Map(
[
...projectAssignableUsers,
...projectRowItem.content.assignees,
...(projectRowDetail?.provider === 'github'
? projectRowDetail.assignees.map((login) => ({
login,
name: null,
avatarUrl: null
}))
: [])
].map((user) => [user.login, user])
).values()
].map((user) => {
const selected = (
projectRowDetail?.provider === 'github'
? projectRowDetail.assignees
: projectRowItem.content.assignees.map((assignee) => assignee.login)
).includes(user.login)
return (
<Pressable
key={user.login}
style={[styles.detailChip, selected ? styles.detailChipSelected : undefined]}
disabled={projectMutating}
onPress={() =>
void mutateProjectRowMetadata(
projectRowItem,
selected ? { removeAssignees: [user.login] } : { addAssignees: [user.login] }
)
}
>
<View style={styles.issueTypeChipContent}>
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
<Text style={styles.detailChipText}>{user.login}</Text>
</View>
</Pressable>
)
})}
</View>
)}
</View>
) : null
}
export function renderMobileTasksProjectIssueMetadata(model: ConnectionPresentationModel) {
const {
mutateProjectRowIssueOrPr,
projectBodyDraft,
projectMutating,
projectRowDetail,
projectRowItem,
projectTitleDraft,
setProjectBodyDraft,
setProjectTitleDraft
} = model
if (!projectRowItem) {
return null
}
return SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? (
<>
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Title</Text>
<TextInput
style={styles.input}
value={projectTitleDraft}
onChangeText={setProjectTitleDraft}
placeholder="Title"
placeholderTextColor={colors.textMuted}
/>
<Pressable
style={styles.inlineSaveButton}
disabled={projectMutating || projectTitleDraft.trim() === projectRowItem.content.title}
onPress={() =>
void mutateProjectRowIssueOrPr(projectRowItem, {
title: projectTitleDraft.trim()
})
}
>
<Text style={styles.inlineSaveText}>Save title</Text>
</Pressable>
</View>
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Description</Text>
<TextInput
style={[styles.input, styles.bodyInput]}
value={projectBodyDraft}
onChangeText={setProjectBodyDraft}
placeholder="Description"
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<Pressable
style={styles.inlineSaveButton}
disabled={
projectMutating ||
projectBodyDraft ===
(projectRowDetail?.provider === 'github'
? projectRowDetail.body
: (projectRowItem.content.body ?? ''))
}
onPress={() =>
void mutateProjectRowIssueOrPr(projectRowItem, {
body: projectBodyDraft
})
}
>
<Text style={styles.inlineSaveText}>Save description</Text>
</Pressable>
<MobileMarkdown content={projectBodyDraft} fallback="No description." />
</View>
</>
) : (
<>
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Title</Text>
<Text style={styles.detailLine}>{projectRowItem.content.title}</Text>
</View>
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Description</Text>
<MobileMarkdown content={projectBodyDraft} fallback="No description." />
</View>
</>
)
}
@@ -0,0 +1,228 @@
import {
StyleSheet,
Platform,
colors,
radii,
spacing,
typography
} from './mobile-tasks-dependencies'
export const mobileTasksProjectPickerStyles = StyleSheet.create({
boardContainer: {
gap: spacing.md,
padding: spacing.md
},
boardColumn: {
width: 280,
maxHeight: '100%',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
overflow: 'hidden'
},
boardHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
boardTitle: {
flex: 1,
color: colors.textPrimary,
fontSize: 13,
fontWeight: '600'
},
boardCount: {
color: colors.textMuted,
fontSize: 11
},
boardCard: {
margin: spacing.sm,
marginBottom: 0,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgBase,
padding: spacing.md
},
repoPickerGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
pagePickerList: {
maxHeight: 420,
backgroundColor: colors.bgPanel,
borderRadius: 12
},
projectPickerControls: {
gap: spacing.sm,
marginBottom: spacing.md
},
projectWarningBanner: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.statusAmber,
borderRadius: radii.card,
backgroundColor: colors.bgPanel
},
projectWarningTextWrap: {
flex: 1,
minWidth: 0
},
projectWarningTitle: {
color: colors.textPrimary,
fontSize: 12,
fontWeight: '600'
},
projectWarningText: {
color: colors.textSecondary,
fontSize: 11,
marginTop: 2
},
projectDataNotice: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
projectDataNoticeText: {
flex: 1,
color: colors.statusAmber,
fontSize: 13
},
projectGroupHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.bgPanel
},
projectGroupChevronCollapsed: {
transform: [{ rotate: '-90deg' }]
},
projectGroupTitle: {
flex: 1,
minWidth: 0,
color: colors.textPrimary,
fontSize: 12,
fontWeight: '600'
},
projectGroupMeta: {
color: colors.textMuted,
fontSize: 11
},
projectFieldPillRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginTop: spacing.xs
},
projectFieldPill: {
maxWidth: '100%',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: 999,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.sm,
paddingVertical: 2
},
projectFieldPillText: {
color: colors.textSecondary,
fontSize: 11
},
projectFieldPillEmptyText: {
color: colors.textMuted
},
projectPasteRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
projectPasteInput: {
flex: 1
},
projectPasteButton: {
minHeight: 40,
alignItems: 'center',
justifyContent: 'center'
},
pickerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md + 2,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
pickerRowSelected: {
backgroundColor: colors.bgRaised
},
pickerRowContent: {
flex: 1,
minWidth: 0
},
pickerRowLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
pickerRowSubtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 1
},
pickerRowWithAction: {
flexDirection: 'row',
alignItems: 'center'
},
pickerRowMain: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md + 2,
paddingVertical: spacing.md
},
pickerCheck: {
width: 18,
alignItems: 'center'
},
pickerContent: {
flex: 1,
minWidth: 0
},
pickerLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
monoText: {
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
pickerSubtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 1
},
iconActionButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center'
}
})
@@ -0,0 +1,246 @@
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
import {
SHOW_MOBILE_PROJECT_REVIEW_PANELS,
getGitHubReviewSummary,
getGitHubReviewerRows,
splitReviewerList,
projectRowType,
isFailedGitHubCheck,
GitHubPrFileDiff
} from './mobile-tasks-legacy-foundation'
import {
View,
Text,
ActivityIndicator,
colors,
Pressable,
Check,
TextInput,
Linking,
ExternalLink
} from './mobile-tasks-dependencies'
import { styles } from './mobile-tasks-legacy-styles'
export function renderMobileTasksProjectReviewPanels(model: ConnectionPresentationModel) {
const {
addProjectGitHubFileReviewComment,
expandedPrFilePath,
prFileCommentDrafts,
prFileContents,
prFileLoadingPath,
projectAssignableUsersError,
projectAssignableUsersLoading,
projectMutating,
projectReviewerCandidates,
projectReviewersDraft,
projectRowDetail,
projectRowHostedRepo,
projectRowItem,
projectSelectedReviewerLogins,
refreshProjectGitHubChecks,
requestProjectGitHubReviewers,
rerunProjectGitHubChecks,
setPrFileCommentDrafts,
setProjectReviewersDraft,
toggleProjectGitHubFileExpansion,
toggleProjectGitHubFileViewed
} = model
if (!projectRowItem) {
return null
}
return SHOW_MOBILE_PROJECT_REVIEW_PANELS &&
projectRowItem.itemType === 'PULL_REQUEST' &&
projectRowDetail?.provider === 'github' &&
projectRowHostedRepo ? (
<>
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Reviewers</Text>
<Text style={styles.detailSectionMeta}>{getGitHubReviewSummary(projectRowDetail)}</Text>
</View>
{getGitHubReviewerRows(projectRowDetail).length === 0 ? (
<Text style={styles.detailMuted}>No reviewers requested.</Text>
) : (
getGitHubReviewerRows(projectRowDetail).map((reviewer) => (
<View key={reviewer.login} style={styles.reviewerRow}>
<View style={styles.reviewerAvatar}>
<Text style={styles.reviewerAvatarText}>
{reviewer.login.slice(0, 1).toUpperCase()}
</Text>
</View>
<View style={styles.reviewerInfo}>
<Text style={styles.reviewerName} numberOfLines={1}>
{reviewer.login}
</Text>
{reviewer.name ? (
<Text style={styles.reviewerMeta} numberOfLines={1}>
{reviewer.name}
</Text>
) : null}
</View>
<Text style={styles.reviewerState}>{reviewer.stateLabel}</Text>
</View>
))
)}
{projectAssignableUsersLoading ? (
<View style={styles.detailLoadingInline}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.detailMuted}>Loading reviewers...</Text>
</View>
) : projectAssignableUsersError ? (
<Text style={styles.detailError}>{projectAssignableUsersError}</Text>
) : projectReviewerCandidates.length === 0 ? (
<Text style={styles.detailMuted}>No reviewer suggestions found.</Text>
) : (
<View style={styles.chipRow}>
{projectReviewerCandidates.map((user) => {
const selected = projectSelectedReviewerLogins.has(user.login.trim().toLowerCase())
return (
<Pressable
key={user.login}
style={[styles.detailChip, selected ? styles.detailChipSelected : undefined]}
disabled={projectMutating || selected}
onPress={() => void requestProjectGitHubReviewers(projectRowItem, [user.login])}
>
<View style={styles.issueTypeChipContent}>
{selected ? <Check size={12} color={colors.accentBlue} /> : null}
<Text style={styles.detailChipText}>{user.login}</Text>
</View>
</Pressable>
)
})}
</View>
)}
<TextInput
style={styles.input}
value={projectReviewersDraft}
onChangeText={setProjectReviewersDraft}
placeholder="Request reviewers"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
/>
<Pressable
style={styles.inlineSaveButton}
disabled={projectMutating || splitReviewerList(projectReviewersDraft).length === 0}
onPress={() => void requestProjectGitHubReviewers(projectRowItem)}
>
<Text style={styles.inlineSaveText}>Request review</Text>
</Pressable>
</View>
{projectRowType(projectRowItem) === 'pr' ? (
<View style={styles.detailSection}>
<View style={styles.detailSectionHeader}>
<Text style={styles.detailSectionTitle}>Checks</Text>
<View style={styles.inlineActionRow}>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating}
onPress={() => void refreshProjectGitHubChecks(projectRowItem)}
>
<Text style={styles.inlineSaveText}>Refresh</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating || !projectRowDetail.checks.some(isFailedGitHubCheck)}
onPress={() => void rerunProjectGitHubChecks(projectRowItem, true)}
>
<Text style={styles.inlineSaveText}>Rerun failed</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating || projectRowDetail.checks.length === 0}
onPress={() => void rerunProjectGitHubChecks(projectRowItem, false)}
>
<Text style={styles.inlineSaveText}>Rerun all</Text>
</Pressable>
</View>
</View>
{projectRowDetail.checks.length === 0 ? (
<Text style={styles.detailMuted}>No checks found.</Text>
) : (
projectRowDetail.checks.map((check) => (
<Pressable
key={`${check.name}:${check.status}:${check.url ?? ''}`}
style={styles.fileActionRow}
disabled={!check.url}
onPress={() => {
if (check.url) {
void Linking.openURL(check.url)
}
}}
>
<Text style={styles.detailLine} numberOfLines={2}>
{check.name} · {check.conclusion ?? check.status}
</Text>
{check.url ? <ExternalLink size={14} color={colors.textSecondary} /> : null}
</Pressable>
))
)}
</View>
) : null}
{projectRowDetail.files.length > 0 ? (
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Changed files</Text>
{projectRowDetail.files.map((file) => (
<View key={file.path} style={styles.fileCard}>
<Pressable
style={styles.fileActionRow}
disabled={projectMutating}
onPress={() => void toggleProjectGitHubFileExpansion(projectRowItem, file)}
>
<Text style={styles.detailLine}>
{file.path}
{typeof file.additions === 'number' || typeof file.deletions === 'number'
? ` · +${file.additions ?? 0} -${file.deletions ?? 0}`
: ''}
</Text>
<Text style={styles.detailSectionMeta}>
{expandedPrFilePath === file.path ? 'Hide' : 'View'}
</Text>
</Pressable>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={projectMutating || !projectRowDetail.pullRequestId}
onPress={() => void toggleProjectGitHubFileViewed(projectRowItem, file)}
>
<Text style={styles.inlineSaveText}>
{file.viewerViewedState === 'VIEWED' ? 'Mark unviewed' : 'Mark viewed'}
</Text>
</Pressable>
{expandedPrFilePath === file.path ? (
<View style={styles.filePreview}>
{prFileLoadingPath === file.path ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : prFileContents[file.path]?.originalIsBinary ||
prFileContents[file.path]?.modifiedIsBinary ? (
<Text style={styles.detailMuted}>Binary file.</Text>
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]}
commentDrafts={prFileCommentDrafts}
disabled={projectMutating}
onCommentDraftChange={(draftKey, next) =>
setPrFileCommentDrafts((current) => ({
...current,
[draftKey]: next
}))
}
onSubmitComment={(line) =>
void addProjectGitHubFileReviewComment(projectRowItem, file, line)
}
/>
) : (
<Text style={styles.detailMuted}>File contents unavailable.</Text>
)}
</View>
) : null}
</View>
))}
</View>
) : null}
</>
) : null
}
@@ -0,0 +1,141 @@
import type {
GitHubWorkItem,
GitLabWorkItem,
GitLabTodo,
LinearIssue,
SetupDecision
} from './mobile-tasks-provider-detail-types'
import type { WorkspaceAgentChoice, SparsePreset } from './mobile-tasks-dependencies'
import type { GitHubProjectRow } from './mobile-tasks-view-state-types'
export type TaskItem =
| {
key: string
provider: 'github'
title: string
subtitle: string
status: string
updatedAt: string
source: GitHubWorkItem
}
| {
key: string
provider: 'gitlab'
title: string
subtitle: string
status: string
updatedAt: string
source: GitLabWorkItem
}
| {
key: string
provider: 'gitlabTodo'
title: string
subtitle: string
status: string
updatedAt: string
source: GitLabTodo
}
| {
key: string
provider: 'linear'
title: string
subtitle: string
status: string
updatedAt: string
source: LinearIssue
}
export type ActionableTaskItem = Exclude<TaskItem, { provider: 'gitlabTodo' }>
export type HostedReviewMergeMethod = 'merge' | 'squash' | 'rebase'
export type HostedReviewItem =
| Extract<TaskItem, { provider: 'github' }>
| Extract<TaskItem, { provider: 'gitlab' }>
export type PendingHostedMerge = {
item: HostedReviewItem
method: HostedReviewMergeMethod
}
export type PendingProjectGitHubMerge = {
row: GitHubProjectRow
method: HostedReviewMergeMethod
}
export type PendingHostedStateChange =
| {
source: 'task'
item: Extract<TaskItem, { provider: 'github' }> | Extract<TaskItem, { provider: 'gitlab' }>
nextState: 'open' | 'opened' | 'closed'
}
| {
source: 'project'
row: GitHubProjectRow
nextState: 'open' | 'closed'
}
export type SetupPrompt = {
item: ActionableTaskItem
repoIdOverride?: string
agentOverride?: WorkspaceAgentChoice
workspaceNameOverride?: string
noteOverride?: string
baseBranchOverride?: string
branchNameOverride?: string
sparseCheckoutOverride?: { directories: string[]; presetId?: string }
repoName: string
command: string
source: string | null
}
export type WorkspaceCreateArgs = {
item: ActionableTaskItem
repoIdOverride?: string
setupOverride?: Exclude<SetupDecision, 'inherit'>
agentOverride?: WorkspaceAgentChoice
workspaceNameOverride?: string
noteOverride?: string
baseBranchOverride?: string
branchNameOverride?: string
sparseCheckoutOverride?: { directories: string[]; presetId?: string }
}
export type OrcaYamlTrustPrompt = WorkspaceCreateArgs & {
repoId: string
repoName: string
scriptContent: string
contentHash: string
previouslyApproved: boolean
}
export type WorkspaceCreateDraft = {
item: ActionableTaskItem
repoIdOverride?: string
}
export type WorkspaceSparseDraft = {
mode: 'new' | 'edit'
presetId?: string
name: string
directoriesText: string
}
export function sortSparsePresetsByName(presets: SparsePreset[]): SparsePreset[] {
return [...presets].sort((left, right) => left.name.localeCompare(right.name))
}
export function workspaceAgentIconId(agent: WorkspaceAgentChoice): string {
return agent === 'blank' ? '__blank__' : agent
}
export type ProjectRepoNotInOrcaPrompt = {
owner: string
repo: string
url: string | null
}
export type TaskListEntry =
| { type: 'section'; key: string; label: string; color: string }
| { type: 'item'; key: string; item: TaskItem }

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