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({
749749 </span>
750750 {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
751751 <ThreadDetailsPrRows
752 threadRef={threadRef}
752753 links={serverThread?.pullRequests ?? []}
753754 currentLink={currentLinkedPr}
754755 onOpenLink={openPrLink}
apps/web/src/components/chat/ThreadDetailsPrRow.tsx+67-1
......@@ -18,7 +18,14 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
1818import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1919import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts";
2020import { 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";
2229import { useState, type MouseEvent as ReactMouseEvent } from "react";
2330
2431import { useLiveRefresh } from "~/hooks/useLiveRefresh";
......@@ -82,6 +89,7 @@ export function ThreadDetailsPrRow({
8289 openAriaLabel,
8390 onOpen,
8491 onActed,
92 onStopWatching,
8593}: {
8694 environmentId: EnvironmentId;
8795 pr: ThreadPr;
......@@ -95,6 +103,8 @@ export function ThreadDetailsPrRow({
95103 onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
96104 /** An action changed the pull request on the host, so the vcs status behind the row is stale. */
97105 onActed?: () => void;
106 /** Set while the server watches this pull request for the thread; stops the watch. */
107 onStopWatching?: (() => void) | undefined;
98108}) {
99109 const serverConfigs = useServerConfigs();
100110 const supportsPullRequests =
......@@ -341,6 +351,40 @@ export function ThreadDetailsPrRow({
341351 </>
342352 );
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
344388 return (
345389 <>
346390 {detail ? (
......@@ -362,6 +406,7 @@ export function ThreadDetailsPrRow({
362406 </TooltipTrigger>
363407 {rowTooltip}
364408 </Tooltip>
409 {watchSegment}
365410 {checksRollup !== null && !conflicting && !detail.isDraft ? (
366411 <>
367412 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
......@@ -398,6 +443,27 @@ export function ThreadDetailsPrRow({
398443 </>
399444 ) : null}
400445 </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>
401467 ) : (
402468 <Tooltip>
403469 <TooltipTrigger
apps/web/src/components/chat/ThreadDetailsPrRows.test.tsx+59-2
......@@ -1,12 +1,28 @@
1import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
1import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts";
22import { act } from "react";
33import { create, type ReactTestRenderer } from "react-test-renderer";
44import { afterEach, expect, it, vi } from "vite-plus/test";
55
6const watchCommand = vi.hoisted(() => vi.fn());
7
68vi.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 ),
822}));
923vi.mock("~/state/entities", () => ({ useProjects: () => [] }));
24vi.mock("~/state/threads", () => ({ threadEnvironment: {} }));
25vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => watchCommand }));
1026vi.mock("~/lib/openPullRequestLink", () => ({
1127 parseChangeRequestUrl: () => null,
1228 findProjectOnChangeRequestHost: () => undefined,
......@@ -55,6 +71,10 @@ function render(links: ReadonlyArray<ThreadPullRequestLink>, current: ThreadPull
5571 act(() => {
5672 renderer = create(
5773 <ThreadDetailsPrRows
74 threadRef={{
75 environmentId: EnvironmentId.make("environment"),
76 threadId: ThreadId.make("thread"),
77 }}
5878 links={links}
5979 currentLink={current}
6080 onOpenLink={vi.fn()}
......@@ -105,3 +125,40 @@ it("keeps the single row untouched when the thread links one pull request", () =
105125 expect(rows()).toEqual(["1"]);
106126 expect(toggleLabel()).toBeUndefined();
107127});
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 @@
11import { ThreadDetailsControl } from "./ThreadDetailsControl";
2import type { EnvironmentId, ThreadPullRequestLink } from "@t3tools/contracts";
2import type { EnvironmentId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
33import {
44 resolveThreadPullRequestChains,
55 threadPullRequestKeyOf,
......@@ -11,6 +11,8 @@ import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } fro
1111import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink";
1212
1313import { useProjects } from "~/state/entities";
14import { threadEnvironment } from "~/state/threads";
15import { useAtomCommand } from "~/state/use-atom-command";
1416
1517import { pullRequestListLines } from "../pullRequest/pullRequestListLines";
1618import { MorphIcon } from "~/components/MorphIcon";
......@@ -23,11 +25,13 @@ function ThreadDetailsPrLinkRow({
2325 link,
2426 onOpen,
2527 onActed,
28 onStopWatching,
2629}: {
2730 environmentId: EnvironmentId;
2831 link: ThreadPullRequestLink;
2932 onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
3033 onActed?: (() => void) | undefined;
34 onStopWatching?: (() => void) | undefined;
3135}) {
3236 const projects = useProjects();
3337 const parsed = parseChangeRequestUrl(link.url);
......@@ -51,33 +55,53 @@ function ThreadDetailsPrLinkRow({
5155 label={`#${link.number}${link.snapshot === null ? "" : `: ${link.snapshot.title}`}`}
5256 openAriaLabel={link.url}
5357 onOpen={onOpen}
58 onStopWatching={onStopWatching}
5459 {...(onActed ? { onActed } : {})}
5560 />
5661 );
5762}
5863
5964export function ThreadDetailsPrRows({
65 threadRef,
6066 links,
6167 currentLink,
6268 onOpenLink,
6369 ...row
6470}: ComponentProps<typeof ThreadDetailsPrRow> & {
71 threadRef: ScopedThreadRef;
6572 links: ReadonlyArray<ThreadPullRequestLink>;
6673 currentLink: ThreadPullRequestLink | null;
6774 onOpenLink: (event: ReactMouseEvent<HTMLElement>, url: string) => void;
6875}) {
6976 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)} />;
7094 const rest =
7195 currentLink === null
7296 ? []
7397 : pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links)))
7498 .map((line) => line.link)
7599 .filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink));
76 if (rest.length === 0) return <ThreadDetailsPrRow {...row} />;
100 if (rest.length === 0) return currentRow;
77101
78102 return (
79103 <>
80 <ThreadDetailsPrRow {...row} />
104 {currentRow}
81105 {expanded
82106 ? rest.map((link) => (
83107 <ThreadDetailsPrLinkRow
......@@ -86,6 +110,7 @@ export function ThreadDetailsPrRows({
86110 link={link}
87111 onOpen={(event) => onOpenLink(event, link.url)}
88112 onActed={row.onActed}
113 onStopWatching={stopWatching(link)}
89114 />
90115 ))
91116 : 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,
201201Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all
202202its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests;
203203the 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
206207A watched thread counts as working between wakes, so it stays in the **Working** section and does
207208not auto-settle. Agents stop watching when they hand the work back to you, and the thread then