authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 16:16:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 16:16:50-07:00
log3da82d5d384a5f45e3e4a9c5bdb8336eb54e49ff
treebe10f217f3f41800742c30873be4b6f01fbafee3
parent9f61ba674115f3ddb67a8a1fbe0a0ac8839551bf
signature Signed by PGP key B5690EEEBB952194

feat(web): see and stop pull request watches in the thread details card (#16235)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 157 insertions(+), 7 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+1
...@@ -749,6 +749,7 @@ export function BranchToolbarBranchSelector({...@@ -749,6 +749,7 @@ export function BranchToolbarBranchSelector({
749 </span>749 </span>
750 {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (750 {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
751 <ThreadDetailsPrRows751 <ThreadDetailsPrRows
752 threadRef={threadRef}
752 links={serverThread?.pullRequests ?? []}753 links={serverThread?.pullRequests ?? []}
753 currentLink={currentLinkedPr}754 currentLink={currentLinkedPr}
754 onOpenLink={openPrLink}755 onOpenLink={openPrLink}
apps/web/src/components/chat/ThreadDetailsPrRow.tsx+67-1
...@@ -18,7 +18,14 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";...@@ -18,7 +18,14 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
18import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";18import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
19import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts";19import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts";
20import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";20import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
21import { ArrowUpRightIcon, FileDiffIcon, GitBranchIcon, TriangleAlertIcon } from "lucide-react";21import {
22 ArrowUpRightIcon,
23 EyeIcon,
24 EyeOffIcon,
25 FileDiffIcon,
26 GitBranchIcon,
27 TriangleAlertIcon,
28} from "lucide-react";
22import { useState, type MouseEvent as ReactMouseEvent } from "react";29import { useState, type MouseEvent as ReactMouseEvent } from "react";
2330
24import { useLiveRefresh } from "~/hooks/useLiveRefresh";31import { useLiveRefresh } from "~/hooks/useLiveRefresh";
...@@ -82,6 +89,7 @@ export function ThreadDetailsPrRow({...@@ -82,6 +89,7 @@ export function ThreadDetailsPrRow({
82 openAriaLabel,89 openAriaLabel,
83 onOpen,90 onOpen,
84 onActed,91 onActed,
92 onStopWatching,
85}: {93}: {
86 environmentId: EnvironmentId;94 environmentId: EnvironmentId;
87 pr: ThreadPr;95 pr: ThreadPr;
...@@ -95,6 +103,8 @@ export function ThreadDetailsPrRow({...@@ -95,6 +103,8 @@ export function ThreadDetailsPrRow({
95 onOpen: (event: ReactMouseEvent<HTMLElement>) => void;103 onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
96 /** An action changed the pull request on the host, so the vcs status behind the row is stale. */104 /** An action changed the pull request on the host, so the vcs status behind the row is stale. */
97 onActed?: () => void;105 onActed?: () => void;
106 /** Set while the server watches this pull request for the thread; stops the watch. */
107 onStopWatching?: (() => void) | undefined;
98}) {108}) {
99 const serverConfigs = useServerConfigs();109 const serverConfigs = useServerConfigs();
100 const supportsPullRequests =110 const supportsPullRequests =
...@@ -341,6 +351,40 @@ export function ThreadDetailsPrRow({...@@ -341,6 +351,40 @@ export function ThreadDetailsPrRow({
341 </>351 </>
342 );352 );
343353
354 // The server ends a watch when the pull request closes, so only an open one shows the eye.
355 // It takes the row's rounded end when nothing follows it.
356 const watchIsLast =
357 detail === null ||
358 ((checksRollup === null || conflicting || detail.isDraft) && trailingAction === null);
359 const watchSegment =
360 onStopWatching && (detail?.state ?? pr?.state ?? "open") === "open" ? (
361 <>
362 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
363 <Tooltip>
364 <TooltipTrigger
365 render={
366 <ThreadDetailsControl
367 type="button"
368 variant="ghost"
369 size="sm"
370 part={watchIsLast ? "secondary" : "checks"}
371 className="group/watch"
372 aria-label={`Stop watching #${number}`}
373 onClick={onStopWatching}
374 />
375 }
376 >
377 <EyeIcon aria-hidden className="size-4 group-hover/watch:hidden" />
378 <EyeOffIcon aria-hidden className="hidden size-4 group-hover/watch:block" />
379 </TooltipTrigger>
380 <TooltipPopup side="top">
381 Watching: the agent wakes when checks finish, someone comments, or the branch conflicts.
382 Click to stop.
383 </TooltipPopup>
384 </Tooltip>
385 </>
386 ) : null;
387
344 return (388 return (
345 <>389 <>
346 {detail ? (390 {detail ? (
...@@ -362,6 +406,7 @@ export function ThreadDetailsPrRow({...@@ -362,6 +406,7 @@ export function ThreadDetailsPrRow({
362 </TooltipTrigger>406 </TooltipTrigger>
363 {rowTooltip}407 {rowTooltip}
364 </Tooltip>408 </Tooltip>
409 {watchSegment}
365 {checksRollup !== null && !conflicting && !detail.isDraft ? (410 {checksRollup !== null && !conflicting && !detail.isDraft ? (
366 <>411 <>
367 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />412 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
...@@ -398,6 +443,27 @@ export function ThreadDetailsPrRow({...@@ -398,6 +443,27 @@ export function ThreadDetailsPrRow({
398 </>443 </>
399 ) : null}444 ) : null}
400 </div>445 </div>
446 ) : watchSegment ? (
447 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
448 <Tooltip>
449 <TooltipTrigger
450 render={
451 <ThreadDetailsControl
452 type="button"
453 variant="ghost"
454 size="sm"
455 part="link-primary"
456 aria-label={openAriaLabel}
457 onClick={onOpen}
458 />
459 }
460 >
461 {rowContent}
462 </TooltipTrigger>
463 {rowTooltip}
464 </Tooltip>
465 {watchSegment}
466 </div>
401 ) : (467 ) : (
402 <Tooltip>468 <Tooltip>
403 <TooltipTrigger469 <TooltipTrigger
apps/web/src/components/chat/ThreadDetailsPrRows.test.tsx+59-2
...@@ -1,12 +1,28 @@...@@ -1,12 +1,28 @@
1import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";1import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts";
2import { act } from "react";2import { act } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, expect, it, vi } from "vite-plus/test";4import { afterEach, expect, it, vi } from "vite-plus/test";
55
6const watchCommand = vi.hoisted(() => vi.fn());
7
6vi.mock("./ThreadDetailsPrRow", () => ({8vi.mock("./ThreadDetailsPrRow", () => ({
7 ThreadDetailsPrRow: ({ number }: { number: number }) => <span data-row={String(number)} />,9 ThreadDetailsPrRow: ({
10 number,
11 onStopWatching,
12 }: {
13 number: number;
14 onStopWatching?: () => void;
15 }) => (
16 <span
17 data-row={String(number)}
18 data-watched={onStopWatching ? "" : undefined}
19 onClick={onStopWatching}
20 />
21 ),
8}));22}));
9vi.mock("~/state/entities", () => ({ useProjects: () => [] }));23vi.mock("~/state/entities", () => ({ useProjects: () => [] }));
24vi.mock("~/state/threads", () => ({ threadEnvironment: {} }));
25vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => watchCommand }));
10vi.mock("~/lib/openPullRequestLink", () => ({26vi.mock("~/lib/openPullRequestLink", () => ({
11 parseChangeRequestUrl: () => null,27 parseChangeRequestUrl: () => null,
12 findProjectOnChangeRequestHost: () => undefined,28 findProjectOnChangeRequestHost: () => undefined,
...@@ -55,6 +71,10 @@ function render(links: ReadonlyArray<ThreadPullRequestLink>, current: ThreadPull...@@ -55,6 +71,10 @@ function render(links: ReadonlyArray<ThreadPullRequestLink>, current: ThreadPull
55 act(() => {71 act(() => {
56 renderer = create(72 renderer = create(
57 <ThreadDetailsPrRows73 <ThreadDetailsPrRows
74 threadRef={{
75 environmentId: EnvironmentId.make("environment"),
76 threadId: ThreadId.make("thread"),
77 }}
58 links={links}78 links={links}
59 currentLink={current}79 currentLink={current}
60 onOpenLink={vi.fn()}80 onOpenLink={vi.fn()}
...@@ -105,3 +125,40 @@ it("keeps the single row untouched when the thread links one pull request", () =...@@ -105,3 +125,40 @@ it("keeps the single row untouched when the thread links one pull request", () =
105 expect(rows()).toEqual(["1"]);125 expect(rows()).toEqual(["1"]);
106 expect(toggleLabel()).toBeUndefined();126 expect(toggleLabel()).toBeUndefined();
107});127});
128
129it("lets only watched pull requests stop their watch", () => {
130 const watched: ThreadPullRequestLink = {
131 ...bottom,
132 watch: {
133 startedAt: "2026-01-01T00:00:30.000Z",
134 headSha: null,
135 failedChecks: [],
136 passed: false,
137 passedChecks: [],
138 remarksThrough: "2026-01-01T00:00:30.000Z",
139 remarkIds: [],
140 conflicting: false,
141 wakes: 0,
142 },
143 };
144 render([other, watched, top], top);
145 toggle();
146 const spans = renderer.root.findAllByType("span");
147 expect(
148 spans
149 .filter((node) => node.props["data-watched"] !== undefined)
150 .map((node) => node.props["data-row"]),
151 ).toEqual(["1"]);
152
153 act(() => spans.find((node) => node.props["data-row"] === "1")!.props.onClick());
154 expect(watchCommand).toHaveBeenCalledWith({
155 environmentId: EnvironmentId.make("environment"),
156 input: {
157 threadId: ThreadId.make("thread"),
158 host: "github.com",
159 repository: "pingdotgg/t3code",
160 number: 1,
161 watching: false,
162 },
163 });
164});
apps/web/src/components/chat/ThreadDetailsPrRows.tsx+28-3
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { ThreadDetailsControl } from "./ThreadDetailsControl";1import { ThreadDetailsControl } from "./ThreadDetailsControl";
2import type { EnvironmentId, ThreadPullRequestLink } from "@t3tools/contracts";2import type { EnvironmentId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
3import {3import {
4 resolveThreadPullRequestChains,4 resolveThreadPullRequestChains,
5 threadPullRequestKeyOf,5 threadPullRequestKeyOf,
...@@ -11,6 +11,8 @@ import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } fro...@@ -11,6 +11,8 @@ import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } fro
11import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink";11import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink";
1212
13import { useProjects } from "~/state/entities";13import { useProjects } from "~/state/entities";
14import { threadEnvironment } from "~/state/threads";
15import { useAtomCommand } from "~/state/use-atom-command";
1416
15import { pullRequestListLines } from "../pullRequest/pullRequestListLines";17import { pullRequestListLines } from "../pullRequest/pullRequestListLines";
16import { MorphIcon } from "~/components/MorphIcon";18import { MorphIcon } from "~/components/MorphIcon";
...@@ -23,11 +25,13 @@ function ThreadDetailsPrLinkRow({...@@ -23,11 +25,13 @@ function ThreadDetailsPrLinkRow({
23 link,25 link,
24 onOpen,26 onOpen,
25 onActed,27 onActed,
28 onStopWatching,
26}: {29}: {
27 environmentId: EnvironmentId;30 environmentId: EnvironmentId;
28 link: ThreadPullRequestLink;31 link: ThreadPullRequestLink;
29 onOpen: (event: ReactMouseEvent<HTMLElement>) => void;32 onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
30 onActed?: (() => void) | undefined;33 onActed?: (() => void) | undefined;
34 onStopWatching?: (() => void) | undefined;
31}) {35}) {
32 const projects = useProjects();36 const projects = useProjects();
33 const parsed = parseChangeRequestUrl(link.url);37 const parsed = parseChangeRequestUrl(link.url);
...@@ -51,33 +55,53 @@ function ThreadDetailsPrLinkRow({...@@ -51,33 +55,53 @@ function ThreadDetailsPrLinkRow({
51 label={`#${link.number}${link.snapshot === null ? "" : `: ${link.snapshot.title}`}`}55 label={`#${link.number}${link.snapshot === null ? "" : `: ${link.snapshot.title}`}`}
52 openAriaLabel={link.url}56 openAriaLabel={link.url}
53 onOpen={onOpen}57 onOpen={onOpen}
58 onStopWatching={onStopWatching}
54 {...(onActed ? { onActed } : {})}59 {...(onActed ? { onActed } : {})}
55 />60 />
56 );61 );
57}62}
5863
59export function ThreadDetailsPrRows({64export function ThreadDetailsPrRows({
65 threadRef,
60 links,66 links,
61 currentLink,67 currentLink,
62 onOpenLink,68 onOpenLink,
63 ...row69 ...row
64}: ComponentProps<typeof ThreadDetailsPrRow> & {70}: ComponentProps<typeof ThreadDetailsPrRow> & {
71 threadRef: ScopedThreadRef;
65 links: ReadonlyArray<ThreadPullRequestLink>;72 links: ReadonlyArray<ThreadPullRequestLink>;
66 currentLink: ThreadPullRequestLink | null;73 currentLink: ThreadPullRequestLink | null;
67 onOpenLink: (event: ReactMouseEvent<HTMLElement>, url: string) => void;74 onOpenLink: (event: ReactMouseEvent<HTMLElement>, url: string) => void;
68}) {75}) {
69 const [expanded, setExpanded] = useState(false);76 const [expanded, setExpanded] = useState(false);
77 const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
78 // Only watched links get the eye; the row hides it once the server records the stop.
79 const stopWatching = (link: ThreadPullRequestLink | null) =>
80 link?.watch === undefined
81 ? undefined
82 : () =>
83 void watch({
84 environmentId: threadRef.environmentId,
85 input: {
86 threadId: threadRef.threadId,
87 host: link.host,
88 repository: link.repository,
89 number: link.number,
90 watching: false,
91 },
92 });
93 const currentRow = <ThreadDetailsPrRow {...row} onStopWatching={stopWatching(currentLink)} />;
70 const rest =94 const rest =
71 currentLink === null95 currentLink === null
72 ? []96 ? []
73 : pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links)))97 : pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links)))
74 .map((line) => line.link)98 .map((line) => line.link)
75 .filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink));99 .filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink));
76 if (rest.length === 0) return <ThreadDetailsPrRow {...row} />;100 if (rest.length === 0) return currentRow;
77101
78 return (102 return (
79 <>103 <>
80 <ThreadDetailsPrRow {...row} />104 {currentRow}
81 {expanded105 {expanded
82 ? rest.map((link) => (106 ? rest.map((link) => (
83 <ThreadDetailsPrLinkRow107 <ThreadDetailsPrLinkRow
...@@ -86,6 +110,7 @@ export function ThreadDetailsPrRows({...@@ -86,6 +110,7 @@ export function ThreadDetailsPrRows({
86 link={link}110 link={link}
87 onOpen={(event) => onOpenLink(event, link.url)}111 onOpen={(event) => onOpenLink(event, link.url)}
88 onActed={row.onActed}112 onActed={row.onActed}
113 onStopWatching={stopWatching(link)}
89 />114 />
90 ))115 ))
91 : null}116 : null}
docs/user/source-control.md+2-1
...@@ -201,7 +201,8 @@ after 10 wakes in a row that bring only comments, after 8 failed reads in a row,...@@ -201,7 +201,8 @@ after 10 wakes in a row that bring only comments, after 8 failed reads in a row,
201Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all201Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all
202its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests;202its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests;
203the thread that delegated to them does. To start or stop it yourself, use the row menu in the203the thread that delegated to them does. To start or stop it yourself, use the row menu in the
204**Linked pull requests** panel.204**Linked pull requests** panel. In the thread details card, a watched pull request shows an eye;
205click it to stop watching.
205206
206A watched thread counts as working between wakes, so it stays in the **Working** section and does207A watched thread counts as working between wakes, so it stays in the **Working** section and does
207not auto-settle. Agents stop watching when they hand the work back to you, and the thread then208not auto-settle. Agents stop watching when they hand the work back to you, and the thread then