Skip to content

Commit 8231f3b

Browse files
committed
feat(mobile): linked PRs own the thread-bar glass pill
When a thread has linked PRs, the bar shows #N (or +count) for a one-tap open/list; long-press still reaches Push, Commit, and the rest of Git.
1 parent 72a00c4 commit 8231f3b

8 files changed

Lines changed: 209 additions & 55 deletions

File tree

‎apps/mobile/src/components/ui/AppBar.android.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState })
170170
{confirm.showLabel || !confirm.icon ? (
171171
<Button
172172
label={confirm.label}
173+
icon={confirm.showLabel ? confirm.icon : undefined}
173174
accessibilityLabel={confirm.accessibilityLabel ?? confirm.label}
174175
tone="text"
175176
loading={confirm.loading}
@@ -190,6 +191,7 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState })
190191
confirm && !confirm.menu && (confirm.showLabel || !confirm.icon) ? (
191192
<Button
192193
label={confirm.label}
194+
icon={confirm.showLabel ? confirm.icon : undefined}
193195
accessibilityLabel={confirm.accessibilityLabel ?? confirm.label}
194196
tone="text"
195197
loading={confirm.loading}

‎apps/mobile/src/components/ui/AppBar.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ export function AppBar(props: AppBarProps) {
219219
// + menu, no chevron — the glass morphs into the menu on hold.
220220
<Toolbar.Button
221221
icon={
222-
props.confirm.showLabel
223-
? undefined
224-
: props.confirm.icon
225-
? iosIcon(props.confirm.icon)
222+
props.confirm.icon
223+
? iosIcon(props.confirm.icon)
224+
: props.confirm.showLabel
225+
? undefined
226226
: iosIcon(props.confirm.menu.icon)
227227
}
228228
label={props.confirm.showLabel ? props.confirm.label : undefined}

‎apps/mobile/src/components/ui/AppBar.types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface AppBarConfirm {
5656
/**
5757
* When true, shows `label` in the toolbar. Sheets keep the checkmark-only control by
5858
* leaving this unset. With `icon` and no label, both platforms use an icon button.
59+
* With both `showLabel` and `icon`, platforms that support it show the glyph beside the label
60+
* (e.g. PR glyph + linked count).
5961
*/
6062
readonly showLabel?: boolean;
6163
/** Custom glyph; without `showLabel`, replaces the default checkmark (iOS) / text (Android). */
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { expect, it } from "vite-plus/test";
2+
3+
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
4+
5+
import { linkedPrBarConfirm, shortGitBarLabel } from "./threadGitBarPrimary";
6+
import type { ThreadPrPresentation } from "../../state/thread-pr-presentation";
7+
8+
const changeRequest = sourceControlClients.get(undefined).changeRequest;
9+
10+
function pr(
11+
input: Partial<ThreadPrPresentation> & Pick<ThreadPrPresentation, "number">,
12+
): ThreadPrPresentation {
13+
return {
14+
kind: "pull-request",
15+
others: 0,
16+
state: "open",
17+
isDraft: false,
18+
updatedAt: null,
19+
url: `https://github.com/pingdotgg/t3code/pull/${input.number}`,
20+
changeRequest,
21+
label: String(input.number),
22+
accessibilityLabel: `#${input.number}`,
23+
textClassName: "text-adaptive-emerald-600-400",
24+
...input,
25+
};
26+
}
27+
28+
it("linkedPrBarConfirm shows the PR number for a single link", () => {
29+
expect(linkedPrBarConfirm(pr({ number: 32394 }))).toEqual({
30+
label: "#32394",
31+
icon: undefined,
32+
showLabel: true,
33+
accessibilityLabel: "#32394",
34+
});
35+
});
36+
37+
it("linkedPrBarConfirm shows a glyph and count when several are linked", () => {
38+
expect(
39+
linkedPrBarConfirm(
40+
pr({
41+
number: 12,
42+
others: 2,
43+
label: "+3",
44+
accessibilityLabel: "3 linked pull requests, overall open",
45+
}),
46+
),
47+
).toEqual({
48+
label: "+3",
49+
showLabel: true,
50+
accessibilityLabel: "3 linked pull requests, overall open",
51+
});
52+
});
53+
54+
it("linkedPrBarConfirm uses the stack depth label for stacked PRs", () => {
55+
expect(
56+
linkedPrBarConfirm(
57+
pr({
58+
number: 12,
59+
kind: "stack",
60+
label: "3",
61+
accessibilityLabel: "3 pull requests in stack, open",
62+
}),
63+
),
64+
).toMatchObject({
65+
label: "3",
66+
showLabel: true,
67+
});
68+
});
69+
70+
it("shortGitBarLabel uses the open PR number instead of View", () => {
71+
expect(
72+
shortGitBarLabel({ kind: "open_pr", label: "View PR", disabled: false }, "PR", 32394),
73+
).toEqual({ label: "#32394", disabled: false });
74+
});

‎apps/mobile/src/features/threads/ThreadGitControls.tsx‎

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useNavigation } from "@react-navigation/native";
1919
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2020

2121
import { useEnvironmentScope } from "../../state/session";
22+
import type { ThreadPrPresentation } from "../../state/thread-pr-presentation";
2223
import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs";
2324
import {
2425
basename,
@@ -27,6 +28,7 @@ import {
2728
type TerminalMenuSession,
2829
} from "../terminal/terminalMenu";
2930
import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
31+
import { linkedPrBarConfirm, shortGitBarLabel } from "./threadGitBarPrimary";
3032

3133
function truncateMiddle(value: string, maxLength: number): string {
3234
if (value.length <= maxLength) {
@@ -79,6 +81,9 @@ export type ThreadGitMenuProps = {
7981
readonly hasThread?: boolean;
8082
readonly gitStatus: VcsStatusResult | null;
8183
readonly gitOperationLabel: string | null;
84+
/** Linked / branch PR badge for the thread bar — wins over the git quick-action pill. */
85+
readonly linkedPr?: ThreadPrPresentation | null;
86+
readonly onOpenLinkedPr?: () => void;
8287
readonly onOpenFilesInspector?: () => void;
8388
readonly onOpenGitInspector?: () => void;
8489
readonly onMergeBack?: () => void;
@@ -368,42 +373,10 @@ function threadGitMenuFromModel(
368373
};
369374
}
370375

371-
/** Short pill copy for the thread-bar CTA — full quick-action label stays in a11y. */
372-
function shortGitBarLabel(
373-
quickAction: ReturnType<typeof useThreadGitControlModel>["quickAction"],
374-
shortLabel: string,
375-
): { readonly label: string; readonly disabled: boolean } | null {
376-
if (quickAction.kind === "open_pr") {
377-
return { label: "View", disabled: quickAction.disabled };
378-
}
379-
if (quickAction.kind === "run_pull") {
380-
return { label: "Pull", disabled: quickAction.disabled };
381-
}
382-
if (quickAction.kind === "run_action") {
383-
switch (quickAction.action) {
384-
case "create_pr":
385-
case "commit_push_pr":
386-
return { label: shortLabel, disabled: quickAction.disabled };
387-
case "push":
388-
return { label: "Push", disabled: quickAction.disabled };
389-
case "commit":
390-
return { label: "Commit", disabled: quickAction.disabled };
391-
case "commit_push":
392-
// Default-branch ahead uses action commit_push with label "Push".
393-
return {
394-
label: quickAction.label === "Push" ? "Push" : "Commit",
395-
disabled: quickAction.disabled,
396-
};
397-
default:
398-
return null;
399-
}
400-
}
401-
return null;
402-
}
403-
404376
/**
405377
* Thread-bar git controls: a compact one-tap pill for the next git step, plus the overflow
406-
* menu for everything else (review, more).
378+
* menu for everything else (review, more). A linked PR wins the pill so tap opens it (or the
379+
* multi-PR list) and long-press still reaches Push / Commit / … .
407380
*/
408381
export function useThreadGitBar(props: ThreadGitMenuProps): {
409382
readonly primaryAction: AppBarConfirm | null;
@@ -413,8 +386,21 @@ export function useThreadGitBar(props: ThreadGitMenuProps): {
413386
const terminology = sourceControlClients.get(
414387
props.gitStatus?.sourceControlProvider?.kind,
415388
).changeRequest;
416-
const short = shortGitBarLabel(model.quickAction, terminology.shortLabel);
417-
const primaryAction: AppBarConfirm | null = short
389+
const linkedPr = props.linkedPr ?? null;
390+
const linkedPrimary: AppBarConfirm | null =
391+
linkedPr && props.onOpenLinkedPr
392+
? {
393+
...linkedPrBarConfirm(linkedPr),
394+
disabled: false,
395+
onPress: props.onOpenLinkedPr,
396+
}
397+
: null;
398+
const short = shortGitBarLabel(
399+
model.quickAction,
400+
terminology.shortLabel,
401+
props.gitStatus?.pr?.state === "open" ? props.gitStatus.pr.number : null,
402+
);
403+
const gitPrimary: AppBarConfirm | null = short
418404
? {
419405
label: short.label,
420406
accessibilityLabel: model.quickAction.label,
@@ -425,10 +411,15 @@ export function useThreadGitBar(props: ThreadGitMenuProps): {
425411
},
426412
}
427413
: null;
414+
const primaryAction = linkedPrimary ?? gitPrimary;
415+
// Linked-PR pill must keep Push/Commit in the hold menu; only omit when the pill *is* that action.
416+
const omitQuickAction =
417+
primaryAction !== null &&
418+
!primaryAction.disabled &&
419+
(linkedPrimary !== null ? model.quickAction.kind === "open_pr" : true);
428420
return {
429421
primaryAction,
430-
// Keep the menu quick-action (and its disabled hint) when the pill is disabled.
431-
menu: threadGitMenuFromModel(props, model, primaryAction !== null && !primaryAction.disabled),
422+
menu: threadGitMenuFromModel(props, model, omitQuickAction),
432423
};
433424
}
434425

‎apps/mobile/src/features/threads/ThreadRouteScreen.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ import {
105105
threadRouteOpensDraft,
106106
} from "./thread-route-hydration";
107107
import { ThreadHeader } from "./ThreadHeader";
108+
import { useOpenThreadPullRequests } from "./use-open-thread-pull-requests";
108109
import { resumeThread } from "./resume-thread";
110+
import { useThreadPr } from "../../state/use-thread-pr";
109111
import {
110112
useAndroidWorkspaceBack,
111113
useWorkspaceBackNavigation,
@@ -777,6 +779,8 @@ function ThreadRouteContent(
777779
terminalId: targetTerminalId,
778780
});
779781
};
782+
const linkedPr = useThreadPr(selectedThread);
783+
const openThreadPullRequests = useOpenThreadPullRequests();
780784
const threadGitControlProps = {
781785
environmentId: environmentIdRaw ?? "",
782786
threadId: threadId ?? "",
@@ -789,6 +793,11 @@ function ThreadRouteContent(
789793
currentBranch: selectedThread?.branch ?? null,
790794
gitStatus: gitStatus.data,
791795
gitOperationLabel: gitState.gitOperationLabel,
796+
linkedPr,
797+
onOpenLinkedPr:
798+
selectedThread && linkedPr
799+
? () => openThreadPullRequests(selectedThread, linkedPr)
800+
: undefined,
792801
canOpenTerminal:
793802
Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
794803
canOperateTerminal,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { AppBarConfirm } from "../../components/ui/AppBar.types";
2+
import {
3+
resolveThreadPrBadgeAction,
4+
type ThreadPrPresentation,
5+
} from "../../state/thread-pr-presentation";
6+
7+
type GitQuickAction = {
8+
readonly kind: string;
9+
readonly label: string;
10+
readonly disabled: boolean;
11+
readonly action?: string;
12+
};
13+
14+
/** Short pill copy for the thread-bar CTA — full quick-action label stays in a11y. */
15+
export function shortGitBarLabel(
16+
quickAction: GitQuickAction,
17+
shortLabel: string,
18+
openPrNumber?: number | null,
19+
): { readonly label: string; readonly disabled: boolean } | null {
20+
if (quickAction.kind === "open_pr") {
21+
return {
22+
label: openPrNumber != null ? `#${openPrNumber}` : "View",
23+
disabled: quickAction.disabled,
24+
};
25+
}
26+
if (quickAction.kind === "run_pull") {
27+
return { label: "Pull", disabled: quickAction.disabled };
28+
}
29+
if (quickAction.kind === "run_action") {
30+
switch (quickAction.action) {
31+
case "create_pr":
32+
case "commit_push_pr":
33+
return { label: shortLabel, disabled: quickAction.disabled };
34+
case "push":
35+
return { label: "Push", disabled: quickAction.disabled };
36+
case "commit":
37+
return { label: "Commit", disabled: quickAction.disabled };
38+
case "commit_push":
39+
// Default-branch ahead uses action commit_push with label "Push".
40+
return {
41+
label: quickAction.label === "Push" ? "Push" : "Commit",
42+
disabled: quickAction.disabled,
43+
};
44+
default:
45+
return null;
46+
}
47+
}
48+
return null;
49+
}
50+
51+
/** Linked-PR bar confirm: number for one; count label when several. */
52+
export function linkedPrBarConfirm(
53+
pr: ThreadPrPresentation,
54+
): Pick<AppBarConfirm, "label" | "icon" | "showLabel" | "accessibilityLabel"> {
55+
const multi = resolveThreadPrBadgeAction(pr).kind === "list";
56+
return {
57+
// Liquid Glass Toolbar.Button drops the title when an icon is set, so multi stays label-only
58+
// (`+3` / stack depth) — same Edit-style glass text as a single `#32394`.
59+
label: multi ? pr.label : `#${pr.number}`,
60+
showLabel: true,
61+
accessibilityLabel: pr.accessibilityLabel,
62+
};
63+
}

‎apps/mobile/src/state/use-thread-pr.ts‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import {
2525

2626
const MAX_PULL_REQUEST_SNAPSHOTS = 500;
2727

28+
const noEnvironmentConfigAtom = Atom.make(null).pipe(
29+
Atom.keepAlive,
30+
Atom.withLabel("mobile:thread-pr-no-environment"),
31+
);
32+
2833
// One bounded cache survives row virtualization (the summary query is dropped once idle) without
2934
// retaining one live atom for every pull request ever seen.
3035
const pullRequestSnapshotsAtom = Atom.make<ReadonlyMap<string, PullRequestSummary>>(new Map()).pipe(
@@ -77,31 +82,39 @@ export function withKnownPullRequestSummary<
7782
return initialSummary === undefined ? params : { ...params, initialSummary };
7883
}
7984

80-
export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {
85+
export function useThreadPr(thread: EnvironmentThreadShell | null): ThreadPrPresentation | null {
8186
const demand = use(ThreadPrDemandContext);
87+
const environmentId = thread?.environmentId ?? null;
8288
const supportsLinks = useAtomValue(
83-
serverEnvironment.configValueAtom(thread.environmentId),
89+
environmentId === null
90+
? noEnvironmentConfigAtom
91+
: serverEnvironment.configValueAtom(environmentId),
8492
(config) => config !== null,
8593
);
8694
const { linkedPresentation, pullRequestRef } = useMemo(
8795
() =>
88-
resolveThreadPrSource(
89-
{
90-
pullRequests: thread.pullRequests,
91-
linkedPullRequest: thread.linkedPullRequest,
92-
branchPullRequest: thread.branchPullRequest,
93-
},
94-
supportsLinks,
95-
),
96-
[thread.pullRequests, thread.linkedPullRequest, thread.branchPullRequest, supportsLinks],
96+
thread === null
97+
? { linkedPresentation: null, pullRequestRef: null }
98+
: resolveThreadPrSource(
99+
{
100+
pullRequests: thread.pullRequests,
101+
linkedPullRequest: thread.linkedPullRequest,
102+
branchPullRequest: thread.branchPullRequest,
103+
},
104+
supportsLinks,
105+
),
106+
[thread, supportsLinks],
97107
);
98-
const key = pullRequestRef === null ? null : snapshotKey(thread.environmentId, pullRequestRef);
108+
const key =
109+
thread === null || pullRequestRef === null
110+
? null
111+
: snapshotKey(thread.environmentId, pullRequestRef);
99112
// Select this row's entry so answers for other pull requests do not re-render it.
100113
const snapshot = useAtomValue(pullRequestSnapshotsAtom, (current) =>
101114
key === null ? undefined : current.get(key),
102115
);
103116
const pullRequestSummary = useEnvironmentQuery(
104-
pullRequestRef === null || !demand
117+
thread === null || pullRequestRef === null || !demand
105118
? null
106119
: pullRequestSummaryAtom({
107120
environmentId: thread.environmentId,

0 commit comments

Comments
 (0)