Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d966f59
feat(mobile): group settled pull request threads
builderorin Oct 31, 2026
d4cf0a0
fix(mobile): hide menu indicators on disabled PR actions
builderorin Oct 31, 2026
93e7c0e
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Oct 31, 2026
9d8c0e4
Merge upstream main into feature/group-settled-pr-threads
builderorin Oct 31, 2026
f6a5bcb
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Oct 31, 2026
448cbba
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 1, 2026
7b0f271
Merge remote-tracking branch 'github/main' into takeover-27941
maria-rcks Nov 1, 2026
3cd48f0
fix(server): report automatically settled PR threads
maria-rcks Nov 1, 2026
26c227e
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 1, 2026
63cb6d3
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 1, 2026
7a88ffa
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 1, 2026
4af0219
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
dfbba7f
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
311fbd8
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
774239a
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
ce3e604
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
a4d3c7b
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
e45fa41
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
21e3d44
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
9237deb
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 2, 2026
2685b54
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
0dfd79c
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
36c3a32
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
63cb270
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
a4903a2
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
61d6c0d
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
1332373
Merge branch 'pingdotgg:main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
5c9adda
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 3, 2026
edde648
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 4, 2026
c7388b6
Merge branch 'main' into feature/group-settled-pr-threads
arifali123 Nov 5, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ export function PullRequestAgentRow(props: {
title={props.title ?? action.title}
icon="sparkles"
accessibilityLabel={props.title ?? action.title}
menu={agent.threads.length > 1}
menu={agent.chooseThread && !action.attributes.disabled}
onPress={
action.attributes.disabled || agent.threads.length > 1
? undefined
: () => agent.start(props.task)
action.attributes.disabled || agent.chooseThread ? undefined : () => agent.start(props.task)
}
/>
);
if (agent.threads.length <= 1 || action.attributes.disabled) return row;
if (!agent.chooseThread || action.attributes.disabled) return row;
return (
<Menu
title={props.title ?? action.title}
Expand Down
48 changes: 24 additions & 24 deletions apps/mobile/src/features/pull-requests/PullRequestControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Animated from "react-native-reanimated";
import { PR_ROW_LAYOUT } from "./pullRequestAnimations";
import { useNavigation } from "@react-navigation/native";
import type { PullRequestDetail } from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
import type { ReactNode } from "react";
import { Platform, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -128,7 +129,7 @@ export function PullRequestActionToolbar(props: {
).changeRequest;
const threadLabel = agent.threads.length ? "Continue thread" : `Work on ${shortLabel}`;
const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel;
const chooseThread = agent.threads.length > 1;
const chooseThread = agent.chooseThread;
const agentActions = agent.actions.filter((action) => action.task !== "ask");
const open = (mode: "review" | "comment") =>
navigation.navigate("PullRequestReview", { ...props.params, mode });
Expand Down Expand Up @@ -193,17 +194,6 @@ export function PullRequestActionToolbar(props: {
</View>
);
}
const nativeThreadItems = threadMenuItems.map((item) => (
<Toolbar.MenuAction
key={item.id}
icon={item.image}
subtitle={"subtitle" in item ? item.subtitle : undefined}
disabled={"attributes" in item && item.attributes.disabled}
onPress={() => agent.select(item.id)}
>
<Toolbar.Label>{item.title}</Toolbar.Label>
</Toolbar.MenuAction>
));
const primaryControl = showPrimary ? (
!pendingReview && chooseThread ? (
<Toolbar.Menu
Expand All @@ -222,7 +212,7 @@ export function PullRequestActionToolbar(props: {
disabled={primaryDisabled}
separateBackground
>
{nativeThreadItems}
{renderThreadMenuItems(threadMenuItems, agent.select)}
</Toolbar.Menu>
) : (
<Toolbar.Button
Expand Down Expand Up @@ -256,17 +246,7 @@ export function PullRequestActionToolbar(props: {
{agentActions.map((action) =>
chooseThread && !action.disabled ? (
<Toolbar.Menu key={action.task} title={action.title} icon={action.icon}>
{agent.threadActions(action.task).map((item) => (
<Toolbar.MenuAction
key={item.id}
icon={item.image}
subtitle={"subtitle" in item ? item.subtitle : undefined}
disabled={"attributes" in item && item.attributes.disabled}
onPress={() => agent.select(item.id)}
>
<Toolbar.Label>{item.title}</Toolbar.Label>
</Toolbar.MenuAction>
))}
{renderThreadMenuItems(agent.threadActions(action.task), agent.select)}
</Toolbar.Menu>
) : (
<Toolbar.MenuAction
Expand Down Expand Up @@ -308,3 +288,23 @@ export function PullRequestActionToolbar(props: {
</Toolbar>
);
}

function renderThreadMenuItems(items: MenuAction[], select: (id: string) => void): ReactNode {
return items.map((item) =>
item.subactions ? (
<Toolbar.Menu key={item.id} title={item.title} icon={item.image}>
{renderThreadMenuItems(item.subactions, select)}
</Toolbar.Menu>
) : (
<Toolbar.MenuAction
key={item.id}
icon={item.image}
subtitle={item.subtitle}
disabled={item.attributes?.disabled}
onPress={() => item.id && select(item.id)}
>
<Toolbar.Label>{item.title}</Toolbar.Label>
</Toolbar.MenuAction>
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
appendPullRequestAgentPrompt,
pullRequestAgentPrompt,
pullRequestAgentThread,
pullRequestAgentThreadActions,
} from "./pullRequestAgentTasks";
import { IsoDateTime, ProjectId, ThreadId } from "@t3tools/contracts";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
Expand All @@ -27,7 +28,30 @@ describe("PR agent tasks", () => {
archivedAt: IsoDateTime.make("2026-10-09T10:00:00.000Z"),
};
expect(pullRequestAgentThread([thread, archived])).toBeUndefined();
expect(pullRequestAgentThread([archived])).toBeUndefined();
const settled = { ...thread, settled: true };
expect(pullRequestAgentThread([settled])).toBeUndefined();
expect(pullRequestAgentThread([settled], settled.id)).toBe(settled);
expect(pullRequestAgentThread([thread, { ...settled, id: archived.id }])).toBeUndefined();
expect(pullRequestAgentThread([thread, archived], archived.id)).toBe(archived);
const actions = pullRequestAgentThreadActions([settled, archived], "ask", true);
expect(actions.map((action) => action.title)).toEqual([
"Start a new thread",
"Existing work",
"Settled threads",
]);
expect(actions[2]?.subactions).toMatchObject([
{ id: `ask:${settled.id}`, subtitle: "Settled thread" },
]);
expect(pullRequestAgentThreadActions([thread], "ask", false)).toMatchObject([
{ id: `ask:${thread.id}`, subtitle: "Continue thread" },
]);
expect(
pullRequestAgentThreadActions([settled], "feedback", false)[0]?.subactions,
).toMatchObject([{ id: `feedback:${settled.id}`, attributes: { disabled: false } }]);
expect(pullRequestAgentThreadActions([archived], "feedback", true)[1]).toMatchObject({
attributes: { disabled: true },
});
});
it("replaces the helper's own text when switching tasks, keeping user edits", () => {
const first = appendPullRequestAgentPrompt("My question", "explain", GITHUB);
Expand Down
35 changes: 34 additions & 1 deletion apps/mobile/src/features/pull-requests/pullRequestAgentTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
sourceControlClients,
type ChangeRequestTerminology,
} from "@t3tools/client-runtime/source-control-clients";
import type { MenuAction } from "@react-native-menu/menu";
import type { PullRequestDetail, PullRequestLinkedThreadsResult } from "@t3tools/contracts";

export type PullRequestAgentTask = "ask" | "explain" | "feedback" | "conflicts" | "watch";
Expand All @@ -11,7 +12,39 @@ export function pullRequestAgentThread(
threadId?: string,
) {
if (threadId) return threads.find((thread) => thread.id === threadId);
return threads.length === 1 ? threads[0] : undefined;
return threads.length === 1 && !threads[0]?.settled && !threads[0]?.archivedAt
? threads[0]
: undefined;
}

export function pullRequestAgentThreadActions(
threads: PullRequestLinkedThreadsResult["threads"],
task: PullRequestAgentTask,
canAsk: boolean,
): MenuAction[] {
const active: MenuAction[] = [];
const settled: MenuAction[] = [];
for (const thread of threads) {
const action = {
id: `${task}:${thread.id}`,
title: thread.title,
subtitle: thread.archivedAt
? "Archived thread"
: thread.settled
? "Settled thread"
: "Continue thread",
image: "bubble.left.and.bubble.right",
attributes: { disabled: task !== "ask" && Boolean(thread.archivedAt) },
};
(thread.settled ? settled : active).push(action);
}
return [
...(canAsk ? [{ id: `${task}:new`, title: "Start a new thread", image: "plus" }] : []),
...active,
...(settled.length
? [{ id: `${task}:settled`, title: "Settled threads", subactions: settled }]
: []),
];
}

export function pullRequestAgentPrompt(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Platform } from "react-native";
import type { PullRequestDetail } from "@t3tools/contracts";
import {
pullRequestAgentActions,
pullRequestAgentThread,
pullRequestAgentThreadActions,
type PullRequestAgentTask,
} from "./pullRequestAgentTasks";
import type { PullRequestParams } from "./usePullRequestDiff";
Expand All @@ -28,24 +30,30 @@ export function usePullRequestAgentActions(
if (!links.canAsk || actions.find((action) => action.task === task)?.disabled) return;
void links.ask(thread?.archivedAt ? undefined : thread, task);
};
const threadActions = (task: PullRequestAgentTask) => [
...links.threads.map((thread) => ({
id: `${task}:${thread.id}`,
title: thread.title,
subtitle: thread.archivedAt ? "Archived thread" : "Continue thread",
image: "bubble.left.and.bubble.right",
attributes: { disabled: task !== "ask" && Boolean(thread.archivedAt) },
})),
...(links.canAsk ? [{ id: `${task}:new`, title: "Start a new thread", image: "plus" }] : []),
];
const chooseThread =
links.threads.length > 1 || links.threads.some((thread) => thread.settled || thread.archivedAt);
const threadActions = (task: PullRequestAgentTask) =>
pullRequestAgentThreadActions(links.threads, task, links.canAsk);
const menuAction = (task: PullRequestAgentTask) => {
const action = actions.find((action) => action.task === task);
const items = threadActions(task);
// Android supports only one submenu level beneath each helper action.
const subactions =
Platform.OS === "android"
? items.flatMap(
(item) =>
item.subactions?.map((child) => ({
...child,
title: `Settled: ${child.title}`,
})) ?? [item],
)
: items;
return {
id: task,
title: action?.title ?? "Open thread",
image: action?.icon,
attributes: { disabled: disabled || !links.canAsk || action?.disabled },
...(links.threads.length > 1 ? { subactions: threadActions(task) } : {}),
...(chooseThread ? { subactions } : {}),
};
};
const select = (event: string) => {
Expand All @@ -55,5 +63,5 @@ export function usePullRequestAgentActions(
if (action)
start(action.task, event === action.task ? undefined : event.slice(action.task.length + 1));
};
return { ...links, actions, disabled, start, threadActions, menuAction, select };
return { ...links, actions, disabled, chooseThread, start, threadActions, menuAction, select };
}
36 changes: 33 additions & 3 deletions apps/server/src/pullRequest/linkedThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));

it.effect(
"finds active and archived threads for exactly one pull request, excluding deleted, dismissed, and side chat links",
"finds active, settled, and archived threads for exactly one pull request, excluding deleted, dismissed, and side chat links",
() =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
Expand Down Expand Up @@ -46,6 +46,14 @@ it.effect(
number: 7,
source: "manual",
},
{ id: "settled", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
{
id: "auto-settled",
host: "github.com",
repository: "acme/web",
number: 7,
source: "manual",
},
{ id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
{
id: "archived",
Expand Down Expand Up @@ -94,6 +102,8 @@ it.effect(
for (const fixture of fixtures) {
const payload = yield* encodePayload({
linkedPullRequest: null,
settledOverride: ["settled", "archived"].includes(fixture.id) ? "settled" : null,
settledAt: fixture.id === "auto-settled" ? archivedAt : null,
...(fixture.id === "side-chat" ? { lineage: { sideChat: true } } : {}),
pullRequests: [{ ...fixture, linkedAt: createdAt, snapshot: null, stack: null }],
});
Expand Down Expand Up @@ -165,8 +175,28 @@ it.effect(
});
expect(result).toEqual({
threads: [
{ id: "archived", projectId: "project-1", title: "archived", archivedAt },
{ id: "active", projectId: "project-1", title: "active", archivedAt: null },
{ id: "archived", projectId: "project-1", title: "archived", archivedAt, settled: true },
{
id: "active",
projectId: "project-1",
title: "active",
archivedAt: null,
settled: false,
},
{
id: "auto-settled",
projectId: "project-1",
title: "auto-settled",
archivedAt: null,
settled: true,
},
{
id: "settled",
projectId: "project-1",
title: "settled",
archivedAt: null,
settled: true,
},
],
});
assert.deepStrictEqual(
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/pullRequest/linkedThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre
projectId: string;
title: string;
archivedAt: string | null;
settled: number;
host: string;
repository: string;
number: number;
url: string;
}>`
SELECT t.thread_id AS id, t.project_id AS "projectId", t.title,
t.archived_at AS "archivedAt", json_extract(link.value, '$.host') AS host,
t.archived_at AS "archivedAt",
(json_extract(t.payload_json, '$.settledAt') IS NOT NULL
OR json_extract(t.payload_json, '$.settledOverride') IS 'settled') AS settled,
json_extract(link.value, '$.host') AS host,
json_extract(link.value, '$.repository') AS repository,
json_extract(link.value, '$.number') AS number,
json_extract(link.value, '$.url') AS url,
Expand All @@ -44,7 +48,13 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre
`.pipe(NodeSqliteClient.readOnly);
const threads = rows
.filter((row) => threadPullRequestKeysEqual(row, key))
.map(({ id, projectId, title, archivedAt }) => ({ id, projectId, title, archivedAt }));
.map(({ id, projectId, title, archivedAt, settled }) => ({
id,
projectId,
title,
archivedAt,
settled: settled === 1,
}));
return yield* decodeLinkedThreads({ threads });
},
Effect.mapError(
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/pullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({
projectId: ProjectId,
title: Schema.String,
archivedAt: Schema.NullOr(IsoDateTime),
settled: Schema.optionalKey(Schema.Boolean),
}),
),
});
Expand Down
Loading