Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch";
import IconServer from "@tabler/icons-react-native/IconServer";
import IconSettings from "@tabler/icons-react-native/IconSettings";
import IconSparkles from "@tabler/icons-react-native/IconSparkles";
import IconStack2 from "@tabler/icons-react-native/IconStack2";
import IconSun from "@tabler/icons-react-native/IconSun";
import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
Expand All @@ -99,6 +100,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"arrow.right.circle": IconArrowRightCircle,
"arrow.triangle.branch": IconGitBranch,
"arrow.triangle.pull": IconGitPullRequest,
"square.3.layers.3d": IconStack2,
"arrow.turn.left.up": IconArrowBackUp,
"arrow.up": IconArrowUp,
"arrow.up.circle": IconArrowUpCircle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeThread(
interactionMode: "default",
branch: null,
worktreePath: null,
pullRequests: [],
latestTurn: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
interactionMode: "default",
branch: null,
worktreePath: null,
pullRequests: [],
latestTurn: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function makeThread(
interactionMode: "default",
branch: null,
worktreePath: null,
pullRequests: [],
latestTurn: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
Expand Down
59 changes: 58 additions & 1 deletion apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
getGitActionDisabledReason,
requiresDefaultBranchConfirmation,
} from "@t3tools/client-runtime/state/vcs";
import {
resolveThreadPullRequestChains,
threadPullRequestKeyOf,
} from "@t3tools/shared/threadPullRequests";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import {
CommonActions,
Expand Down Expand Up @@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
const isInspector = presentation === "inspector";
const environmentId = EnvironmentId.make(props.route.params.environmentId);
const threadId = ThreadId.make(props.route.params.threadId);
const { selectedThread } = useThreadSelection();
const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection();
const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
const supportsLinkedPrSnapshots =
selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true;
const linkedPrChains = useMemo(
() =>
resolveThreadPullRequestChains(
supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [],
),
[selectedThread?.pullRequests, supportsLinkedPrSnapshots],
);
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
const theme = useUniwindTheme();
Expand Down Expand Up @@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
/>
</View>

{linkedPrChains.length > 0 ? (
<View className="gap-2">
<Text className="px-1 text-xs font-t3-bold text-foreground-muted">
Linked pull requests
</Text>
{linkedPrChains.map((chain) => (
<View
key={threadPullRequestKeyOf(chain.layers[0]!)}
className="overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
>
{chain.layers.length > 1 ? (
<View className="flex-row items-center gap-2 px-1 pt-2 pb-1">
<SymbolView
name="square.3.layers.3d"
size={14}
tintColorClassName="accent-foreground-muted"
/>
<Text className="text-xs text-foreground-muted">
{chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs
· bottom to top
</Text>
</View>
) : null}
{chain.layers.map((link, index) => (
<View key={threadPullRequestKeyOf(link)}>
{index > 0 ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
icon="arrow.triangle.pull"
title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}`}
onPress={() => {
void tryOpenExternalUrl(link.url, "pull-request").then((opened) => {
if (!opened)
Alert.alert("Unable to open PR", "The pull request could not be opened.");
});
}}
/>
</View>
))}
</View>
))}
</View>
) : null}

{currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
</ScrollView>
);
Expand Down
34 changes: 25 additions & 9 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function pullRequestTintColor(
return dark ? "#34d399" : "#059669";
case "merged":
return dark ? "#a78bfa" : "#7c3aed";
case null:
case "closed":
return dark ? "#a1a1aa" : "#71717a";
}
Expand Down Expand Up @@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
</>
) : null}
{pr !== null ? (
<View className="flex-row items-center gap-0.5">
<PullRequestIcon
size={compact ? 13 : 11}
color={
visuallySelected
? String(effectiveSelectedForeground)
: pullRequestTintColor(pr, colorScheme)
}
/>
<View
className="flex-row items-center gap-0.5"
accessibilityLabel={pr.accessibilityLabel}
>
{pr.kind === "stack" ? (
<SymbolView
name="square.3.layers.3d"
size={compact ? 13 : 11}
tintColor={
visuallySelected
? String(effectiveSelectedForeground)
: pullRequestTintColor(pr, colorScheme)
}
/>
) : (
<PullRequestIcon
size={compact ? 13 : 11}
color={
visuallySelected
? String(effectiveSelectedForeground)
: pullRequestTintColor(pr, colorScheme)
}
/>
)}
<Text
className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
visuallySelected
Expand Down
43 changes: 29 additions & 14 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -860,20 +860,35 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
<View className="flex-1" />
)}
{pr ? (
<Text
accessibilityLabel={pr.accessibilityLabel}
className={cn(
"text-xs",
selected
? materialYouStyleLayoutActive
? "text-thread-selected-foreground"
: "text-user-bubble-foreground"
: pr.textClassName,
)}
style={{ fontFamily: MONO_FONT }}
>
#{pr.label}
</Text>
<View className="flex-row items-center gap-1" accessibilityLabel={pr.accessibilityLabel}>
{pr.kind === "stack" ? (
<SymbolView
name="square.3.layers.3d"
size={12}
tintColorClassName={
selected
? materialYouStyleLayoutActive
? "accent-thread-selected-foreground"
: "accent-user-bubble-foreground"
: "accent-foreground-muted"
}
/>
) : null}
<Text
accessibilityLabel={pr.accessibilityLabel}
className={cn(
"text-xs",
selected
? materialYouStyleLayoutActive
? "text-thread-selected-foreground"
: "text-user-bubble-foreground"
: pr.textClassName,
)}
style={{ fontFamily: MONO_FONT }}
>
{pr.kind === "stack" ? pr.label : `#${pr.label}`}
</Text>
</View>
) : null}
{props.providerInstance ? (
<ProviderInstanceIcon
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function makeThread(
interactionMode: "default",
branch: null,
worktreePath: null,
pullRequests: [],
latestTurn: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ function makeThread(
interactionMode: "default",
branch: null,
worktreePath: null,
pullRequests: [],
latestTurn: null,
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/pending-thread-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export function pendingThreadCreationShell(
runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,
interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
branch: creation.branch,
pullRequests: [],
worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,
linkedPullRequest: null,
latestTurn: null,
Expand Down
66 changes: 64 additions & 2 deletions apps/mobile/src/state/thread-pr-presentation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import type { VcsStatusResult } from "@t3tools/contracts";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type {
ExecutionEnvironmentCapabilities,
ThreadPullRequestLink,
VcsStatusResult,
} from "@t3tools/contracts";
import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";

import {
resolveThreadCurrentPullRequestLink,
resolveThreadPullRequestBadge,
} from "@t3tools/shared/threadPullRequests";

export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;

export interface ThreadPrPresentation {
readonly number: number;
readonly state: ThreadPr["state"];
readonly state: ThreadPr["state"] | null;
readonly kind: "pull-request" | "stack";
readonly isDraft: boolean;
/** Provider-side last activity, bounding when a terminal state landed. */
readonly updatedAt: string | null;
Expand All @@ -30,6 +41,7 @@ export function presentThreadPr(
const presentation = resolveChangeRequestPresentation(provider);
const isDraft = pr.state === "open" && pr.isDraft === true;
return {
kind: "pull-request",
number: pr.number,
state: pr.state,
isDraft,
Expand All @@ -40,3 +52,53 @@ export function presentThreadPr(
textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],
};
}

/** Persisted links render immediately, including links awaiting their first host sync. */
export function presentThreadLinkedPullRequests(
links: ReadonlyArray<ThreadPullRequestLink>,
): ThreadPrPresentation | null {
const link = resolveThreadCurrentPullRequestLink(links);
const badge = resolveThreadPullRequestBadge(links);
if (link === null || badge === null) return null;
const snapshot = link.snapshot;
const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null);
const isDraft = snapshot?.isDraft === true && state === "open";
const label =
badge.kind === "stack"
? String(badge.layers)
: `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`;
return {
kind: badge.kind,
number: link.number,
state,
isDraft,
updatedAt: snapshot?.updatedAt ?? null,
url: link.url,
label,
accessibilityLabel:
badge.kind === "stack"
? `${badge.layers} pull requests in stack, ${state ?? "status pending"}`
: `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`,
textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state],
};
}

/** Only the array capability replaces legacy references with persisted snapshots. */
export function resolveThreadPrSource(
thread: Pick<EnvironmentThreadShell, "pullRequests" | "linkedPullRequest" | "branchPullRequest">,
capabilities:
| Pick<ExecutionEnvironmentCapabilities, "threadPullRequests" | "threadPullRequestLinking">
| undefined,
) {
const supportsSnapshots = capabilities?.threadPullRequests === true;
const linkedPresentation = supportsSnapshots
? presentThreadLinkedPullRequests(thread.pullRequests)
: null;
const pullRequestRef =
linkedPresentation !== null
? null
: ((supportsSnapshots
? thread.branchPullRequest
: (thread.linkedPullRequest ?? thread.branchPullRequest)) ?? null);
return { linkedPresentation, pullRequestRef };
}
2 changes: 2 additions & 0 deletions apps/mobile/src/state/use-selected-thread-git-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() {
...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
// A pull request the action opens is linked to the thread it ran beside.
threadId: thread.id,
});
if (AsyncResult.isFailure(result)) {
return result;
Expand Down
Loading
Loading