Optimize and integrate mobile PR view within source control hub

- Lazy-load heavy PR comments and descriptions (Phase 2) only when the
  PR tab is active, using fast metadata (Phase 1) for the branch chip.
- Unmount the PR body when inactive to avoid unnecessary comment tree
  re-renders and preserve WebView resources during commit text editing.
- Implement soft-refresh on HEAD advancement to keep the ready UI
  visible while re-fetching checks post-commit.
- Display the "Aborting..." label only when a merge or rebase abort
  is actively in flight.
- Memoize the git history list and skip branch identity RPCs when
  gating the dock icon.
This commit is contained in:
Jinjing
2026-07-09 18:04:29 -07:00
parent 6071fb4d51
commit a91ab04b19
17 changed files with 988 additions and 377 deletions
+2 -2
View File
@@ -3,8 +3,8 @@ import { firstParam } from '../../../../src/source-control/mobile-source-control
// The Pull Request view is now a segment of the Source Control hub. This route
// stays as a thin redirect so existing deep links land on the hub with the Pull
// Request segment selected. The wide-layout dock still renders MobilePrViewPanel
// directly (it does not route through here).
// Request segment selected. The wide-layout dock opens the same hub with
// initialTab="pr" (SessionDockColumn), not this route.
export default function PrRedirect() {
const params = useLocalSearchParams<{
hostId?: string | string[]
+13 -23
View File
@@ -856,20 +856,16 @@ export default function SessionScreen() {
setActivePanel(null)
}
}, [canDockPanel, activePanel])
// Session-level PR context feeds the docked PR panel and gates the GitHub-only
// PR entry so GitLab/other providers do not open a GitHub RPC surface.
const {
branch: prBranch,
headSha: prHeadSha,
status: prStatus,
isGithubRepo: prIsGithubRepo,
repoLoaded: prRepoContextLoaded,
loaded: prContextLoaded
} = useMobilePrBranchContext({
client,
connState,
worktreeId
})
// Session-level GitHub remote probe gates the PR dock icon so non-GitHub
// providers do not open the hosted-review surface. Branch/head/status for the
// hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs.
const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
useMobilePrBranchContext({
client,
connState,
worktreeId,
includeBranchIdentity: false
})
useEffect(() => {
if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') {
setActivePanel(null)
@@ -4438,8 +4434,9 @@ export default function SessionScreen() {
hostId,
worktreeId,
name: worktreeName || '',
// Source control's post-diff-open dismissal keys off origin: 'session' (U2).
...(action.panel === 'sourceControl' ? { origin: 'session' } : {}),
// SC + PR both land on the source-control hub; post-diff-open dismissal
// keys off origin: 'session' (U2). Files keeps its own route without origin.
...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}),
// The PR panel routes into the hub's Pull Request segment via descriptor params.
...descriptor.params
}
@@ -5127,13 +5124,6 @@ export default function SessionScreen() {
hostId={hostId}
worktreeId={worktreeId}
name={worktreeName || ''}
client={client}
connState={connState}
branch={prBranch}
headSha={prHeadSha}
gitStatus={prStatus}
isGithubRepo={prIsGithubRepo}
branchContextLoaded={prContextLoaded && prRepoContextLoaded}
availableWidth={sessionContentRowWidth}
onRequestClose={() => setActivePanel(null)}
onFileOpenStart={handleFileOpenStart}
+383 -50
View File
@@ -1258,21 +1258,54 @@
</div>
<div class="row-fg">
<span class="row-check">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
<span class="status-badge s-mod">M</span>
<span class="file-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
<span class="file-text">
<span class="file-name">EditorFileTab.tsx</span>
<span class="file-meta">
<span class="file-dir">&#8206;src/renderer/src/components/tab-bar/</span>
<span class="diffstat"><span class="add">+12</span><span class="del">−4</span></span>
<span class="diffstat"
><span class="add">+12</span><span class="del">−4</span></span
>
</span>
</span>
<span class="chev">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</span>
</div>
</div>
@@ -1293,17 +1326,53 @@
<div class="row-action stage"><span>+</span><span>Stage</span></div>
<div class="row-action discard"><span>🗑</span><span>Discard</span></div>
<div class="row-fg">
<span class="row-check"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="row-check"
><svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" /></svg
></span>
<span class="status-badge s-mod">M</span>
<span class="file-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
<span class="file-icon"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" /></svg
></span>
<span class="file-text">
<span class="file-name">CenterSplitDivider.tsx</span>
<span class="file-meta">
<span class="file-dir">&#8206;src/renderer/src/components/split-view/</span>
<span class="diffstat"><span class="add">+34</span><span class="del">−8</span></span>
<span class="diffstat"
><span class="add">+34</span><span class="del">−8</span></span
>
</span>
</span>
<span class="chev"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
<span class="chev"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" /></svg
></span>
</div>
</div>
<div
@@ -1315,17 +1384,53 @@
<div class="row-action stage"><span>+</span><span>Stage</span></div>
<div class="row-action discard"><span>🗑</span><span>Discard</span></div>
<div class="row-fg">
<span class="row-check"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="row-check"
><svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" /></svg
></span>
<span class="status-badge s-mod">M</span>
<span class="file-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
<span class="file-icon"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" /></svg
></span>
<span class="file-text">
<span class="file-name">split-view-geometry.ts</span>
<span class="file-meta">
<span class="file-dir">&#8206;src/renderer/src/lib/</span>
<span class="diffstat"><span class="add">+6</span><span class="del">−2</span></span>
<span class="diffstat"
><span class="add">+6</span><span class="del">−2</span></span
>
</span>
</span>
<span class="chev"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
<span class="chev"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" /></svg
></span>
</div>
</div>
<div
@@ -1337,17 +1442,55 @@
<div class="row-action stage"><span>+</span><span>Stage</span></div>
<div class="row-action discard"><span>🗑</span><span>Discard</span></div>
<div class="row-fg">
<span class="row-check"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="row-check"
><svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" /></svg
></span>
<span class="status-badge s-add">A</span>
<span class="file-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
<span class="file-icon"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" /></svg
></span>
<span class="file-text">
<span class="file-name">SplitDividerHandle.test.tsx</span>
<span class="file-meta">
<span class="file-dir">&#8206;src/renderer/src/components/split-view/__tests__/</span>
<span class="diffstat"><span class="add">+48</span><span class="del">−0</span></span>
<span class="file-dir"
>&#8206;src/renderer/src/components/split-view/__tests__/</span
>
<span class="diffstat"
><span class="add">+48</span><span class="del">−0</span></span
>
</span>
</span>
<span class="chev"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
<span class="chev"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" /></svg
></span>
</div>
</div>
</div>
@@ -1356,22 +1499,78 @@
<div class="commit-bar" id="commit-bar">
<div class="commit-input">
<input type="text" placeholder="Commit message" />
<button class="sparkle-btn" title="Generate message" onclick="toast('Generating commit message ✦')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/><path d="M19 15l.7 1.8L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.7z"/></svg>
<button
class="sparkle-btn"
title="Generate message"
onclick="toast('Generating commit message ✦')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z" />
<path d="M19 15l.7 1.8L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.7z" />
</svg>
</button>
</div>
<div class="split-btn">
<button class="split-main" onclick="commitAction('Commit')">Commit</button>
<button class="split-caret" onclick="toggleSplitMenu(event)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div class="split-menu" id="split-menu">
<div class="menu-item" onclick="commitAction('Commit &amp; Push')">
<span class="mi-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></span>
<span class="mi-icon"
><svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" /></svg
></span>
Commit &amp; Push
</div>
<div class="menu-item" onclick="commitAction('Commit &amp; Sync')">
<span class="mi-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span>
<span class="mi-icon"
><svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
/></svg
></span>
Commit &amp; Sync
</div>
</div>
@@ -1383,7 +1582,13 @@
<span class="bulk-count" id="bulk-count">0 selected</span>
<button class="bulk-action" onclick="bulkAction('Stage')">Stage</button>
<button class="bulk-action" onclick="bulkAction('Unstage')">Unstage</button>
<button class="bulk-action destructive" onclick="bulkAction('Discard')" id="bulk-discard">Discard</button>
<button
class="bulk-action destructive"
onclick="bulkAction('Discard')"
id="bulk-discard"
>
Discard
</button>
</div>
</div>
@@ -1396,10 +1601,14 @@
<div class="row">
<span class="pill open">Open</span>
<span class="pr-num">#7701</span>
<span style="font-size:12px;color:var(--text-muted);">octocat wants to merge into main</span>
<span style="font-size: 12px; color: var(--text-muted)"
>octocat wants to merge into main</span
>
</div>
<div class="pr-title">Make center split divider more visible</div>
<div class="pr-subline">make-center-split-more-visible → main · updated 20m ago</div>
<div class="pr-subline">
make-center-split-more-visible → main · updated 20m ago
</div>
</div>
<!-- failing checks first -->
@@ -1407,25 +1616,71 @@
<span class="g-title warn">1 check failing</span>
<span class="g-spacer"></span>
<button class="ai-btn" onclick="toast('Launching agent to fix checks ✦')">
<span class="sparkle"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/></svg></span>
<span class="sparkle"
><svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"
/></svg
></span>
Fix failing checks
</button>
</div>
<div class="check-row" onclick="toast('Opening check log tail')">
<span class="check-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--status-red)" stroke-width="2.4" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></span>
<span class="check-icon"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--status-red)"
stroke-width="2.4"
stroke-linecap="round"
>
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" /></svg
></span>
<span class="check-name">
<span class="n">e2e / playwright (ubuntu-latest)</span>
<span class="d">Failing · 1 test · 2m 14s</span>
</span>
<button class="mini-btn" onclick="event.stopPropagation();toast('Rerunning check…')">Rerun</button>
<button
class="mini-btn"
onclick="
event.stopPropagation()
toast('Rerunning check…')
"
>
Rerun
</button>
</div>
<!-- merge box -->
<div class="merge-box">
<div class="merge-method" onclick="openMergeMethod()">
<span class="lbl">Merge method</span>
<span class="val">Squash and merge
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span class="val"
>Squash and merge
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</div>
<div class="toggle-row">
@@ -1433,10 +1688,25 @@
<div class="t">Auto-merge when checks pass</div>
<div class="s">Merges automatically once required checks are green</div>
</div>
<div class="switch" id="automerge" onclick="toggleSwitch('automerge')"><span class="knob"></span></div>
<div class="switch" id="automerge" onclick="toggleSwitch('automerge')">
<span class="knob"></span>
</div>
</div>
<button class="merge-btn" onclick="toast('Confirm merge…')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M6 21V9a9 9 0 0 0 9 9" />
</svg>
Squash and merge
</button>
<div class="merge-blocked">1 failing check must pass before merging</div>
@@ -1448,20 +1718,37 @@
<span class="g-sub">· 3</span>
<span class="g-spacer"></span>
<button class="ai-btn" onclick="toast('Resolving comments with AI ✦')">
<span class="sparkle"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/></svg></span>
<span class="sparkle"
><svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"
/></svg
></span>
Resolve with AI
</button>
</div>
<div class="comment-row" onclick="toast('Opening comment thread')">
<span class="avatar" style="background:#f59e0b;">R</span>
<span class="avatar" style="background: #f59e0b">R</span>
<span class="comment-body">
<span class="comment-meta">rauchg · 18m ago</span>
<span class="comment-text">Can we clamp the divider width so it doesn't overlap the gutter on narrow panes?</span>
<span class="comment-text"
>Can we clamp the divider width so it doesn't overlap the gutter on narrow
panes?</span
>
<span class="comment-file">CenterSplitDivider.tsx:42</span>
</span>
</div>
<div class="comment-row" onclick="toast('Opening comment thread')">
<span class="avatar" style="background:#3b82f6;">M</span>
<span class="avatar" style="background: #3b82f6">M</span>
<span class="comment-body">
<span class="comment-meta">maren · 25m ago</span>
<span class="comment-text">Add a test for the min-width geometry branch.</span>
@@ -1474,12 +1761,12 @@
<span class="g-title">Reviewers</span>
</div>
<div class="reviewer-row">
<span class="avatar" style="background:#22c55e;">O</span>
<span class="avatar" style="background: #22c55e">O</span>
<span class="r-name">octocat</span>
<span class="r-state approved">Approved</span>
</div>
<div class="reviewer-row">
<span class="avatar" style="background:#8b5cf6;">D</span>
<span class="avatar" style="background: #8b5cf6">D</span>
<span class="r-name">dstndev</span>
<span class="r-state pending">Review requested</span>
</div>
@@ -1492,12 +1779,41 @@
<button class="mini-btn" onclick="toast('Rerunning failed…')">Rerun failed</button>
</div>
<div class="check-row" onclick="toast('Opening check log tail')">
<span class="check-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--status-green)" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="check-name"><span class="n">build / typecheck</span><span class="d">Passed · 1m 02s</span></span>
<span class="check-icon"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--status-green)"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" /></svg
></span>
<span class="check-name"
><span class="n">build / typecheck</span
><span class="d">Passed · 1m 02s</span></span
>
</div>
<div class="check-row" onclick="toast('Opening check log tail')">
<span class="check-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--status-green)" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="check-name"><span class="n">lint / oxlint</span><span class="d">Passed · 38s</span></span>
<span class="check-icon"
><svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--status-green)"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" /></svg
></span>
<span class="check-name"
><span class="n">lint / oxlint</span><span class="d">Passed · 38s</span></span
>
</div>
</div>
</div>
@@ -1507,18 +1823,26 @@
<div class="pane" id="pane-history">
<div class="list-pad">
<div class="history">
<div class="commit" data-hash="a1b2c3d" data-msg="Make center split divider more visible">
<div
class="commit"
data-hash="a1b2c3d"
data-msg="Make center split divider more visible"
>
<div class="rail"><div class="dot head"></div></div>
<div class="commit-body">
<div class="commit-msg">Make center split divider more visible</div>
<div class="commit-sub"><span class="hash">a1b2c3d</span> · Jinjing · 18m ago</div>
<div class="commit-sub">
<span class="hash">a1b2c3d</span> · Jinjing · 18m ago
</div>
</div>
</div>
<div class="commit" data-hash="9f8e7d6" data-msg="Add split-view geometry helper">
<div class="rail"><div class="dot"></div></div>
<div class="commit-body">
<div class="commit-msg">Add split-view geometry helper</div>
<div class="commit-sub"><span class="hash">9f8e7d6</span> · Jinjing · 42m ago</div>
<div class="commit-sub">
<span class="hash">9f8e7d6</span> · Jinjing · 42m ago
</div>
</div>
</div>
@@ -1527,11 +1851,17 @@
<span class="line"></span>
</div>
<div class="commit" data-hash="0f7658d" data-msg="Make center split divider more visible (#7701)">
<div
class="commit"
data-hash="0f7658d"
data-msg="Make center split divider more visible (#7701)"
>
<div class="rail"><div class="dot merge"></div></div>
<div class="commit-body">
<div class="commit-msg">Merge pull request #7699 from orca/main</div>
<div class="commit-sub"><span class="hash">0f7658d</span> · web-flow · 3h ago</div>
<div class="commit-sub">
<span class="hash">0f7658d</span> · web-flow · 3h ago
</div>
</div>
</div>
<div class="commit" data-hash="2dad1f0" data-msg="Update README downloads badge">
@@ -1645,10 +1975,13 @@
diff: '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/>',
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
stage: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
discard: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
discard:
'<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
remote: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/>',
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
remote:
'<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/>',
merge:
'<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
hash: '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>',
message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
sparkle: '<path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/>'
@@ -1,20 +1,13 @@
import { useEffect } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, ExternalLink, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { StyleSheet, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { colors } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import {
useMobilePrSidebarController,
type MobilePrSidebarController
} from '../../session/use-mobile-pr-sidebar-controller'
import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
type BodyProps = {
type Props = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
@@ -24,32 +17,11 @@ type BodyProps = {
isGithubRepo?: boolean
branchContextLoaded?: boolean
controller: MobilePrSidebarController
// 'chromeless' drops the panel's own header + SafeAreaView so it can live inside
// the source-control hub, which owns the header, segmented control, and chrome.
chromeless?: boolean
// When false, the parent (the hub) owns triggering the initial load — the body
// must not also fire it, or a ?tab=pr deep link double-fetches. Default true so
// the standalone route stays self-loading.
autoLoad?: boolean
embedded?: boolean
onRequestClose?: () => void
}
type Props = Omit<BodyProps, 'controller' | 'autoLoad'>
// Standalone entry (the /pr route + wide-layout dock): owns its controller and
// self-loads. The hub uses MobilePrViewPanelBody with a shared controller instead.
export function MobilePrViewPanel(props: Props) {
const controller = useMobilePrSidebarController({
client: props.client,
connState: props.connState,
worktreeId: props.worktreeId,
branch: props.branch,
headSha: props.headSha
})
return <MobilePrViewPanelBody {...props} controller={controller} />
}
// Chromeless PR sidebar body for the source-control hub's Pull Request segment.
// The hub owns the header, segmented control, load triggers, and the shared
// controller (one fetch feeds both the branch-card chip and this body).
export function MobilePrViewPanelBody({
client,
connState,
@@ -59,28 +31,10 @@ export function MobilePrViewPanelBody({
gitStatus,
isGithubRepo = true,
branchContextLoaded = true,
controller,
chromeless = false,
autoLoad = true,
embedded = false,
onRequestClose
}: BodyProps) {
const router = useRouter()
controller
}: Props) {
const insets = useSafeAreaInsets()
// A docked/full-screen PR panel is always visible — there is no drawer to open,
// so trigger the load directly once context is ready rather than gating on the
// showPRSidebar overlay flag (KTD4). Skipped when the hub owns the trigger.
const prSidebarKind = controller.prSidebarState.kind
const refetch = controller.refetchPRSidebar
useEffect(() => {
if (autoLoad && branch && isGithubRepo && prSidebarKind === 'hidden') {
refetch()
}
}, [autoLoad, branch, isGithubRepo, prSidebarKind, refetch])
// Embedded: the dock column applies the bottom inset; full-screen relies on its own
// SafeAreaView (edges top only), so content must clear the home indicator itself.
const sidebarState = !branchContextLoaded
? ({ kind: 'loading' } as const)
: !isGithubRepo
@@ -94,91 +48,22 @@ export function MobilePrViewPanelBody({
message: 'Current branch unavailable.'
} as const)
: controller.prSidebarState
// Why: open-on-host lives in the chrome so the PR URL is always flush-right of
// the screen title, even when the body is scrolled past the PR header.
const prUrl =
sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null
const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null
const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined
const openPrControl = openPr ? (
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={
prNumber != null
? `Open pull request #${prNumber} on the web`
: 'Open pull request on the web'
}
>
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null
const sidebar = (
<MobilePRSidebar
state={sidebarState}
onRetry={controller.retryPRSidebar}
refetch={controller.refetchPRSidebar}
client={client}
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
/>
)
// Chromeless: the hub owns the header + chrome, so render just the scrollable body.
if (chromeless) {
return <View style={styles.container}>{sidebar}</View>
}
if (embedded) {
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={onRequestClose}
hitSlop={8}
accessibilityLabel="Close pull request panel"
>
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={() => router.back()}
hitSlop={8}
accessibilityLabel="Back to session"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
</SafeAreaView>
<View style={styles.container}>
<MobilePRSidebar
state={sidebarState}
onRetry={controller.retryPRSidebar}
refetch={controller.refetchPRSidebar}
client={client}
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
/>
</View>
)
}
@@ -186,34 +71,5 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
header: {
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
topBar: {
minHeight: 58,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.md
},
iconButton: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
iconButtonPressed: {
backgroundColor: colors.bgRaised
},
title: {
flex: 1,
minWidth: 0,
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '600'
}
})
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ArrowRight, Pencil } from 'lucide-react-native'
import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types'
import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action'
@@ -36,19 +36,32 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
return (
<View style={styles.section}>
<View style={styles.sectionBody}>
<Pressable
onPress={openPr}
disabled={!openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
style={({ pressed }) => [
styles.badge,
{ borderColor: badgeColor },
pressed && { opacity: 0.6 }
]}
>
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
</Pressable>
<View style={styles.badgeRow}>
<Pressable
onPress={openPr}
disabled={!openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
style={({ pressed }) => [
styles.badge,
{ borderColor: badgeColor },
pressed && { opacity: 0.6 }
]}
>
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
</Pressable>
{openPr ? (
<Pressable
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
style={({ pressed }) => [styles.openWebButton, pressed && { opacity: 0.6 }]}
>
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null}
</View>
<PRTitle
title={title}
number={pr.number}
@@ -56,6 +56,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontWeight: '600'
},
// Header section: state badge, title, author, base<-head branches.
// Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it).
badgeRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
badge: {
alignSelf: 'flex-start',
paddingHorizontal: spacing.sm,
@@ -68,6 +75,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontSize: typography.metaSize,
fontWeight: '700'
},
openWebButton: {
minWidth: 32,
minHeight: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
prTitle: {
color: colors.textPrimary,
fontSize: typography.titleSize,
+8 -52
View File
@@ -1,27 +1,16 @@
import { memo } from 'react'
import { View, StyleSheet } from 'react-native'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { MobileSourceControlPanel } from '../source-control/MobileSourceControlPanel'
import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel'
import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel'
import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles'
import { useMobileDockResize } from './use-mobile-dock-resize'
import type { ActivePanel } from './session-panel-host'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
type Props = {
activePanel: Exclude<ActivePanel, null>
hostId: string
worktreeId: string
name: string
client: RpcClient | null
connState: ConnectionState
branch: string | null
headSha: string | null
gitStatus: MobileGitStatusResult | null
isGithubRepo: boolean
branchContextLoaded: boolean
availableWidth: number
onRequestClose: () => void
onFileOpenStart?: () => void
@@ -40,13 +29,6 @@ export function SessionDockColumn({
hostId,
worktreeId,
name,
client,
connState,
branch,
headSha,
gitStatus,
isGithubRepo,
branchContextLoaded,
availableWidth,
onRequestClose,
onFileOpenStart,
@@ -63,13 +45,6 @@ export function SessionDockColumn({
hostId={hostId}
worktreeId={worktreeId}
name={name}
client={client}
connState={connState}
branch={branch}
headSha={headSha}
gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
onRequestClose={onRequestClose}
onFileOpenStart={onFileOpenStart}
onOpenedFileDiff={onOpenedFileDiff}
@@ -85,18 +60,14 @@ const DockPanelContent = memo(function DockPanelContent({
hostId,
worktreeId,
name,
client,
connState,
branch,
headSha,
gitStatus,
isGithubRepo,
branchContextLoaded,
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
}: DockPanelContentProps) {
if (activePanel === 'sourceControl') {
// Source Control and Pull Request share one hub instance so swapping dock icons
// keeps commit draft / visited-tab / scroll state and lands on the right segment
// (design: PR dock maps to hub + tab=pr).
if (activePanel === 'sourceControl' || activePanel === 'pr') {
return (
<MobileSourceControlPanel
hostId={hostId}
@@ -104,33 +75,18 @@ const DockPanelContent = memo(function DockPanelContent({
name={name}
origin="session"
embedded
initialTab={activePanel === 'pr' ? 'pr' : 'changes'}
onRequestClose={onRequestClose}
onFileOpenStart={onFileOpenStart}
onOpenedFileDiff={onOpenedFileDiff}
/>
)
}
if (activePanel === 'files') {
return (
<MobileFileExplorerPanel
hostId={hostId}
worktreeId={worktreeId}
name={name}
embedded
onRequestClose={onRequestClose}
/>
)
}
return (
<MobilePrViewPanel
client={client}
connState={connState}
<MobileFileExplorerPanel
hostId={hostId}
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
name={name}
embedded
onRequestClose={onRequestClose}
/>
@@ -140,6 +140,47 @@ export async function loadPrSidebarDetails(
}
}
// UI treats details===null as "still loading". After phase 2 finishes (success or
// non-fatal failure), never leave null — use prior comments if any, else empty body.
export function resolvePrSidebarDetailsAfterPhase2(args: {
fetched: GitHubWorkItemDetails | null
prior: GitHubWorkItemDetails | null
pr: PRInfo
}): GitHubWorkItemDetails {
if (args.fetched != null) {
return args.fetched
}
if (args.prior != null) {
return args.prior
}
return emptyPrSidebarDetails(args.pr)
}
// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
return {
item: {
id: `pr-${pr.number}`,
type: 'pr',
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.url,
labels: [],
updatedAt: pr.updatedAt,
author: null
},
body: '',
comments: []
}
}
// Soft head refresh may restart an in-flight phase-1 load; only skip when there is
// no visible/in-flight sidebar work (hidden or terminal error states).
export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean {
return kind === 'ready' || kind === 'none' || kind === 'loading'
}
// Stale-response guard (KTD6): a load tagged with an older sequence must not
// overwrite a newer one. The hook bumps a monotonic counter per load.
export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean {
@@ -34,12 +34,16 @@ export function deriveMobilePrBranchContext(
// Loads repo eligibility independently from branch/SHA. The header PR icon only
// needs the cheap GitHub probe; the panel can keep loading branch context after
// the entry point is already stable in the top bar.
//
// `includeBranchIdentity: false` skips git.status + branchCompare — use when the
// caller only gates a PR entry on hosted-repo eligibility (session dock icon).
export function useMobilePrBranchContext(input: {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
includeBranchIdentity?: boolean
}): MobilePrBranchContext {
const { client, connState, worktreeId } = input
const { client, connState, worktreeId, includeBranchIdentity = true } = input
const [context, setContext] = useState<MobilePrBranchContext>({
branch: null,
headSha: null,
@@ -60,7 +64,7 @@ export function useMobilePrBranchContext(input: {
status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
loaded: !includeBranchIdentity
})
return
}
@@ -70,7 +74,8 @@ export function useMobilePrBranchContext(input: {
status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
// Repo-only mode is "loaded" for branch fields immediately (they stay null).
loaded: !includeBranchIdentity
})
void loadMobilePrRepoContext(client, worktreeId)
@@ -95,6 +100,12 @@ export function useMobilePrBranchContext(input: {
}
})
if (!includeBranchIdentity) {
return () => {
cancelled = true
}
}
void loadMobilePrBranchIdentity(client, worktreeId)
.then((next) => {
if (!cancelled) {
@@ -121,7 +132,7 @@ export function useMobilePrBranchContext(input: {
return () => {
cancelled = true
}
}, [ready, client, worktreeId])
}, [ready, client, worktreeId, includeBranchIdentity])
return context
}
@@ -4,11 +4,15 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import type { GitHubPrReadOutcome } from './github-pr-rpc'
import {
classifyPrSidebarFailure,
emptyPrSidebarDetails,
loadPrSidebarData,
loadPrSidebarDetails,
resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
shouldSoftRefreshPrSidebarOnHeadChange,
type PrSidebarLoadDeps
} from './mobile-pr-sidebar-state'
import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller'
function ok<T>(result: T): GitHubPrReadOutcome<T> {
return { ok: true, result }
@@ -200,3 +204,61 @@ describe('shouldApplyResult', () => {
expect(shouldApplyResult(2, 3)).toBe(false)
})
})
describe('shouldSoftRefreshPrSidebarOnHeadChange', () => {
it('restarts ready/none/loading so mid-flight head advances are not stuck', () => {
expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true)
expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true)
expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true)
})
it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => {
expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false)
expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false)
expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false)
})
})
describe('resolvePrSidebarDetailsAfterPhase2', () => {
it('prefers a successful fetch over prior details', () => {
const prior = emptyPrSidebarDetails(PR)
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS)
})
it('keeps prior details when the fetch is non-fatal null', () => {
const prior = emptyPrSidebarDetails(PR)
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior)
})
it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => {
const empty = resolvePrSidebarDetailsAfterPhase2({
fetched: null,
prior: null,
pr: PR
})
expect(empty.body).toBe('')
expect(empty.comments).toEqual([])
expect(empty.item.number).toBe(7)
expect(empty.item.type).toBe('pr')
})
})
describe('buildMobilePrSidebarIdentity', () => {
it('keys identity on worktree + branch, not head SHA', () => {
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat')
// Head advances on commit must not form a new identity — soft refresh keeps ready UI.
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe(
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })
)
})
it('is null without a branch and changes when branch or worktree changes', () => {
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull()
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' })
)
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' })
)
})
})
@@ -11,7 +11,9 @@ import {
import {
loadPrSidebarData,
loadPrSidebarDetails,
resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
shouldSoftRefreshPrSidebarOnHeadChange,
type PrSidebarLoadDeps,
type PrSidebarState
} from './mobile-pr-sidebar-state'
@@ -27,12 +29,20 @@ type PrSidebarControllerInput = {
headSha: string | null
}
function buildPrSidebarIdentity(args: {
// Load options for the shared PR controller. The Source Control hub chip only needs
// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the
// Pull Request segment is actually open.
export type PrSidebarLoadOptions = {
includeDetails?: boolean
}
// Identity is worktree + branch only. Head SHA advances on every commit and must not
// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead.
export function buildMobilePrSidebarIdentity(args: {
worktreeId: string
branch: string | null
headSha: string | null
}): string | null {
return args.branch ? `${args.worktreeId}\u0000${args.branch}\u0000${args.headSha ?? ''}` : null
return args.branch ? `${args.worktreeId}\u0000${args.branch}` : null
}
export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
@@ -41,13 +51,27 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
// independent of whether the branch has an open PR (a no-PR branch shows an
// empty state rather than hiding the icon).
const [isGithubRepo, setIsGithubRepo] = useState(false)
// False until the probe resolves for this worktree. Consumers gate "unavailable
// for this provider" copy on it — isGithubRepo=false is meaningless mid-probe.
const [repoProbeLoaded, setRepoProbeLoaded] = useState(false)
const [state, setState] = useState<PrSidebarState>({ kind: 'hidden' })
const [showPRSidebar, setShowPRSidebar] = useState(false)
const loadSeqRef = useRef(0)
// Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent
// phase-1 soft refresh (and vice versa) when chip bootstrap left details null.
const detailsSeqRef = useRef(0)
// The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's
// fill-in effect fires as soon as phase 1 renders ready with null details —
// exactly when load()'s own phase 2 just started. Without this claim, every
// cold PR-segment open fetched the heavy details payload twice.
const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null)
const stateIdentityRef = useRef<string | null>(null)
const stateRef = useRef(state)
stateRef.current = state
const headShaRef = useRef(headSha)
const ready = client !== null && connState === 'connected' && !!branch
const identity = buildPrSidebarIdentity({ worktreeId, branch, headSha })
const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
const buildDeps = useCallback((): PrSidebarLoadDeps | null => {
if (!client) {
@@ -63,15 +87,22 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}, [client])
// Probe whether this is a GitHub repo to decide icon availability (GitHub-only).
// Worktree change must reset eligibility; a brief disconnect must not — otherwise
// the hub shows "unavailable for this provider" and hides the chip mid-session.
useEffect(() => {
setIsGithubRepo(false)
setRepoProbeLoaded(false)
}, [worktreeId])
useEffect(() => {
let cancelled = false
if (!ready || !client) {
setIsGithubRepo(false)
return
}
void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
if (!cancelled) {
setIsGithubRepo(outcome.ok && outcome.result !== null)
setRepoProbeLoaded(true)
}
})
return () => {
@@ -82,47 +113,183 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
useEffect(() => {
if (!identity) {
loadSeqRef.current += 1
detailsSeqRef.current += 1
stateIdentityRef.current = null
setState({ kind: 'hidden' })
return
}
if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) {
// Why: ready/loading data is scoped to branch+head. A branch switch must
// not let the open panel keep rendering the previous PR as "fresh."
// Why: ready/loading data is scoped to branch. A branch switch must not let
// the open panel keep rendering the previous PR as "fresh."
loadSeqRef.current += 1
detailsSeqRef.current += 1
stateIdentityRef.current = null
setState({ kind: 'hidden' })
}
}, [identity])
const load = useCallback(async () => {
const load = useCallback(
async (options?: PrSidebarLoadOptions) => {
const includeDetails = options?.includeDetails ?? true
const deps = buildDeps()
const loadIdentity = identity
if (!deps || !branch || !loadIdentity) {
return
}
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
// In-flight phase-2 work is NOT invalidated here: this load only takes phase-2
// ownership when its own phase 2 actually starts (the bump below), so a load
// superseded at the phase-1 guard can never orphan a detailsSeq and silently
// discard the only details fetch. A stale ensure applying mid-phase-1 is safe —
// its identity/number/kind guards only let matching details through.
const previousIdentity = stateIdentityRef.current
stateIdentityRef.current = loadIdentity
// Soft refresh: same branch already showing ready/none stays visible while
// checks re-fetch (head advanced after commit). Hard loading only on first load
// or after a real identity wipe.
const keepVisible =
previousIdentity === loadIdentity &&
(stateRef.current.kind === 'ready' ||
stateRef.current.kind === 'none' ||
stateRef.current.kind === 'loading')
if (!keepVisible) {
setState({ kind: 'loading' })
}
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
if (
!shouldApplyResult(seq, loadSeqRef.current) ||
stateIdentityRef.current !== loadIdentity
) {
return
}
stateIdentityRef.current = loadIdentity
// Keep prior comments/body visible across phase 1 when the same PR is still open.
// loadPrSidebarData always returns details:null; without this, soft refresh and
// PR-tab refresh blank the comment tree until phase 2 finishes.
const priorDetails =
next.kind === 'ready' &&
stateRef.current.kind === 'ready' &&
stateRef.current.data.details != null &&
stateRef.current.data.pr.number === next.data.pr.number
? stateRef.current.data.details
: null
if (next.kind === 'ready' && priorDetails != null) {
setState({ kind: 'ready', data: { ...next.data, details: priorDetails } })
if (!includeDetails) {
return
}
} else {
setState(next)
if (next.kind !== 'ready' || !includeDetails) {
return
}
}
// Phase 2: refresh (or first-load) the heavy comments/body payload.
const detailsSeq = detailsSeqRef.current + 1
detailsSeqRef.current = detailsSeq
detailsInFlightRef.current = { seq: detailsSeq, prNumber: next.data.pr.number }
const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
// Release the claim unless a newer phase-2 superseded it (never clear theirs).
if (detailsInFlightRef.current?.seq === detailsSeq) {
detailsInFlightRef.current = null
}
// Phase-2 ownership is encoded by detailsSeq + identity + PR number alone —
// deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without
// bumping detailsSeq, and must not discard the in-flight details it preserved
// (ensure dedupes against this claim, so nothing would re-fetch them).
if (
detailsSeq !== detailsSeqRef.current ||
stateIdentityRef.current !== loadIdentity ||
stateRef.current.kind !== 'ready' ||
stateRef.current.data.pr.number !== next.data.pr.number
) {
return
}
// Non-fatal null must not leave details===null (UI treats that as forever-loading).
const details = resolvePrSidebarDetailsAfterPhase2({
fetched: fetchedDetails,
prior: stateRef.current.data.details,
pr: stateRef.current.data.pr
})
setState({ kind: 'ready', data: { ...stateRef.current.data, details } })
},
[buildDeps, branch, headSha, identity, worktreeId]
)
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
const ensurePrSidebarDetails = useCallback(async () => {
const current = stateRef.current
if (current.kind !== 'ready' || current.data.details != null) {
return
}
const deps = buildDeps()
const loadIdentity = identity
if (!deps || !branch || !loadIdentity) {
if (!deps || !loadIdentity || stateIdentityRef.current !== loadIdentity) {
return
}
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
stateIdentityRef.current = loadIdentity
setState({ kind: 'loading' })
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
if (!shouldApplyResult(seq, loadSeqRef.current) || stateIdentityRef.current !== loadIdentity) {
const prNumber = current.data.pr.number
// A live phase-2 fetch for this PR is already in flight (its claim still owns
// the latest details seq) — do not start a duplicate.
const inFlight = detailsInFlightRef.current
if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) {
return
}
stateIdentityRef.current = loadIdentity
setState(next)
if (next.kind !== 'ready') {
const detailsSeq = detailsSeqRef.current + 1
detailsSeqRef.current = detailsSeq
detailsInFlightRef.current = { seq: detailsSeq, prNumber }
const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, prNumber)
if (detailsInFlightRef.current?.seq === detailsSeq) {
detailsInFlightRef.current = null
}
if (
detailsSeq !== detailsSeqRef.current ||
stateIdentityRef.current !== loadIdentity ||
stateRef.current.kind !== 'ready' ||
stateRef.current.data.pr.number !== prNumber
) {
return
}
// Phase 2: lazy-load the heavy comments/body payload and merge it in, so it never
// blocks the actionable PR UI. Re-check the seq so a newer load isn't clobbered.
const details = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
if (shouldApplyResult(seq, loadSeqRef.current) && stateIdentityRef.current === loadIdentity) {
setState({ kind: 'ready', data: { ...next.data, details } })
const details = resolvePrSidebarDetailsAfterPhase2({
fetched: fetchedDetails,
prior: stateRef.current.data.details,
pr: stateRef.current.data.pr
})
setState({
kind: 'ready',
data: { ...stateRef.current.data, details }
})
}, [buildDeps, identity, worktreeId])
// Soft-refresh checks when HEAD advances on the same branch (post-commit).
// Also restarts an in-flight phase-1 load so a mid-flight head advance is not
// applied with a stale SHA (headShaRef would otherwise advance with no reload).
useEffect(() => {
if (headShaRef.current === headSha) {
return
}
}, [buildDeps, branch, headSha, identity, worktreeId])
headShaRef.current = headSha
// Identity was just wiped (branch/worktree switch): stateRef still holds the
// pre-wipe state in this effect flush, which would start a load flavored for
// the OLD surface (e.g. heavy details on the Changes tab). Let the owning
// surface's hidden-state effects drive the first load for the new identity.
if (stateIdentityRef.current === null) {
return
}
const current = stateRef.current
if (!shouldSoftRefreshPrSidebarOnHeadChange(current.kind)) {
return
}
void load({
includeDetails: current.kind === 'ready' && current.data.details != null
})
}, [headSha, load])
const openPRSidebar = useCallback(() => {
setShowPRSidebar(true)
@@ -131,18 +298,26 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
stateIdentityRef.current !== identity ||
(state.kind !== 'ready' && state.kind !== 'loading')
) {
void load()
void load({ includeDetails: true })
} else if (state.kind === 'ready' && state.data.details == null) {
void ensurePrSidebarDetails()
}
}, [identity, state.kind, load])
}, [identity, state, load, ensurePrSidebarDetails])
const retry = useCallback(() => {
void load({ includeDetails: true })
}, [load])
return {
prSidebarState: state,
prSidebarIsGithubRepo: isGithubRepo,
prSidebarRepoProbeLoaded: repoProbeLoaded,
showPRSidebar,
setShowPRSidebar,
openPRSidebar,
retryPRSidebar: load,
refetchPRSidebar: load
retryPRSidebar: retry,
refetchPRSidebar: load,
ensurePrSidebarDetails
}
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
@@ -26,7 +26,9 @@ type Props = {
// Headerless commit-history list. Extracted from the /history route so the hub's
// History segment and the standalone route render the same body over one code path.
export function MobileGitHistoryList({
// Memoized: it stays mounted (hidden) while the Changes segment is active, and must
// not re-reconcile its FlatList on every commit-message keystroke re-render.
export const MobileGitHistoryList = memo(function MobileGitHistoryList({
client,
connState,
worktreeId,
@@ -217,7 +219,7 @@ export function MobileGitHistoryList({
contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }}
/>
)
}
})
const styles = StyleSheet.create({
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
@@ -4,6 +4,7 @@ import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-source-control-styles'
import { MobileSourceControlPrChip } from './MobileSourceControlPrChip'
import type { MobilePrChipSummary } from './mobile-pr-chip-summary'
import { mobileConflictAbortLabel } from './mobile-source-control-conflict-abort'
type Props = {
branchLabel: string
@@ -12,7 +13,10 @@ type Props = {
stagedCount: number
branchCount: number
conflictOperation: string | null
// True while any serial git IO is in flight — disables Abort so ops don't race.
conflictBusy: boolean
// True only while abort-merge / abort-rebase itself is running (label accuracy).
conflictAborting: boolean
onAbortConflict: (operation: string) => void
// The PR chip is shown only on repos with a hosted-review remote; null hides it.
prChip: MobilePrChipSummary | null
@@ -30,6 +34,7 @@ export function MobileSourceControlBranchCard({
branchCount,
conflictOperation,
conflictBusy,
conflictAborting,
onAbortConflict,
prChip,
onOpenPr
@@ -60,7 +65,7 @@ export function MobileSourceControlBranchCard({
onPress={() => onAbortConflict(conflictOperation)}
>
<Text style={styles.abortText}>
{conflictBusy ? 'Aborting…' : `Abort ${conflictOperation}`}
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
</Text>
</Pressable>
) : null}
@@ -1,5 +1,5 @@
import { Pressable, Text, View } from 'react-native'
import { ChevronLeft, RefreshCw, X } from 'lucide-react-native'
import { ChevronLeft, ExternalLink, RefreshCw, X } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-source-control-styles'
@@ -9,6 +9,10 @@ type Props = {
ioBusy: boolean
onBack: () => void
onRefresh: () => void
// When set (PR segment ready with a host URL), show open-on-web flush-right of
// the title so the control stays visible while the PR body scrolls.
onOpenPrWeb?: () => void
prNumber?: number | null
}
export function MobileSourceControlHeader({
@@ -16,7 +20,9 @@ export function MobileSourceControlHeader({
worktreeLabel,
ioBusy,
onBack,
onRefresh
onRefresh,
onOpenPrWeb,
prNumber = null
}: Props) {
return (
<View style={styles.topBar}>
@@ -40,6 +46,21 @@ export function MobileSourceControlHeader({
{worktreeLabel}
</Text>
</View>
{onOpenPrWeb ? (
<Pressable
style={({ pressed }) => [styles.refreshButton, pressed && styles.refreshButtonPressed]}
onPress={onOpenPrWeb}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={
prNumber != null
? `Open pull request #${prNumber} on the web`
: 'Open pull request on the web'
}
>
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.refreshButton,
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { colors } from '../theme/mobile-theme'
@@ -14,8 +14,10 @@ import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
export type MobileSourceControlPanelProps = {
hostId: string
@@ -43,8 +45,9 @@ export function MobileSourceControlPanel({
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
// Track first visit so PR/History keep their fetch + scroll state across segment
// switches without paying the mount cost until the user actually opens them.
// Track first visit so Changes/History keep scroll state across segment switches
// without paying the mount cost until the user actually opens them. PR unmounts
// when inactive (WebViews / comment tree) but its controller state stays for the chip.
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<SourceControlHubTab>>(
() => new Set<SourceControlHubTab>([initialTab])
)
@@ -104,6 +107,7 @@ export function MobileSourceControlPanel({
openingBranchPath,
loadStatus,
status,
branchCompareResult,
branchLabel,
syncLabel,
unstagedCount,
@@ -118,8 +122,25 @@ export function MobileSourceControlPanel({
// segment, so the chip's rollup can never disagree with the checks list it
// links to. Branch + head come from the already-loaded git.status — no second
// status read. The chip loads independently, so it never blocks the file list.
const prBranch = status?.branch ?? null
const prHeadSha = status?.head ?? null
// Keep last-known identity across a transient status unload (disconnect /
// failed refresh) so the controller does not wipe ready → hidden → cold start.
// Head SHA matches the review path: status.head ?? branchCompare headOid.
const lastPrBranchRef = useRef<string | null>(null)
const lastPrHeadRef = useRef<string | null>(null)
useEffect(() => {
lastPrBranchRef.current = null
lastPrHeadRef.current = null
}, [worktreeId])
const statusBranch = status?.branch ?? null
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
if (statusBranch) {
lastPrBranchRef.current = statusBranch
}
if (statusHead) {
lastPrHeadRef.current = statusHead
}
const prBranch = statusBranch ?? lastPrBranchRef.current
const prHeadSha = statusHead ?? lastPrHeadRef.current
const prController = useMobilePrSidebarController({
client,
connState,
@@ -130,11 +151,49 @@ export function MobileSourceControlPanel({
const isHostedRepo = prController.prSidebarIsGithubRepo
const prSidebarKind = prController.prSidebarState.kind
const refetchPr = prController.refetchPRSidebar
const ensurePrDetails = prController.ensurePrSidebarDetails
// Refs so tab effects do not re-fire when headSha recreates load() (soft refresh).
const refetchPrRef = useRef(refetchPr)
refetchPrRef.current = refetchPr
const ensurePrDetailsRef = useRef(ensurePrDetails)
ensurePrDetailsRef.current = ensurePrDetails
// Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until
// the Pull Request segment is open — opening SC to stage must not pull details.
useEffect(() => {
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
refetchPr()
if (activeTab === 'pr') {
return
}
}, [prBranch, isHostedRepo, prSidebarKind, refetchPr])
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
void refetchPrRef.current({ includeDetails: false })
}
}, [activeTab, prBranch, isHostedRepo, prSidebarKind])
// Number of the ready PR whose phase-2 details are still missing (null when
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
const prDetailsMissingFor =
prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null
? prController.prSidebarState.data.pr.number
: null
// PR segment: full load or phase-2 fill-in. Body only mounts while active.
// Guarded (and keyed) on prBranch so a branch that arrives while the segment is
// already open — e.g. mounted on a detached HEAD, then checkout — still loads;
// kind stays 'hidden' through that transition, so kind alone can't re-fire this.
useEffect(() => {
if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
return
}
if (prSidebarKind === 'hidden') {
void refetchPrRef.current({ includeDetails: true })
return
}
if (prDetailsMissingFor != null) {
void ensurePrDetailsRef.current()
}
}, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
const prChip = useMemo(() => {
if (!isHostedRepo) {
@@ -147,21 +206,39 @@ export function MobileSourceControlPanel({
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
}, [isHostedRepo, prController.prSidebarState])
// Design: refresh the active segment's body work, plus git.status for the shared
// branch card (counts/sync stay honest even while on History). Preserve ready
// status on a failed refresh so PR chip identity is not wiped to hidden.
const onRefresh = useCallback(() => {
void loadStatus()
// Chip + PR segment share the controller; always refresh when hosted so the
// branch card stays in sync even while the Changes segment is active.
if (isHostedRepo) {
void refetchPr()
}
if (visitedTabs.has('history')) {
void loadStatus({ preserveReadyOnFailure: true })
if (activeTab === 'history') {
setHistoryRefreshNonce((n) => n + 1)
return
}
}, [isHostedRepo, loadStatus, refetchPr, visitedTabs])
if (!isHostedRepo) {
return
}
if (activeTab === 'pr') {
void refetchPr({ includeDetails: true })
return
}
// Changes: light chip refresh so the branch card stays current without comments.
void refetchPr({ includeDetails: false })
}, [activeTab, isHostedRepo, loadStatus, refetchPr])
// Embedded mode docks beside the terminal: close the dock instead of popping
// a route, and skip the full-screen safe-area chrome (the dock column owns it).
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
// Chromeless PR body has no panel header — surface open-on-web on the hub chrome
// while the Pull Request segment is active (same affordance as the old /pr route).
const prWebUrl =
activeTab === 'pr' &&
prController.prSidebarState.kind === 'ready' &&
prController.prSidebarState.data.pr.url
? prController.prSidebarState.data.pr.url
: null
const prWebNumber =
prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null
const header = (
<MobileSourceControlHeader
embedded={embedded}
@@ -169,6 +246,8 @@ export function MobileSourceControlPanel({
ioBusy={ioBusy}
onBack={onBack}
onRefresh={onRefresh}
onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined}
prNumber={prWebNumber}
/>
)
@@ -206,8 +285,14 @@ export function MobileSourceControlPanel({
// History only needs the RPC client — do not block it behind git.status.
// Changes/PR need status (branch, file list, head SHA), so they stay gated.
const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
const showHistory = activeTab === 'history' || visitedTabs.has('history')
const showPr = ready && (activeTab === 'pr' || visitedTabs.has('pr'))
// Why: only mount the PR body while its segment is active. Keep-mounting retained
// Mermaid WebViews and re-rendered the comment tree on every commit keystroke.
// Controller + chip state still live for instant re-open without a full cold start.
const showPrBody = ready && activeTab === 'pr'
const conflictOperation = status?.conflictOperation ?? null
const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
return (
<View ref={setRootRef} style={styles.container}>
@@ -228,24 +313,25 @@ export function MobileSourceControlPanel({
unstagedCount={unstagedCount}
stagedCount={stagedCount}
branchCount={branchEntries.length}
conflictOperation={status?.conflictOperation ?? null}
conflictOperation={conflictOperation}
conflictBusy={busyAction !== null}
conflictAborting={conflictAborting}
onAbortConflict={(operation) => void abortConflictOperation(operation)}
prChip={prChip}
onOpenPr={openPrTab}
/>
) : null}
{activeTab === 'changes' ? (
ready ? (
{showChanges ? (
<View style={activeTab === 'changes' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
<MobileSourceControlContent state={state} />
) : (
statusGate
)
</View>
) : activeTab === 'changes' ? (
statusGate
) : null}
{showPr ? (
<View style={activeTab === 'pr' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
{showPrBody ? (
<View style={hubStyles.tabBody}>
<MobilePrViewPanelBody
client={client}
connState={connState}
@@ -254,10 +340,10 @@ export function MobileSourceControlPanel({
headSha={prHeadSha}
gitStatus={status}
isGithubRepo={isHostedRepo}
branchContextLoaded={ready}
// Gate on the probe too: isGithubRepo=false mid-probe must render as
// loading, not flash "unavailable for this provider" (old /pr parity).
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
controller={prController}
chromeless
autoLoad={false}
/>
</View>
) : activeTab === 'pr' ? (
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
isMobileConflictAborting,
mobileConflictAbortLabel
} from './mobile-source-control-conflict-abort'
describe('isMobileConflictAborting', () => {
it('is true only for the matching abort action', () => {
expect(isMobileConflictAborting('abort-merge', 'merge')).toBe(true)
expect(isMobileConflictAborting('abort-rebase', 'rebase')).toBe(true)
})
it('is false for other busy actions (stage/commit must not look like abort)', () => {
expect(isMobileConflictAborting('stage-all', 'merge')).toBe(false)
expect(isMobileConflictAborting('commit', 'rebase')).toBe(false)
expect(isMobileConflictAborting(null, 'merge')).toBe(false)
expect(isMobileConflictAborting('abort-merge', 'rebase')).toBe(false)
expect(isMobileConflictAborting('abort-merge', 'unknown')).toBe(false)
})
})
describe('mobileConflictAbortLabel', () => {
it('shows Aborting only while abort is in flight', () => {
expect(mobileConflictAbortLabel('merge', true)).toBe('Aborting…')
expect(mobileConflictAbortLabel('merge', false)).toBe('Abort merge')
expect(mobileConflictAbortLabel('rebase', false)).toBe('Abort rebase')
})
})
@@ -0,0 +1,18 @@
// Pure helpers for the branch-card conflict Abort control. Kept free of React so
// the busy-label rule (abort-in-flight only) is unit-testable.
/** True while git.abortMerge / git.abortRebase is the active serial action. */
export function isMobileConflictAborting(
busyAction: string | null,
conflictOperation: string | null
): boolean {
if (conflictOperation !== 'merge' && conflictOperation !== 'rebase') {
return false
}
return busyAction === `abort-${conflictOperation}`
}
/** Label for the Abort control — never says "Aborting…" for unrelated busy work. */
export function mobileConflictAbortLabel(conflictOperation: string, aborting: boolean): string {
return aborting ? 'Aborting…' : `Abort ${conflictOperation}`
}