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 {...@@ -12,7 +12,6 @@ import {
12 type EnvironmentThreadSearchMatch,12 type EnvironmentThreadSearchMatch,
13} from "@t3tools/client-runtime/state/thread-search";13} from "@t3tools/client-runtime/state/thread-search";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
16import type {15import type {
17 EnvironmentId,16 EnvironmentId,
18 SidebarProjectGroupingMode,17 SidebarProjectGroupingMode,
...@@ -54,6 +53,7 @@ import {...@@ -54,6 +53,7 @@ import {
54 buildThreadListV2ListItems,53 buildThreadListV2ListItems,
55 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,54 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
56 THREAD_LIST_V2_SETTLED_PAGE_COUNT,55 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
56 type ThreadListV2ChangeRequestState,
57 type ThreadListV2ListItem,57 type ThreadListV2ListItem,
58} from "../threads/threadListV2";58} from "../threads/threadListV2";
59import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";59import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
...@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -491,15 +491,16 @@ export function HomeScreen(props: HomeScreenProps) {
491 // PR states stream in per-row. The next partition applies the configured491 // PR states stream in per-row. The next partition applies the configured
492 // merge rule and the always-on close rule, matching web.492 // merge rule and the always-on close rule, matching web.
493 const [changeRequestByKey, setChangeRequestByKey] = useState<493 const [changeRequestByKey, setChangeRequestByKey] = useState<
494 ReadonlyMap<string, ChangeRequestSettleSource>494 ReadonlyMap<string, ThreadListV2ChangeRequestState>
495 >(() => new Map());495 >(() => new Map());
496 const handleChangeRequestState = useCallback(496 const handleChangeRequestState = useCallback(
497 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {497 (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
498 setChangeRequestByKey((current) => {498 setChangeRequestByKey((current) => {
499 const existing = current.get(threadKey) ?? null;499 const existing = current.get(threadKey) ?? null;
500 if (500 if (
501 (existing?.state ?? null) === (changeRequest?.state ?? null) &&501 (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)
503 ) {504 ) {
504 return current;505 return current;
505 }506 }
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+5-4
...@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";...@@ -12,7 +12,6 @@ import { useAtomValue } from "@effect/atom-react";
12import { AsyncResult } from "effect/unstable/reactivity";12import { AsyncResult } from "effect/unstable/reactivity";
13import type { EnvironmentId } from "@t3tools/contracts";13import type { EnvironmentId } from "@t3tools/contracts";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
16import { useCallback, useEffect, useMemo, useRef, useState } from "react";15import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17import type { LayoutChangeEvent } from "react-native";16import type { LayoutChangeEvent } from "react-native";
18import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";17import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
...@@ -84,6 +83,7 @@ import {...@@ -84,6 +83,7 @@ import {
84 buildThreadListV2ListItems,83 buildThreadListV2ListItems,
85 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,84 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
86 THREAD_LIST_V2_SETTLED_PAGE_COUNT,85 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
86 type ThreadListV2ChangeRequestState,
87 type ThreadListV2ListItem,87 type ThreadListV2ListItem,
88} from "./threadListV2";88} from "./threadListV2";
8989
...@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(...@@ -377,15 +377,16 @@ function ThreadNavigationSidebarPane(
377 // PR states stream in per-row. The next partition applies the configured377 // PR states stream in per-row. The next partition applies the configured
378 // merge rule and the always-on close rule.378 // merge rule and the always-on close rule.
379 const [changeRequestByKey, setChangeRequestByKey] = useState<379 const [changeRequestByKey, setChangeRequestByKey] = useState<
380 ReadonlyMap<string, ChangeRequestSettleSource>380 ReadonlyMap<string, ThreadListV2ChangeRequestState>
381 >(() => new Map());381 >(() => new Map());
382 const handleChangeRequestState = useCallback(382 const handleChangeRequestState = useCallback(
383 (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => {383 (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
384 setChangeRequestByKey((current) => {384 setChangeRequestByKey((current) => {
385 const existing = current.get(threadKey) ?? null;385 const existing = current.get(threadKey) ?? null;
386 if (386 if (
387 (existing?.state ?? null) === (changeRequest?.state ?? null) &&387 (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)
389 ) {390 ) {
390 return current;391 return current;
391 }392 }
apps/mobile/src/features/threads/thread-list-v2-items.tsx+12-11
...@@ -3,11 +3,7 @@ import type {...@@ -3,11 +3,7 @@ import type {
3 EnvironmentThreadShell,3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import {6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
7 canSnooze,
8 resolveSnoozePresets,
9 type ChangeRequestSettleSource,
10} from "@t3tools/client-runtime/state/thread-settled";
11import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
12import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";8import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
13import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";9import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
...@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";...@@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
27import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
28import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
29import {25import {
26 resolveThreadListV2ChangeRequestState,
30 resolveThreadListV2SnoozeMenuSelection,27 resolveThreadListV2SnoozeMenuSelection,
31 resolveThreadListV2SnoozeGateExpiryMs,28 resolveThreadListV2SnoozeGateExpiryMs,
32 resolveThreadListV2Status,29 resolveThreadListV2Status,
33 resolveThreadListV2SwipeActions,30 resolveThreadListV2SwipeActions,
31 type ThreadListV2ChangeRequestState,
34 type ThreadListV2Status,32 type ThreadListV2Status,
35} from "./threadListV2";33} from "./threadListV2";
36import { ThreadSearchMatchExcerpt } from "./thread-search-match";34import { ThreadSearchMatchExcerpt } from "./thread-search-match";
...@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -377,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
377 merge and close rules. Mirrors web's onChangeRequestState. */375 merge and close rules. Mirrors web's onChangeRequestState. */
378 readonly onChangeRequestState?: (376 readonly onChangeRequestState?: (
379 threadKey: string,377 threadKey: string,
380 changeRequest: ChangeRequestSettleSource | null,378 changeRequest: ThreadListV2ChangeRequestState | null,
381 ) => void;379 ) => void;
382 readonly projectCwd?: string | null;380 readonly projectCwd?: string | null;
383 readonly searchMatch?: EnvironmentThreadSearchMatch;381 readonly searchMatch?: EnvironmentThreadSearchMatch;
...@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -411,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
411 const prUpdatedAt = pr?.updatedAt ?? null;409 const prUpdatedAt = pr?.updatedAt ?? null;
412 const threadKey = `${thread.environmentId}:${thread.id}`;410 const threadKey = `${thread.environmentId}:${thread.id}`;
413 useEffect(() => {411 useEffect(() => {
414 onChangeRequestState?.(412 const changeRequest = resolveThreadListV2ChangeRequestState({
415 threadKey,413 linkedPullRequest: thread.linkedPullRequest,
416 prState === null ? null : { state: prState, updatedAt: prUpdatedAt },414 state: prState,
417 );415 updatedAt: prUpdatedAt,
418 }, [onChangeRequestState, prState, prUpdatedAt, threadKey]);416 });
417 if (changeRequest === undefined) return;
418 onChangeRequestState?.(threadKey, changeRequest);
419 }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);
419420
420 const screenColor = useThemeColor("--color-screen");421 const screenColor = useThemeColor("--color-screen");
421 const drawerColor = useThemeColor("--color-drawer");422 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";...@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
16import {16import {
17 buildThreadListV2Items,17 buildThreadListV2Items,
18 buildThreadListV2ListItems,18 buildThreadListV2ListItems,
19 resolveThreadListV2ChangeRequestState,
19 resolveThreadListV2Enabled,20 resolveThreadListV2Enabled,
20 resolveThreadListV2SnoozeMenuSelection,21 resolveThreadListV2SnoozeMenuSelection,
21 resolveThreadListV2SnoozeGateExpiryMs,22 resolveThreadListV2SnoozeGateExpiryMs,
...@@ -53,6 +54,48 @@ function makeThread(...@@ -53,6 +54,48 @@ function makeThread(
53}54}
5455
55const NOW = "2026-06-02T00:00:00.000Z";56const 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
57describe("resolveThreadListV2SnoozeMenuSelection", () => {100describe("resolveThreadListV2SnoozeMenuSelection", () => {
58 it("accepts a displayed evening preset while its wake time is still future", () => {101 it("accepts a displayed evening preset while its wake time is still future", () => {
...@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {...@@ -263,6 +306,58 @@ describe("sortThreadsForListV2", () => {
263});306});
264307
265describe("buildThreadListV2Items", () => {308describe("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
266 it("keeps a merged thread active when auto-settle on merge is off", () => {361 it("keeps a merged thread active when auto-settle on merge is off", () => {
267 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });362 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
268 const layout = buildThreadListV2Items({363 const layout = buildThreadListV2Items({
apps/mobile/src/features/threads/threadListV2.ts+38-3
...@@ -13,7 +13,7 @@ import type {...@@ -13,7 +13,7 @@ import type {
13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
16import type { EnvironmentId, ProjectId } from "@t3tools/contracts";16import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
1717
18import type { PendingNewTask } from "../../state/use-pending-new-tasks";18import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1919
...@@ -30,6 +30,35 @@ export { snoozeWakeLabel };...@@ -30,6 +30,35 @@ export { snoozeWakeLabel };
30export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";30export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
31export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";31export 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
33export function resolveThreadListV2SnoozeMenuSelection(input: {62export function resolveThreadListV2SnoozeMenuSelection(input: {
34 readonly event: string;63 readonly event: string;
35 readonly displayedPresets: ReadonlyArray<SnoozePreset>;64 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
...@@ -322,7 +351,7 @@ export function buildThreadListV2Items(input: {...@@ -322,7 +351,7 @@ export function buildThreadListV2Items(input: {
322 readonly searchQuery: string;351 readonly searchQuery: string;
323 readonly matchedThreadKeys?: ReadonlySet<string>;352 readonly matchedThreadKeys?: ReadonlySet<string>;
324 /** Per-row PR reported up by visible rows ("env:threadId" keys). */353 /** Per-row PR reported up by visible rows ("env:threadId" keys). */
325 readonly changeRequestByKey?: ReadonlyMap<string, ChangeRequestSettleSource>;354 readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>;
326 /** Environments whose server supports thread.settle/unsettle. Threads on355 /** Environments whose server supports thread.settle/unsettle. Threads on
327 other environments never classify as settled — the user could neither356 other environments never classify as settled — the user could neither
328 un-settle nor pin them. Absent = no gating (tests). */357 un-settle nor pin them. Absent = no gating (tests). */
...@@ -384,8 +413,14 @@ export function buildThreadListV2Items(input: {...@@ -384,8 +413,14 @@ export function buildThreadListV2Items(input: {
384 }413 }
385 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;414 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
386 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;415 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
387 const changeRequest =416 const cachedChangeRequest =
388 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;417 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;
389 // Snooze outranks settlement and pinning until the thread wakes.424 // Snooze outranks settlement and pinning until the thread wakes.
390 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {425 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
391 snoozed.push(thread);426 snoozed.push(thread);
apps/mobile/src/state/use-thread-pr.ts+31-1
...@@ -1,9 +1,16 @@...@@ -1,9 +1,16 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import 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";
3import { useEnvironmentQuery } from "./query";8import { useEnvironmentQuery } from "./query";
4import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";9import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
5import { vcsEnvironment } from "./vcs";10import { vcsEnvironment } from "./vcs";
611
12const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);
13
7export {14export {
8 presentThreadPr,15 presentThreadPr,
9 type ThreadPr,16 type ThreadPr,
...@@ -22,13 +29,36 @@ export function useThreadPr(...@@ -22,13 +29,36 @@ export function useThreadPr(
22): ThreadPrPresentation | null {29): ThreadPrPresentation | null {
23 const cwd = thread.worktreePath ?? projectCwd;30 const cwd = thread.worktreePath ?? projectCwd;
24 const gitStatus = useEnvironmentQuery(31 const gitStatus = useEnvironmentQuery(
25 thread.branch !== null && cwd !== null32 thread.linkedPullRequest == null && thread.branch !== null && cwd !== null
26 ? vcsEnvironment.status({33 ? vcsEnvironment.status({
27 environmentId: thread.environmentId,34 environmentId: thread.environmentId,
28 input: { cwd },35 input: { cwd },
29 })36 })
30 : null,37 : null,
31 );38 );
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
33 const status = gitStatus.data;63 const status = gitStatus.data;
34 if (status === null || thread.branch === null || status.refName !== thread.branch) {64 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) => {...@@ -93,6 +93,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
93 expect(second.capabilities.attachmentUploads).toBe(true);93 expect(second.capabilities.attachmentUploads).toBe(true);
94 expect(second.capabilities.pullRequests).toBe(true);94 expect(second.capabilities.pullRequests).toBe(true);
95 expect(second.capabilities.threadTitleRegeneration).toBe(true);95 expect(second.capabilities.threadTitleRegeneration).toBe(true);
96 expect(second.capabilities.threadPullRequestLinking).toBe(true);
96 expect(second.capabilities.agentActivityPublishing).toBe(false);97 expect(second.capabilities.agentActivityPublishing).toBe(false);
97 }),98 }),
98 );99 );
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -153,6 +153,7 @@ export const make = Effect.gen(function* () {...@@ -153,6 +153,7 @@ export const make = Effect.gen(function* () {
153 threadPinning: true,153 threadPinning: true,
154 threadPinReorder: true,154 threadPinReorder: true,
155 threadTitleRegeneration: true,155 threadTitleRegeneration: true,
156 threadPullRequestLinking: true,
156 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),157 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
157 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),158 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
158 },159 },
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
...@@ -634,6 +634,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -634,6 +634,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
634 interactionMode: event.payload.interactionMode,634 interactionMode: event.payload.interactionMode,
635 branch: event.payload.branch,635 branch: event.payload.branch,
636 worktreePath: event.payload.worktreePath,636 worktreePath: event.payload.worktreePath,
637 linkedPullRequest: null,
637 latestTurnId: null,638 latestTurnId: null,
638 createdAt: event.payload.createdAt,639 createdAt: event.payload.createdAt,
639 updatedAt: event.payload.updatedAt,640 updatedAt: event.payload.updatedAt,
...@@ -822,6 +823,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -822,6 +823,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
822 ...(event.payload.worktreePath !== undefined823 ...(event.payload.worktreePath !== undefined
823 ? { worktreePath: event.payload.worktreePath }824 ? { worktreePath: event.payload.worktreePath }
824 : {}),825 : {}),
826 ...(event.payload.linkedPullRequest !== undefined
827 ? { linkedPullRequest: event.payload.linkedPullRequest }
828 : {}),
825 updatedAt: event.payload.updatedAt,829 updatedAt: event.payload.updatedAt,
826 });830 });
827 return;831 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+14
...@@ -82,6 +82,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -82,6 +82,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
82 interaction_mode,82 interaction_mode,
83 branch,83 branch,
84 worktree_path,84 worktree_path,
85 linked_pull_request_json,
85 latest_turn_id,86 latest_turn_id,
86 latest_user_message_at,87 latest_user_message_at,
87 pending_approval_count,88 pending_approval_count,
...@@ -102,6 +103,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -102,6 +103,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
102 'default',103 'default',
103 NULL,104 NULL,
104 NULL,105 NULL,
106 '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',
105 'turn-1',107 'turn-1',
106 '2026-02-24T00:00:04.000Z',108 '2026-02-24T00:00:04.000Z',
107 1,109 1,
...@@ -304,6 +306,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -304,6 +306,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
304 runtimeMode: "full-access",306 runtimeMode: "full-access",
305 branch: null,307 branch: null,
306 worktreePath: null,308 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 },
307 latestTurn: {315 latestTurn: {
308 turnId: asTurnId("turn-1"),316 turnId: asTurnId("turn-1"),
309 state: "completed",317 state: "completed",
...@@ -423,6 +431,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -423,6 +431,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
423 runtimeMode: "full-access",431 runtimeMode: "full-access",
424 branch: null,432 branch: null,
425 worktreePath: null,433 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 },
426 latestTurn: {440 latestTurn: {
427 turnId: asTurnId("turn-1"),441 turnId: asTurnId("turn-1"),
428 state: "completed",442 state: "completed",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+24
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 type OrchestrationThreadShell,24 type OrchestrationThreadShell,
25 ModelSelection,25 ModelSelection,
26 ProjectId,26 ProjectId,
27 ThreadLinkedPullRequest,
27 ThreadId,28 ThreadId,
28} from "@t3tools/contracts";29} from "@t3tools/contracts";
29import * as Arr from "effect/Array";30import * as Arr from "effect/Array";
...@@ -89,6 +90,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;...@@ -89,6 +90,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
89const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(90const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
90 Struct.assign({91 Struct.assign({
91 modelSelection: Schema.fromJsonString(ModelSelection),92 modelSelection: Schema.fromJsonString(ModelSelection),
93 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
92 }),94 }),
93);95);
94const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(96const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
...@@ -422,6 +424,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -422,6 +424,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
422 interaction_mode AS "interactionMode",424 interaction_mode AS "interactionMode",
423 branch,425 branch,
424 worktree_path AS "worktreePath",426 worktree_path AS "worktreePath",
427 linked_pull_request_json AS "linkedPullRequest",
425 latest_turn_id AS "latestTurnId",428 latest_turn_id AS "latestTurnId",
426 created_at AS "createdAt",429 created_at AS "createdAt",
427 updated_at AS "updatedAt",430 updated_at AS "updatedAt",
...@@ -458,6 +461,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -458,6 +461,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
458 interaction_mode AS "interactionMode",461 interaction_mode AS "interactionMode",
459 branch,462 branch,
460 worktree_path AS "worktreePath",463 worktree_path AS "worktreePath",
464 linked_pull_request_json AS "linkedPullRequest",
461 latest_turn_id AS "latestTurnId",465 latest_turn_id AS "latestTurnId",
462 created_at AS "createdAt",466 created_at AS "createdAt",
463 updated_at AS "updatedAt",467 updated_at AS "updatedAt",
...@@ -496,6 +500,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -496,6 +500,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
496 interaction_mode AS "interactionMode",500 interaction_mode AS "interactionMode",
497 branch,501 branch,
498 worktree_path AS "worktreePath",502 worktree_path AS "worktreePath",
503 linked_pull_request_json AS "linkedPullRequest",
499 latest_turn_id AS "latestTurnId",504 latest_turn_id AS "latestTurnId",
500 created_at AS "createdAt",505 created_at AS "createdAt",
501 updated_at AS "updatedAt",506 updated_at AS "updatedAt",
...@@ -938,6 +943,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -938,6 +943,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
938 interaction_mode AS "interactionMode",943 interaction_mode AS "interactionMode",
939 branch,944 branch,
940 worktree_path AS "worktreePath",945 worktree_path AS "worktreePath",
946 linked_pull_request_json AS "linkedPullRequest",
941 latest_turn_id AS "latestTurnId",947 latest_turn_id AS "latestTurnId",
942 created_at AS "createdAt",948 created_at AS "createdAt",
943 updated_at AS "updatedAt",949 updated_at AS "updatedAt",
...@@ -1694,6 +1700,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1694,6 +1700,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1694 interactionMode: row.interactionMode,1700 interactionMode: row.interactionMode,
1695 branch: row.branch,1701 branch: row.branch,
1696 worktreePath: row.worktreePath,1702 worktreePath: row.worktreePath,
1703 ...(row.linkedPullRequest === null
1704 ? {}
1705 : { linkedPullRequest: row.linkedPullRequest }),
1697 latestTurn: latestTurnByThread.get(row.threadId) ?? null,1706 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
1698 createdAt: row.createdAt,1707 createdAt: row.createdAt,
1699 updatedAt: row.updatedAt,1708 updatedAt: row.updatedAt,
...@@ -1901,6 +1910,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1901,6 +1910,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1901 interactionMode: row.interactionMode,1910 interactionMode: row.interactionMode,
1902 branch: row.branch,1911 branch: row.branch,
1903 worktreePath: row.worktreePath,1912 worktreePath: row.worktreePath,
1913 ...(row.linkedPullRequest === null
1914 ? {}
1915 : { linkedPullRequest: row.linkedPullRequest }),
1904 latestTurn: latestTurnByThread.get(row.threadId) ?? null,1916 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
1905 createdAt: row.createdAt,1917 createdAt: row.createdAt,
1906 updatedAt: row.updatedAt,1918 updatedAt: row.updatedAt,
...@@ -2037,6 +2049,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2037,6 +2049,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2037 interactionMode: row.interactionMode,2049 interactionMode: row.interactionMode,
2038 branch: row.branch,2050 branch: row.branch,
2039 worktreePath: row.worktreePath,2051 worktreePath: row.worktreePath,
2052 ...(row.linkedPullRequest === null
2053 ? {}
2054 : { linkedPullRequest: row.linkedPullRequest }),
2040 latestTurn: latestTurnByThread.get(row.threadId) ?? null,2055 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2041 createdAt: row.createdAt,2056 createdAt: row.createdAt,
2042 updatedAt: row.updatedAt,2057 updatedAt: row.updatedAt,
...@@ -2182,6 +2197,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2182,6 +2197,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2182 interactionMode: row.interactionMode,2197 interactionMode: row.interactionMode,
2183 branch: row.branch,2198 branch: row.branch,
2184 worktreePath: row.worktreePath,2199 worktreePath: row.worktreePath,
2200 ...(row.linkedPullRequest === null
2201 ? {}
2202 : { linkedPullRequest: row.linkedPullRequest }),
2185 latestTurn: latestTurnByThread.get(row.threadId) ?? null,2203 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2186 createdAt: row.createdAt,2204 createdAt: row.createdAt,
2187 updatedAt: row.updatedAt,2205 updatedAt: row.updatedAt,
...@@ -2461,6 +2479,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2461,6 +2479,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2461 interactionMode: threadRow.value.interactionMode,2479 interactionMode: threadRow.value.interactionMode,
2462 branch: threadRow.value.branch,2480 branch: threadRow.value.branch,
2463 worktreePath: threadRow.value.worktreePath,2481 worktreePath: threadRow.value.worktreePath,
2482 ...(threadRow.value.linkedPullRequest === null
2483 ? {}
2484 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
2464 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,2485 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
2465 createdAt: threadRow.value.createdAt,2486 createdAt: threadRow.value.createdAt,
2466 updatedAt: threadRow.value.updatedAt,2487 updatedAt: threadRow.value.updatedAt,
...@@ -2602,6 +2623,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2602,6 +2623,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2602 interactionMode: threadRow.value.interactionMode,2623 interactionMode: threadRow.value.interactionMode,
2603 branch: threadRow.value.branch,2624 branch: threadRow.value.branch,
2604 worktreePath: threadRow.value.worktreePath,2625 worktreePath: threadRow.value.worktreePath,
2626 ...(threadRow.value.linkedPullRequest === null
2627 ? {}
2628 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
2605 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,2629 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
2606 createdAt: threadRow.value.createdAt,2630 createdAt: threadRow.value.createdAt,
2607 updatedAt: threadRow.value.updatedAt,2631 updatedAt: threadRow.value.updatedAt,
apps/server/src/orchestration/decider.ts+3
...@@ -847,6 +847,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -847,6 +847,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
847 : {}),847 : {}),
848 ...(branch !== undefined ? { branch } : {}),848 ...(branch !== undefined ? { branch } : {}),
849 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),849 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
850 ...(command.linkedPullRequest !== undefined
851 ? { linkedPullRequest: command.linkedPullRequest }
852 : {}),
850 updatedAt: occurredAt,853 updatedAt: occurredAt,
851 },854 },
852 };855 };
apps/server/src/orchestration/projector.ts+3
...@@ -456,6 +456,9 @@ export function projectEvent(...@@ -456,6 +456,9 @@ export function projectEvent(
456 : {}),456 : {}),
457 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),457 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
458 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),458 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
459 ...(payload.linkedPullRequest !== undefined
460 ? { linkedPullRequest: payload.linkedPullRequest }
461 : {}),
459 updatedAt: payload.updatedAt,462 updatedAt: payload.updatedAt,
460 }),463 }),
461 })),464 })),
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+51
...@@ -201,4 +201,55 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -201,4 +201,55 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
201 assert.strictEqual(updated?.pinnedAt, null);201 assert.strictEqual(updated?.pinnedAt, null);
202 }),202 }),
203 );203 );
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 );
204});255});
apps/server/src/persistence/Layers/ProjectionThreads.ts+7-1
...@@ -14,11 +14,12 @@ import {...@@ -14,11 +14,12 @@ import {
14 ProjectionThreadRepository,14 ProjectionThreadRepository,
15 type ProjectionThreadRepositoryShape,15 type ProjectionThreadRepositoryShape,
16} from "../Services/ProjectionThreads.ts";16} from "../Services/ProjectionThreads.ts";
17import { ModelSelection } from "@t3tools/contracts";17import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts";
1818
19const ProjectionThreadDbRow = ProjectionThread.mapFields(19const ProjectionThreadDbRow = ProjectionThread.mapFields(
20 Struct.assign({20 Struct.assign({
21 modelSelection: Schema.fromJsonString(ModelSelection),21 modelSelection: Schema.fromJsonString(ModelSelection),
22 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
22 }),23 }),
23);24);
24type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;25type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
...@@ -39,6 +40,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -39,6 +40,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
39 interaction_mode,40 interaction_mode,
40 branch,41 branch,
41 worktree_path,42 worktree_path,
43 linked_pull_request_json,
42 latest_turn_id,44 latest_turn_id,
43 created_at,45 created_at,
44 updated_at,46 updated_at,
...@@ -66,6 +68,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -66,6 +68,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
66 ${row.interactionMode},68 ${row.interactionMode},
67 ${row.branch},69 ${row.branch},
68 ${row.worktreePath},70 ${row.worktreePath},
71 ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)},
69 ${row.latestTurnId},72 ${row.latestTurnId},
70 ${row.createdAt},73 ${row.createdAt},
71 ${row.updatedAt},74 ${row.updatedAt},
...@@ -93,6 +96,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -93,6 +96,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
93 interaction_mode = excluded.interaction_mode,96 interaction_mode = excluded.interaction_mode,
94 branch = excluded.branch,97 branch = excluded.branch,
95 worktree_path = excluded.worktree_path,98 worktree_path = excluded.worktree_path,
99 linked_pull_request_json = excluded.linked_pull_request_json,
96 latest_turn_id = excluded.latest_turn_id,100 latest_turn_id = excluded.latest_turn_id,
97 created_at = excluded.created_at,101 created_at = excluded.created_at,
98 updated_at = excluded.updated_at,102 updated_at = excluded.updated_at,
...@@ -127,6 +131,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -127,6 +131,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
127 interaction_mode AS "interactionMode",131 interaction_mode AS "interactionMode",
128 branch,132 branch,
129 worktree_path AS "worktreePath",133 worktree_path AS "worktreePath",
134 linked_pull_request_json AS "linkedPullRequest",
130 latest_turn_id AS "latestTurnId",135 latest_turn_id AS "latestTurnId",
131 created_at AS "createdAt",136 created_at AS "createdAt",
132 updated_at AS "updatedAt",137 updated_at AS "updatedAt",
...@@ -163,6 +168,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -163,6 +168,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
163 interaction_mode AS "interactionMode",168 interaction_mode AS "interactionMode",
164 branch,169 branch,
165 worktree_path AS "worktreePath",170 worktree_path AS "worktreePath",
171 linked_pull_request_json AS "linkedPullRequest",
166 latest_turn_id AS "latestTurnId",172 latest_turn_id AS "latestTurnId",
167 created_at AS "createdAt",173 created_at AS "createdAt",
168 updated_at AS "updatedAt",174 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";...@@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
56import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";56import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
57import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
5758
58/**59/**
59 * Migration loader with all migrations defined inline.60 * Migration loader with all migrations defined inline.
...@@ -107,6 +108,7 @@ export const migrationEntries = [...@@ -107,6 +108,7 @@ export const migrationEntries = [
107 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],108 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
108 [40, "ProjectionProjectFaviconPath", Migration0040],109 [40, "ProjectionProjectFaviconPath", Migration0040],
109 [41, "AuthSessionClientConnection", Migration0041],110 [41, "AuthSessionClientConnection", Migration0041],
111 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
110] as const;112] as const;
111113
112export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);114export 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 {...@@ -14,6 +14,7 @@ import {
14 ProjectId,14 ProjectId,
15 ProviderInteractionMode,15 ProviderInteractionMode,
16 RuntimeMode,16 RuntimeMode,
17 ThreadLinkedPullRequest,
17 ThreadId,18 ThreadId,
18 TurnId,19 TurnId,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
...@@ -33,6 +34,7 @@ export const ProjectionThread = Schema.Struct({...@@ -33,6 +34,7 @@ export const ProjectionThread = Schema.Struct({
33 interactionMode: ProviderInteractionMode,34 interactionMode: ProviderInteractionMode,
34 branch: Schema.NullOr(Schema.String),35 branch: Schema.NullOr(Schema.String),
35 worktreePath: Schema.NullOr(Schema.String),36 worktreePath: Schema.NullOr(Schema.String),
37 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
36 latestTurnId: Schema.NullOr(TurnId),38 latestTurnId: Schema.NullOr(TurnId),
37 createdAt: IsoDateTime,39 createdAt: IsoDateTime,
38 updatedAt: IsoDateTime,40 updatedAt: IsoDateTime,
apps/web/src/components/ChatMarkdown.test.tsx+8-1
...@@ -11,10 +11,17 @@ vi.mock("../state/session", async (importOriginal) => ({...@@ -11,10 +11,17 @@ vi.mock("../state/session", async (importOriginal) => ({
11 usePreparedConnection: () => ({ _tag: "Loading" }),11 usePreparedConnection: () => ({ _tag: "Loading" }),
12}));12}));
13vi.mock("../state/entities", () => ({13vi.mock("../state/entities", () => ({
14 readThreadShell: () => null,
14 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),15 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
16 useProjects: () => [],
15}));17}));
16vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));18vi.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
19import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";26import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";
2027
apps/web/src/components/ChatMarkdown.tsx+99-3
...@@ -13,7 +13,11 @@ import {...@@ -13,7 +13,11 @@ import {
13 TriangleAlertIcon,13 TriangleAlertIcon,
14 WrapTextIcon,14 WrapTextIcon,
15} from "lucide-react";15} from "lucide-react";
16import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";16import type {
17 ScopedThreadRef,
18 ServerProviderSkill,
19 ThreadLinkedPullRequest,
20} from "@t3tools/contracts";
17import {21import {
18 isAtomCommandInterrupted,22 isAtomCommandInterrupted,
19 squashAtomCommandFailure,23 squashAtomCommandFailure,
...@@ -88,7 +92,7 @@ import { readLocalApi } from "../localApi";...@@ -88,7 +92,7 @@ import { readLocalApi } from "../localApi";
88import { useAssetUrlState } from "../assets/assetUrls";92import { useAssetUrlState } from "../assets/assetUrls";
89import { cn } from "../lib/utils";93import { cn } from "../lib/utils";
90import { useRightPanelStore } from "../rightPanelStore";94import { useRightPanelStore } from "../rightPanelStore";
91import { useActiveEnvironmentId } from "../state/entities";95import { readThreadShell, useActiveEnvironmentId, useProjects } from "../state/entities";
92import { serverEnvironment } from "../state/server";96import { serverEnvironment } from "../state/server";
93import { assetEnvironment } from "../state/assets";97import { assetEnvironment } from "../state/assets";
94import { usePreparedConnection } from "../state/session";98import { usePreparedConnection } from "../state/session";
...@@ -96,13 +100,19 @@ import { previewEnvironment } from "../state/preview";...@@ -96,13 +100,19 @@ import { previewEnvironment } from "../state/preview";
96import { useAtomCommand } from "../state/use-atom-command";100import { useAtomCommand } from "../state/use-atom-command";
97import { useAtomQueryRunner } from "../state/use-atom-query-runner";101import { useAtomQueryRunner } from "../state/use-atom-query-runner";
98import { projectEnvironment } from "../state/projects";102import { projectEnvironment } from "../state/projects";
103import { threadEnvironment } from "../state/threads";
99import {104import {
100 claimWorkspaceBasenameLookup,105 claimWorkspaceBasenameLookup,
101 needsWorkspaceBasenameLookup,106 needsWorkspaceBasenameLookup,
102 pickWorkspaceBasenameMatch,107 pickWorkspaceBasenameMatch,
103 WORKSPACE_BASENAME_LOOKUP_LIMIT,108 WORKSPACE_BASENAME_LOOKUP_LIMIT,
104} from "../workspaceBasenameLookup";109} from "../workspaceBasenameLookup";
105import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";110import {
111 findProjectForChangeRequest,
112 matchesLinkedPullRequestUrl,
113 parseChangeRequestUrl,
114 useOpenChangeRequestLink,
115} from "~/lib/openPullRequestLink";
106import { writeTextToClipboard } from "../hooks/useCopyToClipboard";116import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
107import { isPreviewSupportedInRuntime } from "../previewStateStore";117import { isPreviewSupportedInRuntime } from "../previewStateStore";
108import {118import {
...@@ -1472,9 +1482,16 @@ function ChatMarkdown({...@@ -1472,9 +1482,16 @@ function ChatMarkdown({
1472 const openPreview = useAtomCommand(previewEnvironment.open, {1482 const openPreview = useAtomCommand(previewEnvironment.open, {
1473 reportFailure: false,1483 reportFailure: false,
1474 });1484 });
1485 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1486 reportFailure: false,
1487 });
1475 const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null);1488 const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null);
1476 const environmentId = useActiveEnvironmentId();1489 const environmentId = useActiveEnvironmentId();
1477 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));1490 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
1491 const threadServerConfig = useAtomValue(
1492 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
1493 );
1494 const projects = useProjects();
1478 const openInPreferredEditor = useOpenInPreferredEditor(1495 const openInPreferredEditor = useOpenInPreferredEditor(
1479 environmentId,1496 environmentId,
1480 serverConfig?.availableEditors ?? [],1497 serverConfig?.availableEditors ?? [],
...@@ -1528,6 +1545,54 @@ function ChatMarkdown({...@@ -1528,6 +1545,54 @@ function ChatMarkdown({
1528 event.clipboardData.setData("text/html", payload.html);1545 event.clipboardData.setData("text/html", payload.html);
1529 }, []);1546 }, []);
1530 const openChangeRequestLink = useOpenChangeRequestLink(threadRef);1547 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 );
1531 const openExternalLinkInPreview = useCallback(1596 const openExternalLinkInPreview = useCallback(
1532 (url: string) => {1597 (url: string) => {
1533 if (!threadRef) {1598 if (!threadRef) {
...@@ -1760,9 +1825,20 @@ function ChatMarkdown({...@@ -1760,9 +1825,20 @@ function ChatMarkdown({
1760 event.stopPropagation();1825 event.stopPropagation();
1761 const api = readLocalApi();1826 const api = readLocalApi();
1762 if (!api) return;1827 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";
1763 void showExternalLinkContextMenu({1838 void showExternalLinkContextMenu({
1764 href,1839 href,
1765 canOpenInPreview,1840 canOpenInPreview,
1841 threadLinkAction,
1766 position: { x: event.clientX, y: event.clientY },1842 position: { x: event.clientX, y: event.clientY },
1767 showContextMenu: (items, position) => api.contextMenu.show(items, position),1843 showContextMenu: (items, position) => api.contextMenu.show(items, position),
1768 openInPreview: async (target) => {1844 openInPreview: async (target) => {
...@@ -1776,8 +1852,25 @@ function ChatMarkdown({...@@ -1776,8 +1852,25 @@ function ChatMarkdown({
1776 },1852 },
1777 openExternal: (target) => api.shell.openExternal(target),1853 openExternal: (target) => api.shell.openExternal(target),
1778 copyLink: (target) => writeTextToClipboard(target, "link"),1854 copyLink: (target) => writeTextToClipboard(target, "link"),
1855 updateThreadLink: updateThreadPullRequestLink,
1779 reportFailure: (operation, cause) => {1856 reportFailure: (operation, cause) => {
1780 reportMarkdownActionFailure({ operation, target: href }, cause);1857 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 }
1781 },1874 },
1782 });1875 });
1783 }}1876 }}
...@@ -1900,12 +1993,15 @@ function ChatMarkdown({...@@ -1900,12 +1993,15 @@ function ChatMarkdown({
1900 onTaskListChange,1993 onTaskListChange,
1901 openFileInPanel,1994 openFileInPanel,
1902 openInPreferredEditor,1995 openInPreferredEditor,
1996 openChangeRequestLink,
1903 openExternalLinkInPreview,1997 openExternalLinkInPreview,
1904 openMarkdownFileInPreview,1998 openMarkdownFileInPreview,
1999 resolveThreadPullRequest,
1905 resolvedTheme,2000 resolvedTheme,
1906 skills,2001 skills,
1907 text,2002 text,
1908 threadRef,2003 threadRef,
2004 updateThreadPullRequestLink,
1909 ]);2005 ]);
1910 /* eslint-enable react/no-unstable-nested-components */2006 /* 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) => ({...@@ -24,10 +24,17 @@ vi.mock("../state/session", async (importOriginal) => ({
24 usePreparedConnection: () => ({ _tag: "Loading" }),24 usePreparedConnection: () => ({ _tag: "Loading" }),
25}));25}));
26vi.mock("../state/entities", () => ({26vi.mock("../state/entities", () => ({
27 readThreadShell: () => null,
27 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),28 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
29 useProjects: () => [],
28}));30}));
29vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));31vi.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
32import ChatMarkdown from "./ChatMarkdown";39import ChatMarkdown from "./ChatMarkdown";
3340
apps/web/src/components/ChatView.tsx+38-7
...@@ -302,6 +302,7 @@ import {...@@ -302,6 +302,7 @@ import {
302import {302import {
303 resolveDisplayedThreadPr,303 resolveDisplayedThreadPr,
304 threadChangeRequestSnapshotsAtom,304 threadChangeRequestSnapshotsAtom,
305 useLinkedThreadPullRequest,
305} from "./ThreadStatusIndicators";306} from "./ThreadStatusIndicators";
306import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";307import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
307import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";308import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
...@@ -3412,24 +3413,48 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3412,24 +3413,48 @@ function ChatViewContent(props: ChatViewProps) {
3412 );3413 );
3413 // The thread's own change request, placed against the project it belongs to. Without a3414 // The thread's own change request, placed against the project it belongs to. Without a
3414 // project there is nothing to resolve it against, so the caller falls back to the browser.3415 // 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;
3416 const openThreadPullRequest = useCallback(3419 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(
3417 (number: number) => {3442 (number: number) => {
3418 if (3443 if (
3419 !supportsPullRequests ||3444 !supportsPullRequests ||
3420 !activeThreadRef ||3445 !activeThreadRef ||
3421 !activeProject ||3446 !activeProject ||
3422 threadRepository === null3447 activeProjectRepository === null
3423 ) {3448 ) {
3424 return;3449 return;
3425 }3450 }
3426 useRightPanelStore.getState().openPullRequest(activeThreadRef, {3451 useRightPanelStore.getState().openPullRequest(activeThreadRef, {
3427 projectId: activeProject.id,3452 projectId: activeProject.id,
3428 repository: threadRepository,3453 repository: activeProjectRepository,
3429 number,3454 number,
3430 });3455 });
3431 },3456 },
3432 [activeProject, activeThreadRef, supportsPullRequests, threadRepository],3457 [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],
3433 );3458 );
3434 const togglePreviewPanel = useCallback(() => {3459 const togglePreviewPanel = useCallback(() => {
3435 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;3460 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
...@@ -4307,11 +4332,17 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4307,11 +4332,17 @@ function ChatViewContent(props: ChatViewProps) {
4307 : null;4332 : null;
4308 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);4333 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4309 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);4334 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4335 const linkedPullRequestStatus = useLinkedThreadPullRequest(
4336 activeThreadRef?.environmentId ?? null,
4337 linkedThreadPullRequest,
4338 );
4310 const activeThreadPr = resolveDisplayedThreadPr({4339 const activeThreadPr = resolveDisplayedThreadPr({
4311 threadBranch: activeThread?.branch ?? null,4340 threadBranch: activeThread?.branch ?? null,
4312 gitStatus: gitStatusQuery.data ?? null,4341 gitStatus: gitStatusQuery.data ?? null,
4313 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,4342 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
4314 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,4343 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
4344 linkedPullRequest: linkedThreadPullRequest,
4345 linkedPullRequestStatus,
4315 });4346 });
4316 // The right panel offers the thread's own change request, so it can only offer it once the4347 // The right panel offers the thread's own change request, so it can only offer it once the
4317 // branch has one; until then the picker says so rather than opening an empty panel.4348 // branch has one; until then the picker says so rather than opening an empty panel.
...@@ -6578,7 +6609,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6578,7 +6609,7 @@ function ChatViewContent(props: ChatViewProps) {
6578 context={6609 context={
6579 isThreadOwnPullRequest(6610 isThreadOwnPullRequest(
6580 {6611 {
6581 projectId: activeProject?.id ?? null,6612 projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null,
6582 repository: threadRepository,6613 repository: threadRepository,
6583 number: activeThreadPr?.number ?? null,6614 number: activeThreadPr?.number ?? null,
6584 },6615 },
...@@ -6651,9 +6682,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6651,9 +6682,9 @@ function ChatViewContent(props: ChatViewProps) {
6651 >6682 >
6652 {!rightPanelOpen ? panelLayoutControls : null}6683 {!rightPanelOpen ? panelLayoutControls : null}
6653 <ChatHeader6684 <ChatHeader
6654 {...(!supportsPullRequests || threadRepository === null6685 {...(!supportsPullRequests || activeProjectRepository === null
6655 ? {}6686 ? {}
6656 : { onOpenPullRequest: openThreadPullRequest })}6687 : { onOpenPullRequest: openProjectPullRequest })}
6657 activeThreadEnvironmentId={activeThread.environmentId}6688 activeThreadEnvironmentId={activeThread.environmentId}
6658 activeThreadId={activeThread.id}6689 activeThreadId={activeThread.id}
6659 {...(routeKind === "draft" && draftId ? { draftId } : {})}6690 {...(routeKind === "draft" && draftId ? { draftId } : {})}
apps/web/src/components/LegacySidebar.tsx+14-6
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 terminalStatusFromRunningIds,20 terminalStatusFromRunningIds,
21 ThreadStatusLabel,21 ThreadStatusLabel,
22 ThreadWorktreeIndicator,22 ThreadWorktreeIndicator,
23 useLinkedThreadPullRequest,
23} from "./ThreadStatusIndicators";24} from "./ThreadStatusIndicators";
24import { ProjectFavicon } from "./ProjectFavicon";25import { ProjectFavicon } from "./ProjectFavicon";
25import { useAtomValue } from "@effect/atom-react";26import { useAtomValue } from "@effect/atom-react";
...@@ -416,7 +417,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -416,7 +417,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
416 const threadProjectCwd = threadProject?.workspaceRoot ?? null;417 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
417 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;418 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
418 const gitStatus = useEnvironmentQuery(419 const gitStatus = useEnvironmentQuery(
419 thread.branch != null && gitCwd !== null420 thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null
420 ? vcsEnvironment.status({421 ? vcsEnvironment.status({
421 environmentId: thread.environmentId,422 environmentId: thread.environmentId,
422 input: { cwd: gitCwd },423 input: { cwd: gitCwd },
...@@ -457,11 +458,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -457,11 +458,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
457 lastVisitedAt,458 lastVisitedAt,
458 },459 },
459 });460 });
460 const pr = resolveThreadPr({461 const linkedPullRequestStatus = useLinkedThreadPullRequest(
461 threadBranch: thread.branch,462 thread.environmentId,
462 gitStatus: gitStatus.data,463 thread.linkedPullRequest,
463 });464 );
464 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);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 );
465 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);473 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
466 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;474 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
467 const threadMetaClassName = isConfirmingArchive475 const threadMetaClassName = isConfirmingArchive
apps/web/src/components/Sidebar.tsx+19-1
...@@ -156,6 +156,7 @@ import {...@@ -156,6 +156,7 @@ import {
156 threadChangeRequestSnapshotsAtom,156 threadChangeRequestSnapshotsAtom,
157 type ThreadChangeRequestSnapshot,157 type ThreadChangeRequestSnapshot,
158 type TerminalStatusIndicator,158 type TerminalStatusIndicator,
159 useLinkedThreadPullRequest,
159} from "./ThreadStatusIndicators";160} from "./ThreadStatusIndicators";
160import {161import {
161 resolveSnoozePresets,162 resolveSnoozePresets,
...@@ -787,6 +788,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -787,6 +788,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
787 const terminalProcessCount = runningTerminalIds.length;788 const terminalProcessCount = runningTerminalIds.length;
788789
789 const gitCwd = thread.worktreePath ?? props.projectCwd;790 const gitCwd = thread.worktreePath ?? props.projectCwd;
791 const linkedPullRequestStatus = useLinkedThreadPullRequest(
792 thread.environmentId,
793 thread.linkedPullRequest,
794 );
790 const gitStatus = useEnvironmentQuery(795 const gitStatus = useEnvironmentQuery(
791 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null796 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
792 ? vcsEnvironment.status({797 ? vcsEnvironment.status({
...@@ -801,6 +806,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -801,6 +806,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
801 gitStatus: gitStatus.data,806 gitStatus: gitStatus.data,
802 snapshot: changeRequestSnapshot,807 snapshot: changeRequestSnapshot,
803 retainTerminalOnBranchMismatch,808 retainTerminalOnBranchMismatch,
809 linkedPullRequest: thread.linkedPullRequest,
810 linkedPullRequestStatus,
804 });811 });
805812
806 // Same semantics as the legacy sidebar (never-visited counts as read):813 // Same semantics as the legacy sidebar (never-visited counts as read):
...@@ -901,6 +908,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -901,6 +908,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
901 gitStatus: gitStatus.data,908 gitStatus: gitStatus.data,
902 snapshot: changeRequestSnapshot,909 snapshot: changeRequestSnapshot,
903 retainTerminalOnBranchMismatch,910 retainTerminalOnBranchMismatch,
911 linkedPullRequest: thread.linkedPullRequest,
912 linkedPullRequestStatus,
904 });913 });
905 const prStatus = prStatusIndicator(pr, prProvider);914 const prStatus = prStatusIndicator(pr, prProvider);
906 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;915 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
...@@ -910,15 +919,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -910,15 +919,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
910 gitStatus: gitStatus.data,919 gitStatus: gitStatus.data,
911 snapshot: changeRequestSnapshot,920 snapshot: changeRequestSnapshot,
912 retainTerminalOnBranchMismatch,921 retainTerminalOnBranchMismatch,
922 linkedPullRequest: thread.linkedPullRequest,
923 linkedPullRequestStatus,
913 });924 });
914 if (nextSnapshot === undefined) return;925 if (nextSnapshot === undefined) return;
915 onChangeRequestSnapshot(threadKey, nextSnapshot);926 onChangeRequestSnapshot(threadKey, nextSnapshot);
916 }, [927 }, [
917 changeRequestSnapshot,928 changeRequestSnapshot,
918 gitStatus.data,929 gitStatus.data,
930 linkedPullRequestStatus,
919 onChangeRequestSnapshot,931 onChangeRequestSnapshot,
920 retainTerminalOnBranchMismatch,932 retainTerminalOnBranchMismatch,
921 thread.branch,933 thread.branch,
934 thread.linkedPullRequest,
922 threadKey,935 threadKey,
923 ]);936 ]);
924937
...@@ -2039,7 +2052,12 @@ export default function Sidebar() {...@@ -2039,7 +2052,12 @@ export default function Sidebar() {
2039 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));2052 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
2040 const snapshot = changeRequestSnapshotByKey.get(threadKey);2053 const snapshot = changeRequestSnapshotByKey.get(threadKey);
2041 const changeRequest =2054 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)
2043 ? snapshot.pr2061 ? snapshot.pr
2044 : null;2062 : null;
2045 // Snooze outranks settlement and pinning until the thread wakes.2063 // Snooze outranks settlement and pinning until the thread wakes.
apps/web/src/components/ThreadStatusIndicators.test.ts+119
...@@ -101,6 +101,12 @@ describe("resolveThreadPr", () => {...@@ -101,6 +101,12 @@ describe("resolveThreadPr", () => {
101describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {101describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
102 const featureBranch = "feature/current";102 const featureBranch = "feature/current";
103 const mergedPr = mergedFeaturePr();103 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 };
104 const provider = {110 const provider = {
105 kind: "github" as const,111 kind: "github" as const,
106 name: "GitHub",112 name: "GitHub",
...@@ -132,6 +138,119 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -132,6 +138,119 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
132 ).toEqual(provider);138 ).toEqual(provider);
133 });139 });
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
135 it("after caching a merged PR, resolves main status back to the cached feature PR", () => {254 it("after caching a merged PR, resolves main status back to the cached feature PR", () => {
136 const matchingStatus = status({255 const matchingStatus = status({
137 refName: featureBranch,256 refName: featureBranch,
apps/web/src/components/ThreadStatusIndicators.tsx+136-12
...@@ -3,7 +3,8 @@ import {...@@ -3,7 +3,8 @@ import {
3 scopedThreadKey,3 scopedThreadKey,
4 scopeThreadRef,4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";5} 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";
7import { Atom } from "effect/unstable/reactivity";8import { Atom } from "effect/unstable/reactivity";
8import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";9import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
9import { useMemo } from "react";10import { useMemo } from "react";
...@@ -11,6 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";...@@ -11,6 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
11import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";12import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
12import { useProject } from "../state/entities";13import { useProject } from "../state/entities";
13import { useEnvironmentQuery } from "../state/query";14import { useEnvironmentQuery } from "../state/query";
15import { linkedPullRequestDetailAtom } from "../state/pullRequests";
14import { useThreadRunningTerminalIds } from "../state/terminalSessions";16import { useThreadRunningTerminalIds } from "../state/terminalSessions";
15import { vcsEnvironment } from "../state/vcs";17import { vcsEnvironment } from "../state/vcs";
16import { useUiStateStore } from "../uiStateStore";18import { useUiStateStore } from "../uiStateStore";
...@@ -37,6 +39,44 @@ export interface TerminalStatusIndicator {...@@ -37,6 +39,44 @@ export interface TerminalStatusIndicator {
3739
38export type ThreadPr = VcsStatusResult["pr"];40export 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
40export function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"]): string {80export function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"]): string {
41 switch (state) {81 switch (state) {
42 case "open":82 case "open":
...@@ -136,6 +176,7 @@ export interface ThreadChangeRequestSnapshot {...@@ -136,6 +176,7 @@ export interface ThreadChangeRequestSnapshot {
136 readonly branch: string;176 readonly branch: string;
137 readonly pr: NonNullable<ThreadPr>;177 readonly pr: NonNullable<ThreadPr>;
138 readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined;178 readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined;
179 readonly linkedPullRequest?: ThreadLinkedPullRequest;
139}180}
140181
141export const threadChangeRequestSnapshotsAtom = Atom.make<182export const threadChangeRequestSnapshotsAtom = Atom.make<
...@@ -157,6 +198,19 @@ function sourceControlProvidersEqual(...@@ -157,6 +198,19 @@ function sourceControlProvidersEqual(
157 return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl;198 return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl;
158}199}
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
160export function threadChangeRequestSnapshotsEqual(214export function threadChangeRequestSnapshotsEqual(
161 left: ThreadChangeRequestSnapshot,215 left: ThreadChangeRequestSnapshot,
162 right: ThreadChangeRequestSnapshot,216 right: ThreadChangeRequestSnapshot,
...@@ -170,7 +224,8 @@ export function threadChangeRequestSnapshotsEqual(...@@ -170,7 +224,8 @@ export function threadChangeRequestSnapshotsEqual(
170 left.pr.headRef === right.pr.headRef &&224 left.pr.headRef === right.pr.headRef &&
171 left.pr.state === right.pr.state &&225 left.pr.state === right.pr.state &&
172 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&226 (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)
174 );229 );
175}230}
176231
...@@ -206,10 +261,32 @@ export function nextThreadChangeRequestSnapshot(input: {...@@ -206,10 +261,32 @@ export function nextThreadChangeRequestSnapshot(input: {
206 gitStatus: VcsStatusResult | null;261 gitStatus: VcsStatusResult | null;
207 snapshot: ThreadChangeRequestSnapshot | null | undefined;262 snapshot: ThreadChangeRequestSnapshot | null | undefined;
208 retainTerminalOnBranchMismatch: boolean;263 retainTerminalOnBranchMismatch: boolean;
264 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
265 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
209}): ThreadChangeRequestSnapshot | null | undefined {266}): 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 }
211 if (gitStatus === null) {288 if (gitStatus === null) {
212 return undefined;289 return snapshot?.linkedPullRequest === undefined ? undefined : null;
213 }290 }
214 if (threadBranch === null) {291 if (threadBranch === null) {
215 return null;292 return null;
...@@ -217,6 +294,7 @@ export function nextThreadChangeRequestSnapshot(input: {...@@ -217,6 +294,7 @@ export function nextThreadChangeRequestSnapshot(input: {
217 if (gitStatus.refName !== threadBranch) {294 if (gitStatus.refName !== threadBranch) {
218 return retainTerminalOnBranchMismatch &&295 return retainTerminalOnBranchMismatch &&
219 snapshot != null &&296 snapshot != null &&
297 snapshot.linkedPullRequest === undefined &&
220 isTerminalChangeRequestState(snapshot.pr.state)298 isTerminalChangeRequestState(snapshot.pr.state)
221 ? undefined299 ? undefined
222 : null;300 : null;
...@@ -225,6 +303,7 @@ export function nextThreadChangeRequestSnapshot(input: {...@@ -225,6 +303,7 @@ export function nextThreadChangeRequestSnapshot(input: {
225 if (303 if (
226 retainTerminalOnBranchMismatch &&304 retainTerminalOnBranchMismatch &&
227 snapshot != null &&305 snapshot != null &&
306 snapshot.linkedPullRequest === undefined &&
228 isTerminalChangeRequestState(snapshot.pr.state)307 isTerminalChangeRequestState(snapshot.pr.state)
229 ) {308 ) {
230 return undefined;309 return undefined;
...@@ -250,8 +329,25 @@ export function resolveDisplayedThreadPr(input: {...@@ -250,8 +329,25 @@ export function resolveDisplayedThreadPr(input: {
250 gitStatus: VcsStatusResult | null;329 gitStatus: VcsStatusResult | null;
251 snapshot: ThreadChangeRequestSnapshot | null | undefined;330 snapshot: ThreadChangeRequestSnapshot | null | undefined;
252 retainTerminalOnBranchMismatch: boolean;331 retainTerminalOnBranchMismatch: boolean;
332 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
333 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
253}): ThreadPr | null {334}): 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 }
255 if (351 if (
256 threadBranch !== null &&352 threadBranch !== null &&
257 gitStatus !== null &&353 gitStatus !== null &&
...@@ -265,6 +361,7 @@ export function resolveDisplayedThreadPr(input: {...@@ -265,6 +361,7 @@ export function resolveDisplayedThreadPr(input: {
265 threadBranch !== null &&361 threadBranch !== null &&
266 retainTerminalOnBranchMismatch &&362 retainTerminalOnBranchMismatch &&
267 snapshot != null &&363 snapshot != null &&
364 snapshot.linkedPullRequest === undefined &&
268 isTerminalChangeRequestState(snapshot.pr.state)365 isTerminalChangeRequestState(snapshot.pr.state)
269 ) {366 ) {
270 return snapshot.pr;367 return snapshot.pr;
...@@ -278,8 +375,25 @@ export function resolveDisplayedThreadPrProvider(input: {...@@ -278,8 +375,25 @@ export function resolveDisplayedThreadPrProvider(input: {
278 gitStatus: VcsStatusResult | null;375 gitStatus: VcsStatusResult | null;
279 snapshot: ThreadChangeRequestSnapshot | null | undefined;376 snapshot: ThreadChangeRequestSnapshot | null | undefined;
280 retainTerminalOnBranchMismatch: boolean;377 retainTerminalOnBranchMismatch: boolean;
378 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
379 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
281}): VcsStatusResult["sourceControlProvider"] | undefined {380}): 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 }
283 if (397 if (
284 threadBranch !== null &&398 threadBranch !== null &&
285 gitStatus !== null &&399 gitStatus !== null &&
...@@ -293,6 +407,7 @@ export function resolveDisplayedThreadPrProvider(input: {...@@ -293,6 +407,7 @@ export function resolveDisplayedThreadPrProvider(input: {
293 threadBranch !== null &&407 threadBranch !== null &&
294 retainTerminalOnBranchMismatch &&408 retainTerminalOnBranchMismatch &&
295 snapshot != null &&409 snapshot != null &&
410 snapshot.linkedPullRequest === undefined &&
296 isTerminalChangeRequestState(snapshot.pr.state)411 isTerminalChangeRequestState(snapshot.pr.state)
297 ) {412 ) {
298 return snapshot.sourceControlProvider;413 return snapshot.sourceControlProvider;
...@@ -417,19 +532,28 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -417,19 +532,28 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
417 );532 );
418 const threadProjectCwd = threadProject?.workspaceRoot ?? null;533 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
419 const gitCwd = thread.worktreePath ?? threadProjectCwd;534 const gitCwd = thread.worktreePath ?? threadProjectCwd;
535 const linkedPullRequest = useLinkedThreadPullRequest(
536 thread.environmentId,
537 thread.linkedPullRequest,
538 );
420 const gitStatus = useEnvironmentQuery(539 const gitStatus = useEnvironmentQuery(
421 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null540 thread.linkedPullRequest == null &&
541 (thread.branch != null || thread.worktreePath !== null) &&
542 gitCwd !== null
422 ? vcsEnvironment.status({543 ? vcsEnvironment.status({
423 environmentId: thread.environmentId,544 environmentId: thread.environmentId,
424 input: { cwd: gitCwd },545 input: { cwd: gitCwd },
425 })546 })
426 : null,547 : null,
427 );548 );
428 const pr = resolveThreadPr({549 const pr =
429 threadBranch: thread.branch,550 thread.linkedPullRequest == null
430 gitStatus: gitStatus.data,551 ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data })
431 });552 : (linkedPullRequest?.pr ?? null);
432 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);553 const prStatus = prStatusIndicator(
554 pr,
555 linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider,
556 );
433 const threadStatus = resolveThreadStatusPill({557 const threadStatus = resolveThreadStatusPill({
434 thread: {558 thread: {
435 ...thread,559 ...thread,
apps/web/src/components/chat/externalLinkContextMenu.test.ts+47-1
...@@ -2,11 +2,20 @@ import { describe, expect, it, vi } from "vite-plus/test";...@@ -2,11 +2,20 @@ import { describe, expect, it, vi } from "vite-plus/test";
22
3import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu";3import { 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) {
6 const showContextMenu = vi.fn().mockResolvedValue(selection);14 const showContextMenu = vi.fn().mockResolvedValue(selection);
7 const openInPreview = vi.fn().mockResolvedValue(undefined);15 const openInPreview = vi.fn().mockResolvedValue(undefined);
8 const openExternal = vi.fn().mockResolvedValue(undefined);16 const openExternal = vi.fn().mockResolvedValue(undefined);
9 const copyLink = vi.fn().mockResolvedValue(undefined);17 const copyLink = vi.fn().mockResolvedValue(undefined);
18 const updateThreadLink = vi.fn().mockResolvedValue(undefined);
10 const reportFailure = vi.fn();19 const reportFailure = vi.fn();
1120
12 return {21 return {
...@@ -14,6 +23,7 @@ function createHarness(selection: "open-in-preview" | "open-external" | "copy-li...@@ -14,6 +23,7 @@ function createHarness(selection: "open-in-preview" | "open-external" | "copy-li
14 openInPreview,23 openInPreview,
15 openExternal,24 openExternal,
16 copyLink,25 copyLink,
26 updateThreadLink,
17 reportFailure,27 reportFailure,
18 };28 };
19}29}
...@@ -71,6 +81,27 @@ describe("external chat link context menu", () => {...@@ -71,6 +81,27 @@ describe("external chat link context menu", () => {
71 expect(harness.openExternal).not.toHaveBeenCalled();81 expect(harness.openExternal).not.toHaveBeenCalled();
72 });82 });
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
74 it.each([105 it.each([
75 ["open-in-preview" as const, "openInPreview" as const],106 ["open-in-preview" as const, "openInPreview" as const],
76 ["open-external" as const, "openExternal" as const],107 ["open-external" as const, "openExternal" as const],
...@@ -132,6 +163,21 @@ describe("external chat link context menu", () => {...@@ -132,6 +163,21 @@ describe("external chat link context menu", () => {
132 expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause);163 expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause);
133 });164 });
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
135 it.each([181 it.each([
136 ["https://example.com", "example.com"],182 ["https://example.com", "example.com"],
137 ["http://localhost:3000/path", "localhost"],183 ["http://localhost:3000/path", "localhost"],
apps/web/src/components/chat/externalLinkContextMenu.ts+32-4
...@@ -1,17 +1,26 @@...@@ -1,17 +1,26 @@
1import type { ContextMenuItem } from "@t3tools/contracts";1import 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
5export type ExternalLinkContextMenuFailureOperation =10export type ExternalLinkContextMenuFailureOperation =
6 | "show-link-context-menu"11 | "show-link-context-menu"
7 | "open-link-in-preview"12 | "open-link-in-preview"
8 | "open-link-external"13 | "open-link-external"
9 | "copy-link";14 | "copy-link"
15 | "link-pull-request-to-thread"
16 | "unlink-pull-request-from-thread";
1017
11const FAILURE_OPERATION_BY_ACTION = {18const FAILURE_OPERATION_BY_ACTION = {
12 "open-in-preview": "open-link-in-preview",19 "open-in-preview": "open-link-in-preview",
13 "open-external": "open-link-external",20 "open-external": "open-link-external",
14 "copy-link": "copy-link",21 "copy-link": "copy-link",
22 "link-to-thread": "link-pull-request-to-thread",
23 "unlink-from-thread": "unlink-pull-request-from-thread",
15} as const satisfies Record<ExternalLinkContextMenuAction, ExternalLinkContextMenuFailureOperation>;24} as const satisfies Record<ExternalLinkContextMenuAction, ExternalLinkContextMenuFailureOperation>;
1625
17const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [26const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [
...@@ -28,10 +37,20 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [...@@ -28,10 +37,20 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [
28 */37 */
29export function externalLinkContextMenuItems(options: {38export function externalLinkContextMenuItems(options: {
30 readonly canOpenInPreview: boolean;39 readonly canOpenInPreview: boolean;
40 readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined;
31}): readonly ContextMenuItem<ExternalLinkContextMenuAction>[] {41}): readonly ContextMenuItem<ExternalLinkContextMenuAction>[] {
32 return options.canOpenInPreview42 const items = options.canOpenInPreview
33 ? EXTERNAL_LINK_CONTEXT_MENU_ITEMS43 ? EXTERNAL_LINK_CONTEXT_MENU_ITEMS
34 : EXTERNAL_LINK_CONTEXT_MENU_ITEMS.filter((item) => item.id !== "open-in-preview");44 : 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 ];
35}54}
3655
37interface ShowExternalLinkContextMenuOptions {56interface ShowExternalLinkContextMenuOptions {
...@@ -39,6 +58,7 @@ interface ShowExternalLinkContextMenuOptions {...@@ -39,6 +58,7 @@ interface ShowExternalLinkContextMenuOptions {
39 readonly position: { readonly x: number; readonly y: number };58 readonly position: { readonly x: number; readonly y: number };
40 /** Absent means yes, which is what every caller before the browser could be missing meant. */59 /** Absent means yes, which is what every caller before the browser could be missing meant. */
41 readonly canOpenInPreview?: boolean;60 readonly canOpenInPreview?: boolean;
61 readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined;
42 readonly showContextMenu: (62 readonly showContextMenu: (
43 items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[],63 items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[],
44 position: { readonly x: number; readonly y: number },64 position: { readonly x: number; readonly y: number },
...@@ -46,6 +66,7 @@ interface ShowExternalLinkContextMenuOptions {...@@ -46,6 +66,7 @@ interface ShowExternalLinkContextMenuOptions {
46 readonly openInPreview: (href: string) => Promise<void>;66 readonly openInPreview: (href: string) => Promise<void>;
47 readonly openExternal: (href: string) => Promise<void>;67 readonly openExternal: (href: string) => Promise<void>;
48 readonly copyLink: (href: string) => Promise<unknown>;68 readonly copyLink: (href: string) => Promise<unknown>;
69 readonly updateThreadLink?: (href: string, linked: boolean) => Promise<void>;
49 readonly reportFailure: (70 readonly reportFailure: (
50 operation: ExternalLinkContextMenuFailureOperation,71 operation: ExternalLinkContextMenuFailureOperation,
51 cause: unknown,72 cause: unknown,
...@@ -67,15 +88,20 @@ export async function showExternalLinkContextMenu({...@@ -67,15 +88,20 @@ export async function showExternalLinkContextMenu({
67 href,88 href,
68 position,89 position,
69 canOpenInPreview = true,90 canOpenInPreview = true,
91 threadLinkAction,
70 showContextMenu,92 showContextMenu,
71 openInPreview,93 openInPreview,
72 openExternal,94 openExternal,
73 copyLink,95 copyLink,
96 updateThreadLink,
74 reportFailure,97 reportFailure,
75}: ShowExternalLinkContextMenuOptions): Promise<void> {98}: ShowExternalLinkContextMenuOptions): Promise<void> {
76 let action: ExternalLinkContextMenuAction | null;99 let action: ExternalLinkContextMenuAction | null;
77 try {100 try {
78 action = await showContextMenu(externalLinkContextMenuItems({ canOpenInPreview }), position);101 action = await showContextMenu(
102 externalLinkContextMenuItems({ canOpenInPreview, threadLinkAction }),
103 position,
104 );
79 } catch (cause) {105 } catch (cause) {
80 reportFailure("show-link-context-menu", cause);106 reportFailure("show-link-context-menu", cause);
81 return;107 return;
...@@ -88,6 +114,8 @@ export async function showExternalLinkContextMenu({...@@ -88,6 +114,8 @@ export async function showExternalLinkContextMenu({
88 await openExternal(href);114 await openExternal(href);
89 } else if (action === "copy-link") {115 } else if (action === "copy-link") {
90 await copyLink(href);116 await copyLink(href);
117 } else if (action === "link-to-thread" || action === "unlink-from-thread") {
118 await updateThreadLink?.(href, action === "link-to-thread");
91 }119 }
92 } catch (cause) {120 } catch (cause) {
93 if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause);121 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";...@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from "vite-plus/test";
3import {3import {
4 changeRequestRepositoryUrl,4 changeRequestRepositoryUrl,
5 findProjectForChangeRequest,5 findProjectForChangeRequest,
6 matchesLinkedPullRequestUrl,
6 openPullRequestLink,7 openPullRequestLink,
7 parseChangeRequestUrl,8 parseChangeRequestUrl,
8 PullRequestLinkOpenError,9 PullRequestLinkOpenError,
9 shouldOpenPullRequestExternally,10 shouldOpenPullRequestExternally,
10} from "./openPullRequestLink";11} from "./openPullRequestLink";
12import { ProjectId } from "@t3tools/contracts";
1113
12describe("changeRequestRepositoryUrl", () => {14describe("changeRequestRepositoryUrl", () => {
13 it("preserves repository path casing", () => {15 it("preserves repository path casing", () => {
...@@ -27,6 +29,36 @@ describe("changeRequestRepositoryUrl", () => {...@@ -27,6 +29,36 @@ describe("changeRequestRepositoryUrl", () => {
27 });29 });
28});30});
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
30describe("openPullRequestLink", () => {62describe("openPullRequestLink", () => {
31 it("opens the requested pull request URL", async () => {63 it("opens the requested pull request URL", async () => {
32 const openExternal = vi.fn(async () => undefined);64 const openExternal = vi.fn(async () => undefined);
apps/web/src/lib/openPullRequestLink.ts+22-1
...@@ -1,4 +1,9 @@...@@ -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";
2import { useNavigate } from "@tanstack/react-router";7import { useNavigate } from "@tanstack/react-router";
3import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
4import { type MouseEvent, useCallback } from "react";9import { type MouseEvent, useCallback } from "react";
...@@ -118,6 +123,22 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu...@@ -118,6 +123,22 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
118 return null;123 return null;
119}124}
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
121/** The repository root behind a recognised change-request URL, without PR-specific state. */142/** The repository root behind a recognised change-request URL, without PR-specific state. */
122export function changeRequestRepositoryUrl(targetUrl: string): string | null {143export function changeRequestRepositoryUrl(targetUrl: string): string | null {
123 const changeRequest = parseChangeRequestUrl(targetUrl);144 const changeRequest = parseChangeRequestUrl(targetUrl);
apps/web/src/state/pullRequests.ts+6-1
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import { useAtomValue } from "@effect/atom-react";1import { 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";
3import type {6import type {
4 EnvironmentId,7 EnvironmentId,
5 PullRequestListInput,8 PullRequestListInput,
...@@ -19,6 +22,8 @@ import {...@@ -19,6 +22,8 @@ import {
19import { formatEnvironmentQueryError } from "./query";22import { formatEnvironmentQueryError } from "./query";
2023
21export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);24export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
25export const linkedPullRequestDetailAtom =
26 createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);
2227
23export interface EnvironmentQueryTarget<Input> {28export interface EnvironmentQueryTarget<Input> {
24 readonly environmentId: EnvironmentId;29 readonly environmentId: EnvironmentId;
docs/user/thread-sidebar.md+4
...@@ -7,6 +7,10 @@ one environment....@@ -7,6 +7,10 @@ one environment.
7Pinned threads still move to **Settled** when they become inactive. They also move when their pull7Pinned threads still move to **Settled** when they become inactive. They also move when their pull
8request merges if **Auto-settle merged threads** is enabled.8request 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
10On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu14On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
11and choose **Move up** or **Move down**. The order is stored by the server and appears on your15and choose **Move up** or **Move down**. The order is stored by the server and appears on your
12other connected devices.16other connected devices.
packages/client-runtime/src/state/pullRequests.ts+32-1
...@@ -1,4 +1,9 @@...@@ -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";
2import * as Data from "effect/Data";7import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";9import * as Option from "effect/Option";
...@@ -21,6 +26,32 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(...@@ -21,6 +26,32 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
21 "EnvironmentHttpConnectionNotReadyError",26 "EnvironmentHttpConnectionNotReadyError",
22)<{ readonly message: string }> {}27)<{ 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
24/**55/**
25 * Every read shells out to the GitHub CLI, so results are reused for a short while and56 * Every read shells out to the GitHub CLI, so results are reused for a short while and
26 * refreshed explicitly. Mutations run serially per environment: `gh` actions on the same57 * 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", () => {...@@ -305,6 +305,51 @@ describe("applyThreadDetailEvent", () => {
305 expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);305 expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);
306 }306 }
307 });307 });
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 });
308 });353 });
309354
310 describe("thread.message-sent", () => {355 describe("thread.message-sent", () => {
packages/client-runtime/src/state/threadReducer.ts+3
...@@ -218,6 +218,9 @@ export function applyThreadDetailEvent(...@@ -218,6 +218,9 @@ export function applyThreadDetailEvent(
218 ...(event.payload.worktreePath !== undefined218 ...(event.payload.worktreePath !== undefined
219 ? { worktreePath: event.payload.worktreePath }219 ? { worktreePath: event.payload.worktreePath }
220 : {}),220 : {}),
221 ...(event.payload.linkedPullRequest !== undefined
222 ? { linkedPullRequest: event.payload.linkedPullRequest }
223 : {}),
221 updatedAt: event.payload.updatedAt,224 updatedAt: event.payload.updatedAt,
222 },225 },
223 };226 };
packages/contracts/src/environment.ts+2
...@@ -69,6 +69,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -69,6 +69,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
69 /** Server understands regenerateTitle on thread.meta.update. Absent on69 /** Server understands regenerateTitle on thread.meta.update. Absent on
70 older servers, so clients hide the action instead of sending it. */70 older servers, so clients hide the action instead of sending it. */
71 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),71 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
72 /** Server persists a pull request reference on thread.meta.update. */
73 threadPullRequestLinking: Schema.optionalKey(Schema.Boolean),
72 /** The update path clients should offer for this server. Absent on74 /** The update path clients should offer for this server. Absent on
73 servers that must be relaunched manually (dev checkouts, Windows75 servers that must be relaunched manually (dev checkouts, Windows
74 foreground runs, pre-update servers). */76 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", () =>...@@ -709,6 +709,28 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () =>
709 }),709 }),
710);710);
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
712it.effect("accepts an internal title regeneration completion", () =>734it.effect("accepts an internal title regeneration completion", () =>
713 Effect.gen(function* () {735 Effect.gen(function* () {
714 const parsed = yield* decodeOrchestrationCommand({736 const parsed = yield* decodeOrchestrationCommand({
packages/contracts/src/orchestration.ts+12
...@@ -387,6 +387,14 @@ export const ThreadTitleRegeneration = Schema.Struct({...@@ -387,6 +387,14 @@ export const ThreadTitleRegeneration = Schema.Struct({
387});387});
388export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;388export 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
390export const OrchestrationThread = Schema.Struct({398export const OrchestrationThread = Schema.Struct({
391 id: ThreadId,399 id: ThreadId,
392 projectId: ProjectId,400 projectId: ProjectId,
...@@ -398,6 +406,7 @@ export const OrchestrationThread = Schema.Struct({...@@ -398,6 +406,7 @@ export const OrchestrationThread = Schema.Struct({
398 ),406 ),
399 branch: Schema.NullOr(TrimmedNonEmptyString),407 branch: Schema.NullOr(TrimmedNonEmptyString),
400 worktreePath: Schema.NullOr(TrimmedNonEmptyString),408 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
409 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
401 latestTurn: Schema.NullOr(OrchestrationLatestTurn),410 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
402 createdAt: IsoDateTime,411 createdAt: IsoDateTime,
403 updatedAt: IsoDateTime,412 updatedAt: IsoDateTime,
...@@ -468,6 +477,7 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -468,6 +477,7 @@ export const OrchestrationThreadShell = Schema.Struct({
468 ),477 ),
469 branch: Schema.NullOr(TrimmedNonEmptyString),478 branch: Schema.NullOr(TrimmedNonEmptyString),
470 worktreePath: Schema.NullOr(TrimmedNonEmptyString),479 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
480 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
471 latestTurn: Schema.NullOr(OrchestrationLatestTurn),481 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
472 createdAt: IsoDateTime,482 createdAt: IsoDateTime,
473 updatedAt: IsoDateTime,483 updatedAt: IsoDateTime,
...@@ -784,6 +794,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({...@@ -784,6 +794,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({
784 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),794 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
785 expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),795 expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
786 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),796 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
797 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
787}).check(798}).check(
788 Schema.makeFilter(799 Schema.makeFilter(
789 (input) =>800 (input) =>
...@@ -1227,6 +1238,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({...@@ -1227,6 +1238,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
1227 modelSelection: Schema.optional(ModelSelection),1238 modelSelection: Schema.optional(ModelSelection),
1228 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1239 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1229 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1240 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1241 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
1230 updatedAt: IsoDateTime,1242 updatedAt: IsoDateTime,
1231});1243});
12321244