| author | |
| committer | |
| log | 3da82d5d384a5f45e3e4a9c5bdb8336eb54e49ff |
| tree | be10f217f3f41800742c30873be4b6f01fbafee3 |
| parent | 9f61ba674115f3ddb67a8a1fbe0a0ac8839551bf |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 749 | </span> |
| 750 | 750 | {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( |
| 751 | 751 | <ThreadDetailsPrRows |
| 752 | threadRef={threadRef} | |
| 752 | 753 | links={serverThread?.pullRequests ?? []} |
| 753 | 754 | currentLink={currentLinkedPr} |
| 754 | 755 | onOpenLink={openPrLink} |
apps/web/src/components/chat/ThreadDetailsPrRow.tsx+67-1| ... | ... | @@ -18,7 +18,14 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 18 | 18 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 19 | 19 | import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; |
| 20 | 20 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; |
| 21 | import { ArrowUpRightIcon, FileDiffIcon, GitBranchIcon, TriangleAlertIcon } from "lucide-react"; | |
| 21 | import { | |
| 22 | ArrowUpRightIcon, | |
| 23 | EyeIcon, | |
| 24 | EyeOffIcon, | |
| 25 | FileDiffIcon, | |
| 26 | GitBranchIcon, | |
| 27 | TriangleAlertIcon, | |
| 28 | } from "lucide-react"; | |
| 22 | 29 | import { useState, type MouseEvent as ReactMouseEvent } from "react"; |
| 23 | 30 | |
| 24 | 31 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| ... | ... | @@ -82,6 +89,7 @@ export function ThreadDetailsPrRow({ |
| 82 | 89 | openAriaLabel, |
| 83 | 90 | onOpen, |
| 84 | 91 | onActed, |
| 92 | onStopWatching, | |
| 85 | 93 | }: { |
| 86 | 94 | environmentId: EnvironmentId; |
| 87 | 95 | pr: ThreadPr; |
| ... | ... | @@ -95,6 +103,8 @@ export function ThreadDetailsPrRow({ |
| 95 | 103 | onOpen: (event: ReactMouseEvent<HTMLElement>) => void; |
| 96 | 104 | /** An action changed the pull request on the host, so the vcs status behind the row is stale. */ |
| 97 | 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 | 109 | const serverConfigs = useServerConfigs(); |
| 100 | 110 | const supportsPullRequests = |
| ... | ... | @@ -341,6 +351,40 @@ export function ThreadDetailsPrRow({ |
| 341 | 351 | </> |
| 342 | 352 | ); |
| 343 | 353 | |
| 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 | 388 | return ( |
| 345 | 389 | <> |
| 346 | 390 | {detail ? ( |
| ... | ... | @@ -362,6 +406,7 @@ export function ThreadDetailsPrRow({ |
| 362 | 406 | </TooltipTrigger> |
| 363 | 407 | {rowTooltip} |
| 364 | 408 | </Tooltip> |
| 409 | {watchSegment} | |
| 365 | 410 | {checksRollup !== null && !conflicting && !detail.isDraft ? ( |
| 366 | 411 | <> |
| 367 | 412 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> |
| ... | ... | @@ -398,6 +443,27 @@ export function ThreadDetailsPrRow({ |
| 398 | 443 | </> |
| 399 | 444 | ) : null} |
| 400 | 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 | 468 | <Tooltip> |
| 403 | 469 | <TooltipTrigger |
apps/web/src/components/chat/ThreadDetailsPrRows.test.tsx+59-2| ... | ... | @@ -1,12 +1,28 @@ |
| 1 | import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 2 | 2 | import { act } from "react"; |
| 3 | 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 4 | 4 | import { afterEach, expect, it, vi } from "vite-plus/test"; |
| 5 | 5 | |
| 6 | const watchCommand = vi.hoisted(() => vi.fn()); | |
| 7 | ||
| 6 | 8 | vi.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 | })); |
| 9 | 23 | vi.mock("~/state/entities", () => ({ useProjects: () => [] })); |
| 24 | vi.mock("~/state/threads", () => ({ threadEnvironment: {} })); | |
| 25 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => watchCommand })); | |
| 10 | 26 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 11 | 27 | parseChangeRequestUrl: () => null, |
| 12 | 28 | findProjectOnChangeRequestHost: () => undefined, |
| ... | ... | @@ -55,6 +71,10 @@ function render(links: ReadonlyArray<ThreadPullRequestLink>, current: ThreadPull |
| 55 | 71 | act(() => { |
| 56 | 72 | renderer = create( |
| 57 | 73 | <ThreadDetailsPrRows |
| 74 | threadRef={{ | |
| 75 | environmentId: EnvironmentId.make("environment"), | |
| 76 | threadId: ThreadId.make("thread"), | |
| 77 | }} | |
| 58 | 78 | links={links} |
| 59 | 79 | currentLink={current} |
| 60 | 80 | onOpenLink={vi.fn()} |
| ... | ... | @@ -105,3 +125,40 @@ it("keeps the single row untouched when the thread links one pull request", () = |
| 105 | 125 | expect(rows()).toEqual(["1"]); |
| 106 | 126 | expect(toggleLabel()).toBeUndefined(); |
| 107 | 127 | }); |
| 128 | ||
| 129 | it("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 | 1 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 2 | import type { EnvironmentId, ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 2 | import type { EnvironmentId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 3 | 3 | import { |
| 4 | 4 | resolveThreadPullRequestChains, |
| 5 | 5 | threadPullRequestKeyOf, |
| ... | ... | @@ -11,6 +11,8 @@ import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } fro |
| 11 | 11 | import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; |
| 12 | 12 | |
| 13 | 13 | import { useProjects } from "~/state/entities"; |
| 14 | import { threadEnvironment } from "~/state/threads"; | |
| 15 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 14 | 16 | |
| 15 | 17 | import { pullRequestListLines } from "../pullRequest/pullRequestListLines"; |
| 16 | 18 | import { MorphIcon } from "~/components/MorphIcon"; |
| ... | ... | @@ -23,11 +25,13 @@ function ThreadDetailsPrLinkRow({ |
| 23 | 25 | link, |
| 24 | 26 | onOpen, |
| 25 | 27 | onActed, |
| 28 | onStopWatching, | |
| 26 | 29 | }: { |
| 27 | 30 | environmentId: EnvironmentId; |
| 28 | 31 | link: ThreadPullRequestLink; |
| 29 | 32 | onOpen: (event: ReactMouseEvent<HTMLElement>) => void; |
| 30 | 33 | onActed?: (() => void) | undefined; |
| 34 | onStopWatching?: (() => void) | undefined; | |
| 31 | 35 | }) { |
| 32 | 36 | const projects = useProjects(); |
| 33 | 37 | const parsed = parseChangeRequestUrl(link.url); |
| ... | ... | @@ -51,33 +55,53 @@ function ThreadDetailsPrLinkRow({ |
| 51 | 55 | label={`#${link.number}${link.snapshot === null ? "" : `: ${link.snapshot.title}`}`} |
| 52 | 56 | openAriaLabel={link.url} |
| 53 | 57 | onOpen={onOpen} |
| 58 | onStopWatching={onStopWatching} | |
| 54 | 59 | {...(onActed ? { onActed } : {})} |
| 55 | 60 | /> |
| 56 | 61 | ); |
| 57 | 62 | } |
| 58 | 63 | |
| 59 | 64 | export function ThreadDetailsPrRows({ |
| 65 | threadRef, | |
| 60 | 66 | links, |
| 61 | 67 | currentLink, |
| 62 | 68 | onOpenLink, |
| 63 | 69 | ...row |
| 64 | 70 | }: ComponentProps<typeof ThreadDetailsPrRow> & { |
| 71 | threadRef: ScopedThreadRef; | |
| 65 | 72 | links: ReadonlyArray<ThreadPullRequestLink>; |
| 66 | 73 | currentLink: ThreadPullRequestLink | null; |
| 67 | 74 | onOpenLink: (event: ReactMouseEvent<HTMLElement>, url: string) => void; |
| 68 | 75 | }) { |
| 69 | 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 | 94 | const rest = |
| 71 | 95 | currentLink === null |
| 72 | 96 | ? [] |
| 73 | 97 | : pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links))) |
| 74 | 98 | .map((line) => line.link) |
| 75 | 99 | .filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink)); |
| 76 | if (rest.length === 0) return <ThreadDetailsPrRow {...row} />; | |
| 100 | if (rest.length === 0) return currentRow; | |
| 77 | 101 | |
| 78 | 102 | return ( |
| 79 | 103 | <> |
| 80 | <ThreadDetailsPrRow {...row} /> | |
| 104 | {currentRow} | |
| 81 | 105 | {expanded |
| 82 | 106 | ? rest.map((link) => ( |
| 83 | 107 | <ThreadDetailsPrLinkRow |
| ... | ... | @@ -86,6 +110,7 @@ export function ThreadDetailsPrRows({ |
| 86 | 110 | link={link} |
| 87 | 111 | onOpen={(event) => onOpenLink(event, link.url)} |
| 88 | 112 | onActed={row.onActed} |
| 113 | onStopWatching={stopWatching(link)} | |
| 89 | 114 | /> |
| 90 | 115 | )) |
| 91 | 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 | 201 | Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all |
| 202 | 202 | its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests; |
| 203 | 203 | the 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; | |
| 205 | click it to stop watching. | |
| 205 | 206 | |
| 206 | 207 | A watched thread counts as working between wakes, so it stays in the **Working** section and does |
| 207 | 208 | not auto-settle. Agents stop watching when they hand the work back to you, and the thread then |