authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 21:51:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 21:51:56-07:00
log3c75eb1132bb5d67cfa95ac6271ef68959f986c1
tree95e4cd1ca35dff1a87b0530d755eca65baf7fa80
parent63eb0429f49fad5f8722407ea971e3199e6c4514
signature Signed by PGP key B5690EEEBB952194

feat: link pull requests to threads (#8160)


39 files changed, 1039 insertions(+), 63 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+5-4
......@@ -12,7 +12,6 @@ import {
1212 type EnvironmentThreadSearchMatch,
1313} from "@t3tools/client-runtime/state/thread-search";
1414import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
1615import type {
1716 EnvironmentId,
1817 SidebarProjectGroupingMode,
......@@ -54,6 +53,7 @@ import {
5453 buildThreadListV2ListItems,
5554 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
5655 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
56 type ThreadListV2ChangeRequestState,
5757 type ThreadListV2ListItem,
5858} from "../threads/threadListV2";
5959import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
......@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
491491 // PR states stream in per-row. The next partition applies the configured
492492 // merge rule and the always-on close rule, matching web.
493493 const [changeRequestByKey, setChangeRequestByKey] = useState<
494 ReadonlyMap<string, ChangeRequestSettleSource>
494 ReadonlyMap<string, ThreadListV2ChangeRequestState>
495495 >(() => new Map());
496496 const handleChangeRequestState = useCallback(
497 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
497 (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
498498 setChangeRequestByKey((current) => {
499499 const existing = current.get(threadKey) ?? null;
500500 if (
501501 (existing?.state ?? null) === (changeRequest?.state ?? null) &&
502 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
502 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
503 (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
503504 ) {
504505 return current;
505506 }
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+5-4
......@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
1212import { AsyncResult } from "effect/unstable/reactivity";
1313import type { EnvironmentId } from "@t3tools/contracts";
1414import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
1615import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1716import type { LayoutChangeEvent } from "react-native";
1817import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
......@@ -84,6 +83,7 @@ import {
8483 buildThreadListV2ListItems,
8584 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
8685 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
86 type ThreadListV2ChangeRequestState,
8787 type ThreadListV2ListItem,
8888} from "./threadListV2";
8989
......@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
377377 // PR states stream in per-row. The next partition applies the configured
378378 // merge rule and the always-on close rule.
379379 const [changeRequestByKey, setChangeRequestByKey] = useState<
380 ReadonlyMap<string, ChangeRequestSettleSource>
380 ReadonlyMap<string, ThreadListV2ChangeRequestState>
381381 >(() => new Map());
382382 const handleChangeRequestState = useCallback(
383 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {
383 (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
384384 setChangeRequestByKey((current) => {
385385 const existing = current.get(threadKey) ?? null;
386386 if (
387387 (existing?.state ?? null) === (changeRequest?.state ?? null) &&
388 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null)
388 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
389 (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
389390 ) {
390391 return current;
391392 }
apps/mobile/src/features/threads/thread-list-v2-items.tsx+12-11
......@@ -3,11 +3,7 @@ import type {
33 EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
55import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import {
7 canSnooze,
8 resolveSnoozePresets,
9 type ChangeRequestSettleSource,
10} from "@t3tools/client-runtime/state/thread-settled";
6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
117import type { MenuAction } from "@react-native-menu/menu";
128import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
139import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
......@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
2723import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2824import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
2925import {
26 resolveThreadListV2ChangeRequestState,
3027 resolveThreadListV2SnoozeMenuSelection,
3128 resolveThreadListV2SnoozeGateExpiryMs,
3229 resolveThreadListV2Status,
3330 resolveThreadListV2SwipeActions,
31 type ThreadListV2ChangeRequestState,
3432 type ThreadListV2Status,
3533} from "./threadListV2";
3634import { ThreadSearchMatchExcerpt } from "./thread-search-match";
......@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
377375 merge and close rules. Mirrors web's onChangeRequestState. */
378376 readonly onChangeRequestState?: (
379377 threadKey: string,
380 changeRequest: ChangeRequestSettleSource | null,
378 changeRequest: ThreadListV2ChangeRequestState | null,
381379 ) => void;
382380 readonly projectCwd?: string | null;
383381 readonly searchMatch?: EnvironmentThreadSearchMatch;
......@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
411409 const prUpdatedAt = pr?.updatedAt ?? null;
412410 const threadKey = `${thread.environmentId}:${thread.id}`;
413411 useEffect(() => {
414 onChangeRequestState?.(
415 threadKey,
416 prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
417 );
418 }, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
412 const changeRequest = resolveThreadListV2ChangeRequestState({
413 linkedPullRequest: thread.linkedPullRequest,
414 state: prState,
415 updatedAt: prUpdatedAt,
416 });
417 if (changeRequest === undefined) return;
418 onChangeRequestState?.(threadKey, changeRequest);
419 }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);
419420
420421 const screenColor = useThemeColor("--color-screen");
421422 const drawerColor = useThemeColor("--color-drawer");
apps/mobile/src/features/threads/threadListV2.test.ts+95
......@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1616import {
1717 buildThreadListV2Items,
1818 buildThreadListV2ListItems,
19 resolveThreadListV2ChangeRequestState,
1920 resolveThreadListV2Enabled,
2021 resolveThreadListV2SnoozeMenuSelection,
2122 resolveThreadListV2SnoozeGateExpiryMs,
......@@ -53,6 +54,48 @@ function makeThread(
5354}
5455
5556const NOW = "2026-06-02T00:00:00.000Z";
57const linkedPullRequest = {
58 projectId: ProjectId.make("project-1"),
59 repository: "pingdotgg/t3code",
60 number: 42,
61 url: "https://github.com/pingdotgg/t3code/pull/42",
62};
63
64describe("resolveThreadListV2ChangeRequestState", () => {
65 it("preserves the previous state while a linked pull request reloads", () => {
66 expect(
67 resolveThreadListV2ChangeRequestState({
68 linkedPullRequest,
69 state: null,
70 updatedAt: null,
71 }),
72 ).toBeUndefined();
73 });
74
75 it("clears the previous state after a pull request is unlinked", () => {
76 expect(
77 resolveThreadListV2ChangeRequestState({
78 linkedPullRequest: null,
79 state: null,
80 updatedAt: null,
81 }),
82 ).toBeNull();
83 });
84
85 it("reports a loaded linked pull request", () => {
86 expect(
87 resolveThreadListV2ChangeRequestState({
88 linkedPullRequest,
89 state: "merged",
90 updatedAt: "2026-06-02T00:00:00.000Z",
91 }),
92 ).toEqual({
93 state: "merged",
94 updatedAt: "2026-06-02T00:00:00.000Z",
95 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
96 });
97 });
98});
5699
57100describe("resolveThreadListV2SnoozeMenuSelection", () => {
58101 it("accepts a displayed evening preset while its wake time is still future", () => {
......@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
263306});
264307
265308describe("buildThreadListV2Items", () => {
309 it("ignores the previous pull request state after a different pull request is linked", () => {
310 const thread = makeThread({
311 id: ThreadId.make("linked"),
312 title: "Linked pull request",
313 linkedPullRequest,
314 });
315 const layout = buildThreadListV2Items({
316 threads: [thread],
317 environmentId: null,
318 searchQuery: "",
319 changeRequestByKey: new Map([
320 [
321 `${environmentId}:${thread.id}`,
322 {
323 state: "merged" as const,
324 linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
325 },
326 ],
327 ]),
328 now: NOW,
329 });
330
331 expect(layout.settledCount).toBe(0);
332 expect(layout.items[0]?.variant).toBe("card");
333 });
334
335 it("settles a thread only when the cached pull request identity matches", () => {
336 const thread = makeThread({
337 id: ThreadId.make("linked-merged"),
338 title: "Linked merged pull request",
339 linkedPullRequest,
340 });
341 const layout = buildThreadListV2Items({
342 threads: [thread],
343 environmentId: null,
344 searchQuery: "",
345 changeRequestByKey: new Map([
346 [
347 `${environmentId}:${thread.id}`,
348 {
349 state: "merged" as const,
350 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
351 },
352 ],
353 ]),
354 now: NOW,
355 });
356
357 expect(layout.settledCount).toBe(1);
358 expect(layout.items[0]?.variant).toBe("slim");
359 });
360
266361 it("keeps a merged thread active when auto-settle on merge is off", () => {
267362 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
268363 const layout = buildThreadListV2Items({
apps/mobile/src/features/threads/threadListV2.ts+38-3
......@@ -13,7 +13,7 @@ import type {
1313import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1414import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1515import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
16import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
16import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
1717
1818import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1919
......@@ -30,6 +30,35 @@ export { snoozeWakeLabel };
3030export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
3131export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
3232
33export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource {
34 readonly linkedPullRequestKey?: string | null;
35}
36
37function linkedPullRequestKey(
38 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
39): string | null {
40 if (linkedPullRequest == null) return null;
41 return JSON.stringify([
42 linkedPullRequest.projectId,
43 linkedPullRequest.repository.toLowerCase(),
44 linkedPullRequest.number,
45 ]);
46}
47
48/** Keep the previous linked PR state while its detail query reloads. */
49export function resolveThreadListV2ChangeRequestState(input: {
50 readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined;
51 readonly state: ChangeRequestSettleSource["state"] | null;
52 readonly updatedAt: string | null;
53}): ThreadListV2ChangeRequestState | null | undefined {
54 if (input.state === null) return input.linkedPullRequest == null ? null : undefined;
55 return {
56 state: input.state,
57 updatedAt: input.updatedAt,
58 linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest),
59 };
60}
61
3362export function resolveThreadListV2SnoozeMenuSelection(input: {
3463 readonly event: string;
3564 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
......@@ -322,7 +351,7 @@ export function buildThreadListV2Items(input: {
322351 readonly searchQuery: string;
323352 readonly matchedThreadKeys?: ReadonlySet<string>;
324353 /** Per-row PR reported up by visible rows ("env:threadId" keys). */
325 readonly changeRequestByKey?: ReadonlyMap<string, ChangeRequestSettleSource>;
354 readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>;
326355 /** Environments whose server supports thread.settle/unsettle. Threads on
327356 other environments never classify as settled — the user could neither
328357 un-settle nor pin them. Absent = no gating (tests). */
......@@ -384,8 +413,14 @@ export function buildThreadListV2Items(input: {
384413 }
385414 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
386415 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
387 const changeRequest =
416 const cachedChangeRequest =
388417 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
418 const changeRequest =
419 cachedChangeRequest !== null &&
420 (cachedChangeRequest.linkedPullRequestKey ?? null) ===
421 linkedPullRequestKey(thread.linkedPullRequest)
422 ? cachedChangeRequest
423 : null;
389424 // Snooze outranks settlement and pinning until the thread wakes.
390425 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
391426 snoozed.push(thread);
apps/mobile/src/state/use-thread-pr.ts+31-1
......@@ -1,9 +1,16 @@
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 createLinkedPullRequestDetailAtomFamily,
4 pullRequestDetailToVcsStatus,
5} from "@t3tools/client-runtime/state/pull-requests";
26
7import { connectionAtomRuntime } from "../connection/runtime";
38import { useEnvironmentQuery } from "./query";
49import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
510import { vcsEnvironment } from "./vcs";
611
12const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);
13
714export {
815 presentThreadPr,
916 type ThreadPr,
......@@ -22,13 +29,36 @@ export function useThreadPr(
2229): ThreadPrPresentation | null {
2330 const cwd = thread.worktreePath ?? projectCwd;
2431 const gitStatus = useEnvironmentQuery(
25 thread.branch !== null && cwd !== null
32 thread.linkedPullRequest == null && thread.branch !== null && cwd !== null
2633 ? vcsEnvironment.status({
2734 environmentId: thread.environmentId,
2835 input: { cwd },
2936 })
3037 : null,
3138 );
39 const linkedPullRequest = useEnvironmentQuery(
40 thread.linkedPullRequest == null
41 ? null
42 : linkedPullRequestDetailAtom({
43 environmentId: thread.environmentId,
44 input: {
45 projectId: thread.linkedPullRequest.projectId,
46 repository: thread.linkedPullRequest.repository,
47 number: thread.linkedPullRequest.number,
48 },
49 }),
50 );
51
52 if (thread.linkedPullRequest != null) {
53 const detail = linkedPullRequest.data;
54 return detail === null
55 ? null
56 : presentThreadPr(pullRequestDetailToVcsStatus(detail), {
57 kind: detail.provider,
58 name: detail.provider,
59 baseUrl: "",
60 });
61 }
3262
3363 const status = gitStatus.data;
3464 if (status === null || thread.branch === null || status.refName !== thread.branch) {
apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -93,6 +93,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
9393 expect(second.capabilities.attachmentUploads).toBe(true);
9494 expect(second.capabilities.pullRequests).toBe(true);
9595 expect(second.capabilities.threadTitleRegeneration).toBe(true);
96 expect(second.capabilities.threadPullRequestLinking).toBe(true);
9697 expect(second.capabilities.agentActivityPublishing).toBe(false);
9798 }),
9899 );
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -153,6 +153,7 @@ export const make = Effect.gen(function* () {
153153 threadPinning: true,
154154 threadPinReorder: true,
155155 threadTitleRegeneration: true,
156 threadPullRequestLinking: true,
156157 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
157158 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
158159 },
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
......@@ -634,6 +634,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
634634 interactionMode: event.payload.interactionMode,
635635 branch: event.payload.branch,
636636 worktreePath: event.payload.worktreePath,
637 linkedPullRequest: null,
637638 latestTurnId: null,
638639 createdAt: event.payload.createdAt,
639640 updatedAt: event.payload.updatedAt,
......@@ -822,6 +823,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
822823 ...(event.payload.worktreePath !== undefined
823824 ? { worktreePath: event.payload.worktreePath }
824825 : {}),
826 ...(event.payload.linkedPullRequest !== undefined
827 ? { linkedPullRequest: event.payload.linkedPullRequest }
828 : {}),
825829 updatedAt: event.payload.updatedAt,
826830 });
827831 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+14
......@@ -82,6 +82,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
8282 interaction_mode,
8383 branch,
8484 worktree_path,
85 linked_pull_request_json,
8586 latest_turn_id,
8687 latest_user_message_at,
8788 pending_approval_count,
......@@ -102,6 +103,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
102103 'default',
103104 NULL,
104105 NULL,
106 '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',
105107 'turn-1',
106108 '2026-02-24T00:00:04.000Z',
107109 1,
......@@ -304,6 +306,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
304306 runtimeMode: "full-access",
305307 branch: null,
306308 worktreePath: null,
309 linkedPullRequest: {
310 projectId: asProjectId("project-1"),
311 repository: "pingdotgg/t3code",
312 number: 42,
313 url: "https://github.com/pingdotgg/t3code/pull/42",
314 },
307315 latestTurn: {
308316 turnId: asTurnId("turn-1"),
309317 state: "completed",
......@@ -423,6 +431,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
423431 runtimeMode: "full-access",
424432 branch: null,
425433 worktreePath: null,
434 linkedPullRequest: {
435 projectId: asProjectId("project-1"),
436 repository: "pingdotgg/t3code",
437 number: 42,
438 url: "https://github.com/pingdotgg/t3code/pull/42",
439 },
426440 latestTurn: {
427441 turnId: asTurnId("turn-1"),
428442 state: "completed",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+24
......@@ -24,6 +24,7 @@ import {
2424 type OrchestrationThreadShell,
2525 ModelSelection,
2626 ProjectId,
27 ThreadLinkedPullRequest,
2728 ThreadId,
2829} from "@t3tools/contracts";
2930import * as Arr from "effect/Array";
......@@ -89,6 +90,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
8990const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
9091 Struct.assign({
9192 modelSelection: Schema.fromJsonString(ModelSelection),
93 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
9294 }),
9395);
9496const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
......@@ -422,6 +424,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
422424 interaction_mode AS "interactionMode",
423425 branch,
424426 worktree_path AS "worktreePath",
427 linked_pull_request_json AS "linkedPullRequest",
425428 latest_turn_id AS "latestTurnId",
426429 created_at AS "createdAt",
427430 updated_at AS "updatedAt",
......@@ -458,6 +461,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
458461 interaction_mode AS "interactionMode",
459462 branch,
460463 worktree_path AS "worktreePath",
464 linked_pull_request_json AS "linkedPullRequest",
461465 latest_turn_id AS "latestTurnId",
462466 created_at AS "createdAt",
463467 updated_at AS "updatedAt",
......@@ -496,6 +500,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
496500 interaction_mode AS "interactionMode",
497501 branch,
498502 worktree_path AS "worktreePath",
503 linked_pull_request_json AS "linkedPullRequest",
499504 latest_turn_id AS "latestTurnId",
500505 created_at AS "createdAt",
501506 updated_at AS "updatedAt",
......@@ -938,6 +943,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
938943 interaction_mode AS "interactionMode",
939944 branch,
940945 worktree_path AS "worktreePath",
946 linked_pull_request_json AS "linkedPullRequest",
941947 latest_turn_id AS "latestTurnId",
942948 created_at AS "createdAt",
943949 updated_at AS "updatedAt",
......@@ -1694,6 +1700,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16941700 interactionMode: row.interactionMode,
16951701 branch: row.branch,
16961702 worktreePath: row.worktreePath,
1703 ...(row.linkedPullRequest === null
1704 ? {}
1705 : { linkedPullRequest: row.linkedPullRequest }),
16971706 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
16981707 createdAt: row.createdAt,
16991708 updatedAt: row.updatedAt,
......@@ -1901,6 +1910,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19011910 interactionMode: row.interactionMode,
19021911 branch: row.branch,
19031912 worktreePath: row.worktreePath,
1913 ...(row.linkedPullRequest === null
1914 ? {}
1915 : { linkedPullRequest: row.linkedPullRequest }),
19041916 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
19051917 createdAt: row.createdAt,
19061918 updatedAt: row.updatedAt,
......@@ -2037,6 +2049,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20372049 interactionMode: row.interactionMode,
20382050 branch: row.branch,
20392051 worktreePath: row.worktreePath,
2052 ...(row.linkedPullRequest === null
2053 ? {}
2054 : { linkedPullRequest: row.linkedPullRequest }),
20402055 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
20412056 createdAt: row.createdAt,
20422057 updatedAt: row.updatedAt,
......@@ -2182,6 +2197,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
21822197 interactionMode: row.interactionMode,
21832198 branch: row.branch,
21842199 worktreePath: row.worktreePath,
2200 ...(row.linkedPullRequest === null
2201 ? {}
2202 : { linkedPullRequest: row.linkedPullRequest }),
21852203 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
21862204 createdAt: row.createdAt,
21872205 updatedAt: row.updatedAt,
......@@ -2461,6 +2479,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
24612479 interactionMode: threadRow.value.interactionMode,
24622480 branch: threadRow.value.branch,
24632481 worktreePath: threadRow.value.worktreePath,
2482 ...(threadRow.value.linkedPullRequest === null
2483 ? {}
2484 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
24642485 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
24652486 createdAt: threadRow.value.createdAt,
24662487 updatedAt: threadRow.value.updatedAt,
......@@ -2602,6 +2623,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
26022623 interactionMode: threadRow.value.interactionMode,
26032624 branch: threadRow.value.branch,
26042625 worktreePath: threadRow.value.worktreePath,
2626 ...(threadRow.value.linkedPullRequest === null
2627 ? {}
2628 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
26052629 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
26062630 createdAt: threadRow.value.createdAt,
26072631 updatedAt: threadRow.value.updatedAt,
apps/server/src/orchestration/decider.ts+3
......@@ -847,6 +847,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
847847 : {}),
848848 ...(branch !== undefined ? { branch } : {}),
849849 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
850 ...(command.linkedPullRequest !== undefined
851 ? { linkedPullRequest: command.linkedPullRequest }
852 : {}),
850853 updatedAt: occurredAt,
851854 },
852855 };
apps/server/src/orchestration/projector.ts+3
......@@ -456,6 +456,9 @@ export function projectEvent(
456456 : {}),
457457 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
458458 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
459 ...(payload.linkedPullRequest !== undefined
460 ? { linkedPullRequest: payload.linkedPullRequest }
461 : {}),
459462 updatedAt: payload.updatedAt,
460463 }),
461464 })),
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+51
......@@ -201,4 +201,55 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
201201 assert.strictEqual(updated?.pinnedAt, null);
202202 }),
203203 );
204
205 it.effect("round-trips a linked pull request through the thread row", () =>
206 Effect.gen(function* () {
207 const threads = yield* ProjectionThreadRepository;
208 const linkedPullRequest = {
209 projectId: ProjectId.make("project-linked-pr"),
210 repository: "pingdotgg/t3code",
211 number: 42,
212 url: "https://github.com/pingdotgg/t3code/pull/42",
213 };
214
215 yield* threads.upsert({
216 threadId: ThreadId.make("thread-linked-pr"),
217 projectId: ProjectId.make("project-linked-pr"),
218 title: "Linked pull request",
219 modelSelection: {
220 instanceId: ProviderInstanceId.make("codex"),
221 model: "gpt-5.4",
222 },
223 runtimeMode: "full-access",
224 interactionMode: "default",
225 branch: null,
226 worktreePath: null,
227 linkedPullRequest,
228 latestTurnId: null,
229 createdAt: "2026-03-24T00:00:00.000Z",
230 updatedAt: "2026-03-24T00:00:00.000Z",
231 archivedAt: null,
232 settledOverride: null,
233 settledAt: null,
234 snoozedUntil: null,
235 snoozedAt: null,
236 pinnedAt: null,
237 latestUserMessageAt: null,
238 pendingApprovalCount: 0,
239 pendingUserInputCount: 0,
240 hasActionableProposedPlan: 0,
241 deletedAt: null,
242 });
243
244 const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });
245 assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);
246
247 const row = Option.getOrNull(persisted);
248 if (row === null) return yield* Effect.die("Expected linked thread row to exist.");
249 yield* threads.upsert({ ...row, linkedPullRequest: null });
250
251 const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });
252 assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null);
253 }),
254 );
204255});
apps/server/src/persistence/Layers/ProjectionThreads.ts+7-1
......@@ -14,11 +14,12 @@ import {
1414 ProjectionThreadRepository,
1515 type ProjectionThreadRepositoryShape,
1616} from "../Services/ProjectionThreads.ts";
17import { ModelSelection } from "@t3tools/contracts";
17import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts";
1818
1919const ProjectionThreadDbRow = ProjectionThread.mapFields(
2020 Struct.assign({
2121 modelSelection: Schema.fromJsonString(ModelSelection),
22 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
2223 }),
2324);
2425type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
......@@ -39,6 +40,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
3940 interaction_mode,
4041 branch,
4142 worktree_path,
43 linked_pull_request_json,
4244 latest_turn_id,
4345 created_at,
4446 updated_at,
......@@ -66,6 +68,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
6668 ${row.interactionMode},
6769 ${row.branch},
6870 ${row.worktreePath},
71 ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)},
6972 ${row.latestTurnId},
7073 ${row.createdAt},
7174 ${row.updatedAt},
......@@ -93,6 +96,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
9396 interaction_mode = excluded.interaction_mode,
9497 branch = excluded.branch,
9598 worktree_path = excluded.worktree_path,
99 linked_pull_request_json = excluded.linked_pull_request_json,
96100 latest_turn_id = excluded.latest_turn_id,
97101 created_at = excluded.created_at,
98102 updated_at = excluded.updated_at,
......@@ -127,6 +131,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
127131 interaction_mode AS "interactionMode",
128132 branch,
129133 worktree_path AS "worktreePath",
134 linked_pull_request_json AS "linkedPullRequest",
130135 latest_turn_id AS "latestTurnId",
131136 created_at AS "createdAt",
132137 updated_at AS "updatedAt",
......@@ -163,6 +168,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
163168 interaction_mode AS "interactionMode",
164169 branch,
165170 worktree_path AS "worktreePath",
171 linked_pull_request_json AS "linkedPullRequest",
166172 latest_turn_id AS "latestTurnId",
167173 created_at AS "createdAt",
168174 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
5454import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
5555import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
5656import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
57import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
5758
5859/**
5960 * Migration loader with all migrations defined inline.
......@@ -107,6 +108,7 @@ export const migrationEntries = [
107108 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
108109 [40, "ProjectionProjectFaviconPath", Migration0040],
109110 [41, "AuthSessionClientConnection", Migration0041],
111 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
110112] as const;
111113
112114export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts created+25
......@@ -0,0 +1,25 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("042_ProjectionThreadLinkedPullRequest", (it) => {
12 it.effect("adds the linked pull request column", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 41 });
17 yield* runMigrations({ toMigrationInclusive: 42 });
18
19 const columns = yield* sql<{ readonly name: string }>`
20 PRAGMA table_info(projection_threads)
21 `;
22 assert.ok(columns.some((column) => column.name === "linked_pull_request_json"));
23 }),
24 );
25});
apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (!columns.some((column) => column.name === "linked_pull_request_json")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN linked_pull_request_json TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionThreads.ts+2
......@@ -14,6 +14,7 @@ import {
1414 ProjectId,
1515 ProviderInteractionMode,
1616 RuntimeMode,
17 ThreadLinkedPullRequest,
1718 ThreadId,
1819 TurnId,
1920} from "@t3tools/contracts";
......@@ -33,6 +34,7 @@ export const ProjectionThread = Schema.Struct({
3334 interactionMode: ProviderInteractionMode,
3435 branch: Schema.NullOr(Schema.String),
3536 worktreePath: Schema.NullOr(Schema.String),
37 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
3638 latestTurnId: Schema.NullOr(TurnId),
3739 createdAt: IsoDateTime,
3840 updatedAt: IsoDateTime,
apps/web/src/components/ChatMarkdown.test.tsx+8-1
......@@ -11,10 +11,17 @@ vi.mock("../state/session", async (importOriginal) => ({
1111 usePreparedConnection: () => ({ _tag: "Loading" }),
1212}));
1313vi.mock("../state/entities", () => ({
14 readThreadShell: () => null,
1415 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
16 useProjects: () => [],
1517}));
1618vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));
17vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
19vi.mock("~/lib/openPullRequestLink", () => ({
20 findProjectForChangeRequest: () => undefined,
21 matchesLinkedPullRequestUrl: () => false,
22 parseChangeRequestUrl: () => null,
23 useOpenChangeRequestLink: () => vi.fn(),
24}));
1825
1926import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";
2027
apps/web/src/components/ChatMarkdown.tsx+99-3
......@@ -13,7 +13,11 @@ import {
1313 TriangleAlertIcon,
1414 WrapTextIcon,
1515} from "lucide-react";
16import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";
16import type {
17 ScopedThreadRef,
18 ServerProviderSkill,
19 ThreadLinkedPullRequest,
20} from "@t3tools/contracts";
1721import {
1822 isAtomCommandInterrupted,
1923 squashAtomCommandFailure,
......@@ -88,7 +92,7 @@ import { readLocalApi } from "../localApi";
8892import { useAssetUrlState } from "../assets/assetUrls";
8993import { cn } from "../lib/utils";
9094import { useRightPanelStore } from "../rightPanelStore";
91import { useActiveEnvironmentId } from "../state/entities";
95import { readThreadShell, useActiveEnvironmentId, useProjects } from "../state/entities";
9296import { serverEnvironment } from "../state/server";
9397import { assetEnvironment } from "../state/assets";
9498import { usePreparedConnection } from "../state/session";
......@@ -96,13 +100,19 @@ import { previewEnvironment } from "../state/preview";
96100import { useAtomCommand } from "../state/use-atom-command";
97101import { useAtomQueryRunner } from "../state/use-atom-query-runner";
98102import { projectEnvironment } from "../state/projects";
103import { threadEnvironment } from "../state/threads";
99104import {
100105 claimWorkspaceBasenameLookup,
101106 needsWorkspaceBasenameLookup,
102107 pickWorkspaceBasenameMatch,
103108 WORKSPACE_BASENAME_LOOKUP_LIMIT,
104109} from "../workspaceBasenameLookup";
105import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
110import {
111 findProjectForChangeRequest,
112 matchesLinkedPullRequestUrl,
113 parseChangeRequestUrl,
114 useOpenChangeRequestLink,
115} from "~/lib/openPullRequestLink";
106116import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
107117import { isPreviewSupportedInRuntime } from "../previewStateStore";
108118import {
......@@ -1472,9 +1482,16 @@ function ChatMarkdown({
14721482 const openPreview = useAtomCommand(previewEnvironment.open, {
14731483 reportFailure: false,
14741484 });
1485 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1486 reportFailure: false,
1487 });
14751488 const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null);
14761489 const environmentId = useActiveEnvironmentId();
14771490 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
1491 const threadServerConfig = useAtomValue(
1492 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
1493 );
1494 const projects = useProjects();
14781495 const openInPreferredEditor = useOpenInPreferredEditor(
14791496 environmentId,
14801497 serverConfig?.availableEditors ?? [],
......@@ -1528,6 +1545,54 @@ function ChatMarkdown({
15281545 event.clipboardData.setData("text/html", payload.html);
15291546 }, []);
15301547 const openChangeRequestLink = useOpenChangeRequestLink(threadRef);
1548 const resolveThreadPullRequest = useCallback(
1549 (href: string): ThreadLinkedPullRequest | null => {
1550 if (
1551 threadRef === undefined ||
1552 readThreadShell(threadRef) === null ||
1553 threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true
1554 ) {
1555 return null;
1556 }
1557 const parsed = parseChangeRequestUrl(href);
1558 if (parsed === null) return null;
1559 const project = findProjectForChangeRequest(
1560 projects.filter((candidate) => candidate.environmentId === threadRef.environmentId),
1561 parsed,
1562 );
1563 if (project === undefined) return null;
1564 return {
1565 projectId: project.id,
1566 repository: project.repositoryIdentity?.displayName ?? parsed.repository,
1567 number: parsed.number,
1568 url: href,
1569 };
1570 },
1571 [projects, threadRef, threadServerConfig],
1572 );
1573 const updateThreadPullRequestLink = useCallback(
1574 async (href: string, linked: boolean) => {
1575 if (threadRef === undefined) return;
1576 const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null;
1577 if (linked && linkedPullRequest === null) {
1578 throw new Error("The pull request is not available in this environment.");
1579 }
1580 if (!linked) {
1581 const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest;
1582 if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) {
1583 return;
1584 }
1585 }
1586 const result = await updateThreadMetadata({
1587 environmentId: threadRef.environmentId,
1588 input: { threadId: threadRef.threadId, linkedPullRequest },
1589 });
1590 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1591 throw squashAtomCommandFailure(result);
1592 }
1593 },
1594 [resolveThreadPullRequest, threadRef, updateThreadMetadata],
1595 );
15311596 const openExternalLinkInPreview = useCallback(
15321597 (url: string) => {
15331598 if (!threadRef) {
......@@ -1760,9 +1825,20 @@ function ChatMarkdown({
17601825 event.stopPropagation();
17611826 const api = readLocalApi();
17621827 if (!api) return;
1828 const pullRequest = resolveThreadPullRequest(href);
1829 const currentPullRequest =
1830 threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
1831 const threadLinkAction =
1832 currentPullRequest != null &&
1833 matchesLinkedPullRequestUrl(currentPullRequest, href)
1834 ? "unlink-from-thread"
1835 : pullRequest === null
1836 ? undefined
1837 : "link-to-thread";
17631838 void showExternalLinkContextMenu({
17641839 href,
17651840 canOpenInPreview,
1841 threadLinkAction,
17661842 position: { x: event.clientX, y: event.clientY },
17671843 showContextMenu: (items, position) => api.contextMenu.show(items, position),
17681844 openInPreview: async (target) => {
......@@ -1776,8 +1852,25 @@ function ChatMarkdown({
17761852 },
17771853 openExternal: (target) => api.shell.openExternal(target),
17781854 copyLink: (target) => writeTextToClipboard(target, "link"),
1855 updateThreadLink: updateThreadPullRequestLink,
17791856 reportFailure: (operation, cause) => {
17801857 reportMarkdownActionFailure({ operation, target: href }, cause);
1858 if (
1859 operation === "link-pull-request-to-thread" ||
1860 operation === "unlink-pull-request-from-thread"
1861 ) {
1862 toastManager.add(
1863 stackedThreadToast({
1864 type: "error",
1865 title:
1866 operation === "link-pull-request-to-thread"
1867 ? "Unable to link pull request"
1868 : "Unable to unlink pull request",
1869 description:
1870 cause instanceof Error ? cause.message : "The request failed.",
1871 }),
1872 );
1873 }
17811874 },
17821875 });
17831876 }}
......@@ -1900,12 +1993,15 @@ function ChatMarkdown({
19001993 onTaskListChange,
19011994 openFileInPanel,
19021995 openInPreferredEditor,
1996 openChangeRequestLink,
19031997 openExternalLinkInPreview,
19041998 openMarkdownFileInPreview,
1999 resolveThreadPullRequest,
19052000 resolvedTheme,
19062001 skills,
19072002 text,
19082003 threadRef,
2004 updateThreadPullRequestLink,
19092005 ]);
19102006 /* eslint-enable react/no-unstable-nested-components */
19112007
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+8-1
......@@ -24,10 +24,17 @@ vi.mock("../state/session", async (importOriginal) => ({
2424 usePreparedConnection: () => ({ _tag: "Loading" }),
2525}));
2626vi.mock("../state/entities", () => ({
27 readThreadShell: () => null,
2728 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
29 useProjects: () => [],
2830}));
2931vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));
30vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
32vi.mock("~/lib/openPullRequestLink", () => ({
33 findProjectForChangeRequest: () => undefined,
34 matchesLinkedPullRequestUrl: () => false,
35 parseChangeRequestUrl: () => null,
36 useOpenChangeRequestLink: () => vi.fn(),
37}));
3138
3239import ChatMarkdown from "./ChatMarkdown";
3340
apps/web/src/components/ChatView.tsx+38-7
......@@ -302,6 +302,7 @@ import {
302302import {
303303 resolveDisplayedThreadPr,
304304 threadChangeRequestSnapshotsAtom,
305 useLinkedThreadPullRequest,
305306} from "./ThreadStatusIndicators";
306307import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
307308import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
......@@ -3412,24 +3413,48 @@ function ChatViewContent(props: ChatViewProps) {
34123413 );
34133414 // The thread's own change request, placed against the project it belongs to. Without a
34143415 // project there is nothing to resolve it against, so the caller falls back to the browser.
3415 const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null;
3416 const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null;
3417 const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
3418 const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
34163419 const openThreadPullRequest = useCallback(
3420 (number: number) => {
3421 if (!supportsPullRequests || !activeThreadRef) {
3422 return;
3423 }
3424 const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id;
3425 const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
3426 if (projectId === undefined || repository === null) return;
3427 useRightPanelStore.getState().openPullRequest(activeThreadRef, {
3428 projectId,
3429 repository,
3430 number,
3431 });
3432 },
3433 [
3434 activeProject,
3435 activeProjectRepository,
3436 activeThreadRef,
3437 linkedThreadPullRequest,
3438 supportsPullRequests,
3439 ],
3440 );
3441 const openProjectPullRequest = useCallback(
34173442 (number: number) => {
34183443 if (
34193444 !supportsPullRequests ||
34203445 !activeThreadRef ||
34213446 !activeProject ||
3422 threadRepository === null
3447 activeProjectRepository === null
34233448 ) {
34243449 return;
34253450 }
34263451 useRightPanelStore.getState().openPullRequest(activeThreadRef, {
34273452 projectId: activeProject.id,
3428 repository: threadRepository,
3453 repository: activeProjectRepository,
34293454 number,
34303455 });
34313456 },
3432 [activeProject, activeThreadRef, supportsPullRequests, threadRepository],
3457 [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],
34333458 );
34343459 const togglePreviewPanel = useCallback(() => {
34353460 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
......@@ -4307,11 +4332,17 @@ function ChatViewContent(props: ChatViewProps) {
43074332 : null;
43084333 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
43094334 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4335 const linkedPullRequestStatus = useLinkedThreadPullRequest(
4336 activeThreadRef?.environmentId ?? null,
4337 linkedThreadPullRequest,
4338 );
43104339 const activeThreadPr = resolveDisplayedThreadPr({
43114340 threadBranch: activeThread?.branch ?? null,
43124341 gitStatus: gitStatusQuery.data ?? null,
43134342 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
43144343 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
4344 linkedPullRequest: linkedThreadPullRequest,
4345 linkedPullRequestStatus,
43154346 });
43164347 // The right panel offers the thread's own change request, so it can only offer it once the
43174348 // branch has one; until then the picker says so rather than opening an empty panel.
......@@ -6578,7 +6609,7 @@ function ChatViewContent(props: ChatViewProps) {
65786609 context={
65796610 isThreadOwnPullRequest(
65806611 {
6581 projectId: activeProject?.id ?? null,
6612 projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null,
65826613 repository: threadRepository,
65836614 number: activeThreadPr?.number ?? null,
65846615 },
......@@ -6651,9 +6682,9 @@ function ChatViewContent(props: ChatViewProps) {
66516682 >
66526683 {!rightPanelOpen ? panelLayoutControls : null}
66536684 <ChatHeader
6654 {...(!supportsPullRequests || threadRepository === null
6685 {...(!supportsPullRequests || activeProjectRepository === null
66556686 ? {}
6656 : { onOpenPullRequest: openThreadPullRequest })}
6687 : { onOpenPullRequest: openProjectPullRequest })}
66576688 activeThreadEnvironmentId={activeThread.environmentId}
66586689 activeThreadId={activeThread.id}
66596690 {...(routeKind === "draft" && draftId ? { draftId } : {})}
apps/web/src/components/LegacySidebar.tsx+14-6
......@@ -20,6 +20,7 @@ import {
2020 terminalStatusFromRunningIds,
2121 ThreadStatusLabel,
2222 ThreadWorktreeIndicator,
23 useLinkedThreadPullRequest,
2324} from "./ThreadStatusIndicators";
2425import { ProjectFavicon } from "./ProjectFavicon";
2526import { useAtomValue } from "@effect/atom-react";
......@@ -416,7 +417,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
416417 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
417418 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
418419 const gitStatus = useEnvironmentQuery(
419 thread.branch != null && gitCwd !== null
420 thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null
420421 ? vcsEnvironment.status({
421422 environmentId: thread.environmentId,
422423 input: { cwd: gitCwd },
......@@ -457,11 +458,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
457458 lastVisitedAt,
458459 },
459460 });
460 const pr = resolveThreadPr({
461 threadBranch: thread.branch,
462 gitStatus: gitStatus.data,
463 });
464 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
461 const linkedPullRequestStatus = useLinkedThreadPullRequest(
462 thread.environmentId,
463 thread.linkedPullRequest,
464 );
465 const pr =
466 thread.linkedPullRequest == null
467 ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data })
468 : (linkedPullRequestStatus?.pr ?? null);
469 const prStatus = prStatusIndicator(
470 pr,
471 linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider,
472 );
465473 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
466474 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
467475 const threadMetaClassName = isConfirmingArchive
apps/web/src/components/Sidebar.tsx+19-1
......@@ -156,6 +156,7 @@ import {
156156 threadChangeRequestSnapshotsAtom,
157157 type ThreadChangeRequestSnapshot,
158158 type TerminalStatusIndicator,
159 useLinkedThreadPullRequest,
159160} from "./ThreadStatusIndicators";
160161import {
161162 resolveSnoozePresets,
......@@ -787,6 +788,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
787788 const terminalProcessCount = runningTerminalIds.length;
788789
789790 const gitCwd = thread.worktreePath ?? props.projectCwd;
791 const linkedPullRequestStatus = useLinkedThreadPullRequest(
792 thread.environmentId,
793 thread.linkedPullRequest,
794 );
790795 const gitStatus = useEnvironmentQuery(
791796 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
792797 ? vcsEnvironment.status({
......@@ -801,6 +806,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
801806 gitStatus: gitStatus.data,
802807 snapshot: changeRequestSnapshot,
803808 retainTerminalOnBranchMismatch,
809 linkedPullRequest: thread.linkedPullRequest,
810 linkedPullRequestStatus,
804811 });
805812
806813 // Same semantics as the legacy sidebar (never-visited counts as read):
......@@ -901,6 +908,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
901908 gitStatus: gitStatus.data,
902909 snapshot: changeRequestSnapshot,
903910 retainTerminalOnBranchMismatch,
911 linkedPullRequest: thread.linkedPullRequest,
912 linkedPullRequestStatus,
904913 });
905914 const prStatus = prStatusIndicator(pr, prProvider);
906915 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
......@@ -910,15 +919,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
910919 gitStatus: gitStatus.data,
911920 snapshot: changeRequestSnapshot,
912921 retainTerminalOnBranchMismatch,
922 linkedPullRequest: thread.linkedPullRequest,
923 linkedPullRequestStatus,
913924 });
914925 if (nextSnapshot === undefined) return;
915926 onChangeRequestSnapshot(threadKey, nextSnapshot);
916927 }, [
917928 changeRequestSnapshot,
918929 gitStatus.data,
930 linkedPullRequestStatus,
919931 onChangeRequestSnapshot,
920932 retainTerminalOnBranchMismatch,
921933 thread.branch,
934 thread.linkedPullRequest,
922935 threadKey,
923936 ]);
924937
......@@ -2039,7 +2052,12 @@ export default function Sidebar() {
20392052 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
20402053 const snapshot = changeRequestSnapshotByKey.get(threadKey);
20412054 const changeRequest =
2042 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)
2055 snapshot != null &&
2056 (thread.linkedPullRequest == null
2057 ? thread.worktreePath === null || snapshot.branch === thread.branch
2058 : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId &&
2059 snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository &&
2060 snapshot.linkedPullRequest.number === thread.linkedPullRequest.number)
20432061 ? snapshot.pr
20442062 : null;
20452063 // Snooze outranks settlement and pinning until the thread wakes.
apps/web/src/components/ThreadStatusIndicators.test.ts+119
......@@ -101,6 +101,12 @@ describe("resolveThreadPr", () => {
101101describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
102102 const featureBranch = "feature/current";
103103 const mergedPr = mergedFeaturePr();
104 const linkedPullRequest = {
105 projectId: ProjectId.make("project-1"),
106 repository: "pingdotgg/t3code",
107 number: 42,
108 url: "https://github.com/pingdotgg/t3code/pull/42",
109 };
104110 const provider = {
105111 kind: "github" as const,
106112 name: "GitHub",
......@@ -132,6 +138,119 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
132138 ).toEqual(provider);
133139 });
134140
141 it("shows a linked pull request when the checkout has a different branch", () => {
142 const linkedPullRequestStatus = {
143 pr: mergedPr,
144 sourceControlProvider: provider,
145 };
146
147 expect(
148 resolveDisplayedThreadPr({
149 threadBranch: "feature/other",
150 gitStatus: status({ refName: "feature/other", pr: null }),
151 snapshot: undefined,
152 retainTerminalOnBranchMismatch: false,
153 linkedPullRequest,
154 linkedPullRequestStatus,
155 }),
156 ).toEqual(mergedPr);
157 expect(
158 resolveDisplayedThreadPrProvider({
159 threadBranch: "feature/other",
160 gitStatus: status({ refName: "feature/other", pr: null }),
161 snapshot: undefined,
162 retainTerminalOnBranchMismatch: false,
163 linkedPullRequest,
164 linkedPullRequestStatus,
165 }),
166 ).toEqual(provider);
167 expect(
168 nextThreadChangeRequestSnapshot({
169 threadBranch: "feature/other",
170 gitStatus: status({ refName: "feature/other", pr: null }),
171 snapshot: undefined,
172 retainTerminalOnBranchMismatch: false,
173 linkedPullRequest,
174 linkedPullRequestStatus,
175 }),
176 ).toEqual({
177 branch: "feature/other",
178 pr: mergedPr,
179 sourceControlProvider: provider,
180 linkedPullRequest,
181 });
182 });
183
184 it("keeps a matching linked pull request snapshot while its status reloads", () => {
185 const snapshot = {
186 ...snapshotFor(featureBranch, mergedPr, provider),
187 linkedPullRequest,
188 };
189
190 expect(
191 resolveDisplayedThreadPr({
192 threadBranch: null,
193 gitStatus: null,
194 snapshot,
195 retainTerminalOnBranchMismatch: false,
196 linkedPullRequest,
197 linkedPullRequestStatus: null,
198 }),
199 ).toEqual(mergedPr);
200 expect(
201 nextThreadChangeRequestSnapshot({
202 threadBranch: null,
203 gitStatus: null,
204 snapshot,
205 retainTerminalOnBranchMismatch: false,
206 linkedPullRequest,
207 linkedPullRequestStatus: null,
208 }),
209 ).toBeUndefined();
210 });
211
212 it("clears an old snapshot when a different pull request is linked", () => {
213 const snapshot = {
214 ...snapshotFor(featureBranch, mergedPr, provider),
215 linkedPullRequest: { ...linkedPullRequest, number: 41 },
216 };
217
218 expect(
219 nextThreadChangeRequestSnapshot({
220 threadBranch: featureBranch,
221 gitStatus: null,
222 snapshot,
223 retainTerminalOnBranchMismatch: true,
224 linkedPullRequest,
225 linkedPullRequestStatus: null,
226 }),
227 ).toBeNull();
228 });
229
230 it("removes a linked pull request snapshot after the link is cleared", () => {
231 const snapshot = {
232 ...snapshotFor(featureBranch, mergedPr, provider),
233 linkedPullRequest,
234 };
235
236 expect(
237 resolveDisplayedThreadPr({
238 threadBranch: featureBranch,
239 gitStatus: status({ refName: "main", pr: null }),
240 snapshot,
241 retainTerminalOnBranchMismatch: true,
242 }),
243 ).toBeNull();
244 expect(
245 nextThreadChangeRequestSnapshot({
246 threadBranch: featureBranch,
247 gitStatus: null,
248 snapshot,
249 retainTerminalOnBranchMismatch: true,
250 }),
251 ).toBeNull();
252 });
253
135254 it("after caching a merged PR, resolves main status back to the cached feature PR", () => {
136255 const matchingStatus = status({
137256 refName: featureBranch,
apps/web/src/components/ThreadStatusIndicators.tsx+136-12
......@@ -3,7 +3,8 @@ import {
33 scopedThreadKey,
44 scopeThreadRef,
55} from "@t3tools/client-runtime/environment";
6import type { VcsStatusResult } from "@t3tools/contracts";
6import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
7import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts";
78import { Atom } from "effect/unstable/reactivity";
89import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
910import { useMemo } from "react";
......@@ -11,6 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
1112import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1213import { useProject } from "../state/entities";
1314import { useEnvironmentQuery } from "../state/query";
15import { linkedPullRequestDetailAtom } from "../state/pullRequests";
1416import { useThreadRunningTerminalIds } from "../state/terminalSessions";
1517import { vcsEnvironment } from "../state/vcs";
1618import { useUiStateStore } from "../uiStateStore";
......@@ -37,6 +39,44 @@ export interface TerminalStatusIndicator {
3739
3840export type ThreadPr = VcsStatusResult["pr"];
3941
42export interface LinkedThreadPullRequestStatus {
43 readonly pr: NonNullable<ThreadPr>;
44 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
45}
46
47export function useLinkedThreadPullRequest(
48 environmentId: EnvironmentId | null,
49 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
50): LinkedThreadPullRequestStatus | null {
51 const detail = useEnvironmentQuery(
52 environmentId === null || linkedPullRequest == null
53 ? null
54 : linkedPullRequestDetailAtom({
55 environmentId,
56 input: {
57 projectId: linkedPullRequest.projectId,
58 repository: linkedPullRequest.repository,
59 number: linkedPullRequest.number,
60 },
61 }),
62 ).data;
63
64 return useMemo(
65 () =>
66 detail === null
67 ? null
68 : {
69 pr: pullRequestDetailToVcsStatus(detail),
70 sourceControlProvider: {
71 kind: detail.provider,
72 name: detail.provider,
73 baseUrl: "",
74 },
75 },
76 [detail],
77 );
78}
79
4080export function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"]): string {
4181 switch (state) {
4282 case "open":
......@@ -136,6 +176,7 @@ export interface ThreadChangeRequestSnapshot {
136176 readonly branch: string;
137177 readonly pr: NonNullable<ThreadPr>;
138178 readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined;
179 readonly linkedPullRequest?: ThreadLinkedPullRequest;
139180}
140181
141182export const threadChangeRequestSnapshotsAtom = Atom.make<
......@@ -157,6 +198,19 @@ function sourceControlProvidersEqual(
157198 return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl;
158199}
159200
201function linkedPullRequestsEqual(
202 left: ThreadLinkedPullRequest | null | undefined,
203 right: ThreadLinkedPullRequest | null | undefined,
204): boolean {
205 if (left == null || right == null) return left == null && right == null;
206 return (
207 left.projectId === right.projectId &&
208 left.repository === right.repository &&
209 left.number === right.number &&
210 left.url === right.url
211 );
212}
213
160214export function threadChangeRequestSnapshotsEqual(
161215 left: ThreadChangeRequestSnapshot,
162216 right: ThreadChangeRequestSnapshot,
......@@ -170,7 +224,8 @@ export function threadChangeRequestSnapshotsEqual(
170224 left.pr.headRef === right.pr.headRef &&
171225 left.pr.state === right.pr.state &&
172226 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&
173 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider)
227 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) &&
228 linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest)
174229 );
175230}
176231
......@@ -206,10 +261,32 @@ export function nextThreadChangeRequestSnapshot(input: {
206261 gitStatus: VcsStatusResult | null;
207262 snapshot: ThreadChangeRequestSnapshot | null | undefined;
208263 retainTerminalOnBranchMismatch: boolean;
264 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
265 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
209266}): ThreadChangeRequestSnapshot | null | undefined {
210 const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input;
267 const {
268 threadBranch,
269 gitStatus,
270 snapshot,
271 retainTerminalOnBranchMismatch,
272 linkedPullRequest,
273 linkedPullRequestStatus,
274 } = input;
275 if (linkedPullRequest != null) {
276 if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) {
277 return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
278 ? undefined
279 : null;
280 }
281 return {
282 branch: threadBranch ?? linkedPullRequestStatus.pr.headRef,
283 pr: linkedPullRequestStatus.pr,
284 sourceControlProvider: linkedPullRequestStatus.sourceControlProvider,
285 linkedPullRequest,
286 };
287 }
211288 if (gitStatus === null) {
212 return undefined;
289 return snapshot?.linkedPullRequest === undefined ? undefined : null;
213290 }
214291 if (threadBranch === null) {
215292 return null;
......@@ -217,6 +294,7 @@ export function nextThreadChangeRequestSnapshot(input: {
217294 if (gitStatus.refName !== threadBranch) {
218295 return retainTerminalOnBranchMismatch &&
219296 snapshot != null &&
297 snapshot.linkedPullRequest === undefined &&
220298 isTerminalChangeRequestState(snapshot.pr.state)
221299 ? undefined
222300 : null;
......@@ -225,6 +303,7 @@ export function nextThreadChangeRequestSnapshot(input: {
225303 if (
226304 retainTerminalOnBranchMismatch &&
227305 snapshot != null &&
306 snapshot.linkedPullRequest === undefined &&
228307 isTerminalChangeRequestState(snapshot.pr.state)
229308 ) {
230309 return undefined;
......@@ -250,8 +329,25 @@ export function resolveDisplayedThreadPr(input: {
250329 gitStatus: VcsStatusResult | null;
251330 snapshot: ThreadChangeRequestSnapshot | null | undefined;
252331 retainTerminalOnBranchMismatch: boolean;
332 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
333 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
253334}): ThreadPr | null {
254 const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input;
335 const {
336 threadBranch,
337 gitStatus,
338 snapshot,
339 retainTerminalOnBranchMismatch,
340 linkedPullRequest,
341 linkedPullRequestStatus,
342 } = input;
343 if (linkedPullRequest != null) {
344 return (
345 linkedPullRequestStatus?.pr ??
346 (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
347 ? (snapshot?.pr ?? null)
348 : null)
349 );
350 }
255351 if (
256352 threadBranch !== null &&
257353 gitStatus !== null &&
......@@ -265,6 +361,7 @@ export function resolveDisplayedThreadPr(input: {
265361 threadBranch !== null &&
266362 retainTerminalOnBranchMismatch &&
267363 snapshot != null &&
364 snapshot.linkedPullRequest === undefined &&
268365 isTerminalChangeRequestState(snapshot.pr.state)
269366 ) {
270367 return snapshot.pr;
......@@ -278,8 +375,25 @@ export function resolveDisplayedThreadPrProvider(input: {
278375 gitStatus: VcsStatusResult | null;
279376 snapshot: ThreadChangeRequestSnapshot | null | undefined;
280377 retainTerminalOnBranchMismatch: boolean;
378 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
379 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
281380}): VcsStatusResult["sourceControlProvider"] | undefined {
282 const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input;
381 const {
382 threadBranch,
383 gitStatus,
384 snapshot,
385 retainTerminalOnBranchMismatch,
386 linkedPullRequest,
387 linkedPullRequestStatus,
388 } = input;
389 if (linkedPullRequest != null) {
390 return (
391 linkedPullRequestStatus?.sourceControlProvider ??
392 (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
393 ? snapshot?.sourceControlProvider
394 : undefined)
395 );
396 }
283397 if (
284398 threadBranch !== null &&
285399 gitStatus !== null &&
......@@ -293,6 +407,7 @@ export function resolveDisplayedThreadPrProvider(input: {
293407 threadBranch !== null &&
294408 retainTerminalOnBranchMismatch &&
295409 snapshot != null &&
410 snapshot.linkedPullRequest === undefined &&
296411 isTerminalChangeRequestState(snapshot.pr.state)
297412 ) {
298413 return snapshot.sourceControlProvider;
......@@ -417,19 +532,28 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
417532 );
418533 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
419534 const gitCwd = thread.worktreePath ?? threadProjectCwd;
535 const linkedPullRequest = useLinkedThreadPullRequest(
536 thread.environmentId,
537 thread.linkedPullRequest,
538 );
420539 const gitStatus = useEnvironmentQuery(
421 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
540 thread.linkedPullRequest == null &&
541 (thread.branch != null || thread.worktreePath !== null) &&
542 gitCwd !== null
422543 ? vcsEnvironment.status({
423544 environmentId: thread.environmentId,
424545 input: { cwd: gitCwd },
425546 })
426547 : null,
427548 );
428 const pr = resolveThreadPr({
429 threadBranch: thread.branch,
430 gitStatus: gitStatus.data,
431 });
432 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
549 const pr =
550 thread.linkedPullRequest == null
551 ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data })
552 : (linkedPullRequest?.pr ?? null);
553 const prStatus = prStatusIndicator(
554 pr,
555 linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider,
556 );
433557 const threadStatus = resolveThreadStatusPill({
434558 thread: {
435559 ...thread,
apps/web/src/components/chat/externalLinkContextMenu.test.ts+47-1
......@@ -2,11 +2,20 @@ import { describe, expect, it, vi } from "vite-plus/test";
22
33import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu";
44
5function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) {
5function createHarness(
6 selection:
7 | "open-in-preview"
8 | "open-external"
9 | "copy-link"
10 | "link-to-thread"
11 | "unlink-from-thread"
12 | null,
13) {
614 const showContextMenu = vi.fn().mockResolvedValue(selection);
715 const openInPreview = vi.fn().mockResolvedValue(undefined);
816 const openExternal = vi.fn().mockResolvedValue(undefined);
917 const copyLink = vi.fn().mockResolvedValue(undefined);
18 const updateThreadLink = vi.fn().mockResolvedValue(undefined);
1019 const reportFailure = vi.fn();
1120
1221 return {
......@@ -14,6 +23,7 @@ function createHarness(selection: "open-in-preview" | "open-external" | "copy-li
1423 openInPreview,
1524 openExternal,
1625 copyLink,
26 updateThreadLink,
1727 reportFailure,
1828 };
1929}
......@@ -71,6 +81,27 @@ describe("external chat link context menu", () => {
7181 expect(harness.openExternal).not.toHaveBeenCalled();
7282 });
7383
84 it.each([
85 ["link-to-thread", "Link to thread", true],
86 ["unlink-from-thread", "Unlink from thread", false],
87 ] as const)("offers and runs the %s action", async (action, label, linked) => {
88 const harness = createHarness(action);
89 const href = "https://github.com/pingdotgg/t3code/pull/42";
90
91 await showExternalLinkContextMenu({
92 href,
93 threadLinkAction: action,
94 position: { x: 1, y: 2 },
95 ...harness,
96 });
97
98 expect(harness.showContextMenu).toHaveBeenCalledWith(
99 expect.arrayContaining([{ id: action, label }]),
100 { x: 1, y: 2 },
101 );
102 expect(harness.updateThreadLink).toHaveBeenCalledWith(href, linked);
103 });
104
74105 it.each([
75106 ["open-in-preview" as const, "openInPreview" as const],
76107 ["open-external" as const, "openExternal" as const],
......@@ -132,6 +163,21 @@ describe("external chat link context menu", () => {
132163 expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause);
133164 });
134165
166 it("reports a failed thread link action", async () => {
167 const harness = createHarness("link-to-thread");
168 const cause = new Error("thread update failed");
169 harness.updateThreadLink.mockRejectedValue(cause);
170
171 await showExternalLinkContextMenu({
172 href: "https://github.com/pingdotgg/t3code/pull/42",
173 threadLinkAction: "link-to-thread",
174 position: { x: 1, y: 2 },
175 ...harness,
176 });
177
178 expect(harness.reportFailure).toHaveBeenCalledWith("link-pull-request-to-thread", cause);
179 });
180
135181 it.each([
136182 ["https://example.com", "example.com"],
137183 ["http://localhost:3000/path", "localhost"],
apps/web/src/components/chat/externalLinkContextMenu.ts+32-4
......@@ -1,17 +1,26 @@
11import type { ContextMenuItem } from "@t3tools/contracts";
22
3export type ExternalLinkContextMenuAction = "open-in-preview" | "open-external" | "copy-link";
3export type ExternalLinkContextMenuAction =
4 | "open-in-preview"
5 | "open-external"
6 | "copy-link"
7 | "link-to-thread"
8 | "unlink-from-thread";
49
510export type ExternalLinkContextMenuFailureOperation =
611 | "show-link-context-menu"
712 | "open-link-in-preview"
813 | "open-link-external"
9 | "copy-link";
14 | "copy-link"
15 | "link-pull-request-to-thread"
16 | "unlink-pull-request-from-thread";
1017
1118const FAILURE_OPERATION_BY_ACTION = {
1219 "open-in-preview": "open-link-in-preview",
1320 "open-external": "open-link-external",
1421 "copy-link": "copy-link",
22 "link-to-thread": "link-pull-request-to-thread",
23 "unlink-from-thread": "unlink-pull-request-from-thread",
1524} as const satisfies Record<ExternalLinkContextMenuAction, ExternalLinkContextMenuFailureOperation>;
1625
1726const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [
......@@ -28,10 +37,20 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [
2837 */
2938export function externalLinkContextMenuItems(options: {
3039 readonly canOpenInPreview: boolean;
40 readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined;
3141}): readonly ContextMenuItem<ExternalLinkContextMenuAction>[] {
32 return options.canOpenInPreview
42 const items = options.canOpenInPreview
3343 ? EXTERNAL_LINK_CONTEXT_MENU_ITEMS
3444 : EXTERNAL_LINK_CONTEXT_MENU_ITEMS.filter((item) => item.id !== "open-in-preview");
45 if (options.threadLinkAction === undefined) return items;
46 return [
47 {
48 id: options.threadLinkAction,
49 label:
50 options.threadLinkAction === "link-to-thread" ? "Link to thread" : "Unlink from thread",
51 },
52 ...items,
53 ];
3554}
3655
3756interface ShowExternalLinkContextMenuOptions {
......@@ -39,6 +58,7 @@ interface ShowExternalLinkContextMenuOptions {
3958 readonly position: { readonly x: number; readonly y: number };
4059 /** Absent means yes, which is what every caller before the browser could be missing meant. */
4160 readonly canOpenInPreview?: boolean;
61 readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined;
4262 readonly showContextMenu: (
4363 items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[],
4464 position: { readonly x: number; readonly y: number },
......@@ -46,6 +66,7 @@ interface ShowExternalLinkContextMenuOptions {
4666 readonly openInPreview: (href: string) => Promise<void>;
4767 readonly openExternal: (href: string) => Promise<void>;
4868 readonly copyLink: (href: string) => Promise<unknown>;
69 readonly updateThreadLink?: (href: string, linked: boolean) => Promise<void>;
4970 readonly reportFailure: (
5071 operation: ExternalLinkContextMenuFailureOperation,
5172 cause: unknown,
......@@ -67,15 +88,20 @@ export async function showExternalLinkContextMenu({
6788 href,
6889 position,
6990 canOpenInPreview = true,
91 threadLinkAction,
7092 showContextMenu,
7193 openInPreview,
7294 openExternal,
7395 copyLink,
96 updateThreadLink,
7497 reportFailure,
7598}: ShowExternalLinkContextMenuOptions): Promise<void> {
7699 let action: ExternalLinkContextMenuAction | null;
77100 try {
78 action = await showContextMenu(externalLinkContextMenuItems({ canOpenInPreview }), position);
101 action = await showContextMenu(
102 externalLinkContextMenuItems({ canOpenInPreview, threadLinkAction }),
103 position,
104 );
79105 } catch (cause) {
80106 reportFailure("show-link-context-menu", cause);
81107 return;
......@@ -88,6 +114,8 @@ export async function showExternalLinkContextMenu({
88114 await openExternal(href);
89115 } else if (action === "copy-link") {
90116 await copyLink(href);
117 } else if (action === "link-to-thread" || action === "unlink-from-thread") {
118 await updateThreadLink?.(href, action === "link-to-thread");
91119 }
92120 } catch (cause) {
93121 if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause);
apps/web/src/lib/openPullRequestLink.test.ts+32
......@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from "vite-plus/test";
33import {
44 changeRequestRepositoryUrl,
55 findProjectForChangeRequest,
6 matchesLinkedPullRequestUrl,
67 openPullRequestLink,
78 parseChangeRequestUrl,
89 PullRequestLinkOpenError,
910 shouldOpenPullRequestExternally,
1011} from "./openPullRequestLink";
12import { ProjectId } from "@t3tools/contracts";
1113
1214describe("changeRequestRepositoryUrl", () => {
1315 it("preserves repository path casing", () => {
......@@ -27,6 +29,36 @@ describe("changeRequestRepositoryUrl", () => {
2729 });
2830});
2931
32describe("matchesLinkedPullRequestUrl", () => {
33 const linkedPullRequest = {
34 projectId: ProjectId.make("project-1"),
35 repository: "pingdotgg/t3code",
36 number: 42,
37 url: "https://github.com/pingdotgg/t3code/pull/42",
38 };
39
40 it("matches the same pull request without looking up its project", () => {
41 expect(
42 matchesLinkedPullRequestUrl(
43 linkedPullRequest,
44 "https://github.com/PingDotGG/T3Code/pull/42/files",
45 ),
46 ).toBe(true);
47 });
48
49 it("rejects a different pull request or host", () => {
50 expect(
51 matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"),
52 ).toBe(false);
53 expect(
54 matchesLinkedPullRequestUrl(
55 linkedPullRequest,
56 "https://github.example.com/pingdotgg/t3code/pull/42",
57 ),
58 ).toBe(false);
59 });
60});
61
3062describe("openPullRequestLink", () => {
3163 it("opens the requested pull request URL", async () => {
3264 const openExternal = vi.fn(async () => undefined);
apps/web/src/lib/openPullRequestLink.ts+22-1
......@@ -1,4 +1,9 @@
1import type { EnvironmentId, LocalApi, ScopedThreadRef } from "@t3tools/contracts";
1import type {
2 EnvironmentId,
3 LocalApi,
4 ScopedThreadRef,
5 ThreadLinkedPullRequest,
6} from "@t3tools/contracts";
27import { useNavigate } from "@tanstack/react-router";
38import * as Schema from "effect/Schema";
49import { type MouseEvent, useCallback } from "react";
......@@ -118,6 +123,22 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
118123 return null;
119124}
120125
126/** Match a stored PR without requiring its project to remain available. */
127export function matchesLinkedPullRequestUrl(
128 linkedPullRequest: ThreadLinkedPullRequest,
129 targetUrl: string,
130): boolean {
131 const linked = parseChangeRequestUrl(linkedPullRequest.url);
132 const target = parseChangeRequestUrl(targetUrl);
133 return (
134 linked !== null &&
135 target !== null &&
136 linked.host === target.host &&
137 linked.repository === target.repository &&
138 linked.number === target.number
139 );
140}
141
121142/** The repository root behind a recognised change-request URL, without PR-specific state. */
122143export function changeRequestRepositoryUrl(targetUrl: string): string | null {
123144 const changeRequest = parseChangeRequestUrl(targetUrl);
apps/web/src/state/pullRequests.ts+6-1
......@@ -1,5 +1,8 @@
11import { useAtomValue } from "@effect/atom-react";
2import { createPullRequestEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests";
2import {
3 createLinkedPullRequestDetailAtomFamily,
4 createPullRequestEnvironmentAtoms,
5} from "@t3tools/client-runtime/state/pull-requests";
36import type {
47 EnvironmentId,
58 PullRequestListInput,
......@@ -19,6 +22,8 @@ import {
1922import { formatEnvironmentQueryError } from "./query";
2023
2124export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
25export const linkedPullRequestDetailAtom =
26 createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);
2227
2328export interface EnvironmentQueryTarget<Input> {
2429 readonly environmentId: EnvironmentId;
docs/user/thread-sidebar.md+4
......@@ -7,6 +7,10 @@ one environment.
77Pinned threads still move to **Settled** when they become inactive. They also move when their pull
88request merges if **Auto-settle merged threads** is enabled.
99
10Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
11in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
12threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.
13
1014On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
1115and choose **Move up** or **Move down**. The order is stored by the server and appears on your
1216other connected devices.
packages/client-runtime/src/state/pullRequests.ts+32-1
......@@ -1,4 +1,9 @@
1import { WS_METHODS, type PullRequestDiffInput } from "@t3tools/contracts";
1import {
2 WS_METHODS,
3 type PullRequestDetail,
4 type PullRequestDiffInput,
5 type VcsStatusResult,
6} from "@t3tools/contracts";
27import * as Data from "effect/Data";
38import * as Effect from "effect/Effect";
49import * as Option from "effect/Option";
......@@ -21,6 +26,32 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
2126 "EnvironmentHttpConnectionNotReadyError",
2227)<{ readonly message: string }> {}
2328
29/** Refresh a linked PR while its thread is visible so merges update the sidebar. */
30export function createLinkedPullRequestDetailAtomFamily<R, E>(
31 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
32) {
33 return createEnvironmentRpcQueryAtomFamily(runtime, {
34 label: "environment-data:pull-requests:linked-detail",
35 tag: WS_METHODS.pullRequestsDetail,
36 staleTimeMs: 15_000,
37 refreshIntervalMs: 30_000,
38 });
39}
40
41export function pullRequestDetailToVcsStatus(
42 detail: PullRequestDetail,
43): NonNullable<VcsStatusResult["pr"]> {
44 return {
45 number: detail.number,
46 title: detail.title,
47 url: detail.url,
48 baseRef: detail.baseBranch,
49 headRef: detail.headBranch,
50 state: detail.state,
51 updatedAt: detail.updatedAt,
52 };
53}
54
2455/**
2556 * Every read shells out to the GitHub CLI, so results are reused for a short while and
2657 * refreshed explicitly. Mutations run serially per environment: `gh` actions on the same
packages/client-runtime/src/state/threadReducer.test.ts+45
......@@ -305,6 +305,51 @@ describe("applyThreadDetailEvent", () => {
305305 expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);
306306 }
307307 });
308
309 it("sets and clears a linked pull request", () => {
310 const linkedPullRequest = {
311 projectId: ProjectId.make("project-1"),
312 repository: "pingdotgg/t3code",
313 number: 42,
314 url: "https://github.com/pingdotgg/t3code/pull/42",
315 };
316 const linked = applyThreadDetailEvent(baseThread, {
317 ...baseEventFields,
318 sequence: 5,
319 occurredAt: "2026-04-01T05:00:00.000Z",
320 aggregateKind: "thread",
321 aggregateId: ThreadId.make("thread-1"),
322 type: "thread.meta-updated",
323 payload: {
324 threadId: ThreadId.make("thread-1"),
325 linkedPullRequest,
326 updatedAt: "2026-04-01T05:00:00.000Z",
327 },
328 });
329
330 expect(linked.kind).toBe("updated");
331 if (linked.kind !== "updated") return;
332 expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest);
333
334 const cleared = applyThreadDetailEvent(linked.thread, {
335 ...baseEventFields,
336 sequence: 6,
337 occurredAt: "2026-04-01T06:00:00.000Z",
338 aggregateKind: "thread",
339 aggregateId: ThreadId.make("thread-1"),
340 type: "thread.meta-updated",
341 payload: {
342 threadId: ThreadId.make("thread-1"),
343 linkedPullRequest: null,
344 updatedAt: "2026-04-01T06:00:00.000Z",
345 },
346 });
347
348 expect(cleared.kind).toBe("updated");
349 if (cleared.kind === "updated") {
350 expect(cleared.thread.linkedPullRequest).toBeNull();
351 }
352 });
308353 });
309354
310355 describe("thread.message-sent", () => {
packages/client-runtime/src/state/threadReducer.ts+3
......@@ -218,6 +218,9 @@ export function applyThreadDetailEvent(
218218 ...(event.payload.worktreePath !== undefined
219219 ? { worktreePath: event.payload.worktreePath }
220220 : {}),
221 ...(event.payload.linkedPullRequest !== undefined
222 ? { linkedPullRequest: event.payload.linkedPullRequest }
223 : {}),
221224 updatedAt: event.payload.updatedAt,
222225 },
223226 };
packages/contracts/src/environment.ts+2
......@@ -69,6 +69,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
6969 /** Server understands regenerateTitle on thread.meta.update. Absent on
7070 older servers, so clients hide the action instead of sending it. */
7171 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
72 /** Server persists a pull request reference on thread.meta.update. */
73 threadPullRequestLinking: Schema.optionalKey(Schema.Boolean),
7274 /** The update path clients should offer for this server. Absent on
7375 servers that must be relaunched manually (dev checkouts, Windows
7476 foreground runs, pre-update servers). */
packages/contracts/src/orchestration.test.ts+22
......@@ -709,6 +709,28 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () =>
709709 }),
710710);
711711
712it.effect("accepts a linked pull request in thread.meta.update", () =>
713 Effect.gen(function* () {
714 const linkedPullRequest = {
715 projectId: "project-1",
716 repository: "pingdotgg/t3code",
717 number: 42,
718 url: "https://github.com/pingdotgg/t3code/pull/42",
719 };
720 const parsed = yield* decodeOrchestrationCommand({
721 type: "thread.meta.update",
722 commandId: "cmd-link-pull-request",
723 threadId: "thread-1",
724 linkedPullRequest,
725 });
726
727 assert.strictEqual(parsed.type, "thread.meta.update");
728 if (parsed.type === "thread.meta.update") {
729 assert.deepStrictEqual(parsed.linkedPullRequest, linkedPullRequest);
730 }
731 }),
732);
733
712734it.effect("accepts an internal title regeneration completion", () =>
713735 Effect.gen(function* () {
714736 const parsed = yield* decodeOrchestrationCommand({
packages/contracts/src/orchestration.ts+12
......@@ -387,6 +387,14 @@ export const ThreadTitleRegeneration = Schema.Struct({
387387});
388388export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;
389389
390export const ThreadLinkedPullRequest = Schema.Struct({
391 projectId: ProjectId,
392 repository: TrimmedNonEmptyString,
393 number: PositiveInt,
394 url: TrimmedNonEmptyString,
395});
396export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type;
397
390398export const OrchestrationThread = Schema.Struct({
391399 id: ThreadId,
392400 projectId: ProjectId,
......@@ -398,6 +406,7 @@ export const OrchestrationThread = Schema.Struct({
398406 ),
399407 branch: Schema.NullOr(TrimmedNonEmptyString),
400408 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
409 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
401410 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
402411 createdAt: IsoDateTime,
403412 updatedAt: IsoDateTime,
......@@ -468,6 +477,7 @@ export const OrchestrationThreadShell = Schema.Struct({
468477 ),
469478 branch: Schema.NullOr(TrimmedNonEmptyString),
470479 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
480 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
471481 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
472482 createdAt: IsoDateTime,
473483 updatedAt: IsoDateTime,
......@@ -784,6 +794,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({
784794 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
785795 expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
786796 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
797 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
787798}).check(
788799 Schema.makeFilter(
789800 (input) =>
......@@ -1227,6 +1238,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
12271238 modelSelection: Schema.optional(ModelSelection),
12281239 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
12291240 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1241 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
12301242 updatedAt: IsoDateTime,
12311243});
12321244