diff --git a/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx index 0c4378671db90..a8e8e86176fc6 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx @@ -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 ( 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 }); @@ -193,17 +194,6 @@ export function PullRequestActionToolbar(props: { ); } - const nativeThreadItems = threadMenuItems.map((item) => ( - agent.select(item.id)} - > - {item.title} - - )); const primaryControl = showPrimary ? ( !pendingReview && chooseThread ? ( - {nativeThreadItems} + {renderThreadMenuItems(threadMenuItems, agent.select)} ) : ( chooseThread && !action.disabled ? ( - {agent.threadActions(action.task).map((item) => ( - agent.select(item.id)} - > - {item.title} - - ))} + {renderThreadMenuItems(agent.threadActions(action.task), agent.select)} ) : ( ); } + +function renderThreadMenuItems(items: MenuAction[], select: (id: string) => void): ReactNode { + return items.map((item) => + item.subactions ? ( + + {renderThreadMenuItems(item.subactions, select)} + + ) : ( + item.id && select(item.id)} + > + {item.title} + + ), + ); +} diff --git a/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.test.ts b/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.test.ts index 9173efd9fb7d7..ceaf675c3b689 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.test.ts @@ -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"; @@ -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); diff --git a/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.ts b/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.ts index d143a93e57e58..58e85f240043c 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestAgentTasks.ts @@ -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"; @@ -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( diff --git a/apps/mobile/src/features/pull-requests/usePullRequestAgentActions.ts b/apps/mobile/src/features/pull-requests/usePullRequestAgentActions.ts index 001e36c8ff6f2..43643aab2a0bb 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestAgentActions.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestAgentActions.ts @@ -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"; @@ -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) => { @@ -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 }; } diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts index 161cb05f8e9ea..8992a32ea10ca 100644 --- a/apps/server/src/pullRequest/linkedThreads.test.ts +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -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; @@ -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", @@ -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 }], }); @@ -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( diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index b140714531ad9..87ffae525eb9c 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -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, @@ -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( diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 64111ab4742b1..b91aaf34b9445 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -433,6 +433,7 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ projectId: ProjectId, title: Schema.String, archivedAt: Schema.NullOr(IsoDateTime), + settled: Schema.optionalKey(Schema.Boolean), }), ), });