| 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,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 | <ThreadDetailsPrRows | 751 | <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"; |
| 18 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 18 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 19 | import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; | 19 | import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; |
| 20 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | 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 | import { useState, type MouseEvent as ReactMouseEvent } from "react"; | 29 | import { useState, type MouseEvent as ReactMouseEvent } from "react"; |
| 23 | 30 | ||
| 24 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 31 | import { 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 | ); |
| 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 | 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 | <TooltipTrigger | 469 | <TooltipTrigger |
apps/web/src/components/chat/ThreadDetailsPrRows.test.tsx+59-2| ... | @@ -1,12 +1,28 @@ | ... | @@ -1,12 +1,28 @@ |
| 1 | import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; |
| 2 | import { act } from "react"; | 2 | import { act } from "react"; |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 4 | import { afterEach, expect, it, vi } from "vite-plus/test"; | 4 | import { afterEach, expect, it, vi } from "vite-plus/test"; |
| 5 | 5 | ||
| 6 | const watchCommand = vi.hoisted(() => vi.fn()); | ||
| 7 | |||
| 6 | vi.mock("./ThreadDetailsPrRow", () => ({ | 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 | vi.mock("~/state/entities", () => ({ useProjects: () => [] })); | 23 | vi.mock("~/state/entities", () => ({ useProjects: () => [] })); |
| 24 | vi.mock("~/state/threads", () => ({ threadEnvironment: {} })); | ||
| 25 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => watchCommand })); | ||
| 10 | vi.mock("~/lib/openPullRequestLink", () => ({ | 26 | vi.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 | <ThreadDetailsPrRows | 73 | <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 | |||
| 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,5 +1,5 @@ |
| 1 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; | 1 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 2 | import type { EnvironmentId, ThreadPullRequestLink } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; |
| 3 | import { | 3 | import { |
| 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 |
| 11 | import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; | 11 | import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; |
| 12 | 12 | ||
| 13 | import { useProjects } from "~/state/entities"; | 13 | import { useProjects } from "~/state/entities"; |
| 14 | import { threadEnvironment } from "~/state/threads"; | ||
| 15 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 14 | 16 | ||
| 15 | import { pullRequestListLines } from "../pullRequest/pullRequestListLines"; | 17 | import { pullRequestListLines } from "../pullRequest/pullRequestListLines"; |
| 16 | import { MorphIcon } from "~/components/MorphIcon"; | 18 | import { 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 | } |
| 58 | 63 | ||
| 59 | export function ThreadDetailsPrRows({ | 64 | export function ThreadDetailsPrRows({ |
| 65 | threadRef, | ||
| 60 | links, | 66 | links, |
| 61 | currentLink, | 67 | currentLink, |
| 62 | onOpenLink, | 68 | onOpenLink, |
| 63 | ...row | 69 | ...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 === null | 95 | 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; |
| 77 | 101 | ||
| 78 | return ( | 102 | return ( |
| 79 | <> | 103 | <> |
| 80 | <ThreadDetailsPrRow {...row} /> | 104 | {currentRow} |
| 81 | {expanded | 105 | {expanded |
| 82 | ? rest.map((link) => ( | 106 | ? rest.map((link) => ( |
| 83 | <ThreadDetailsPrLinkRow | 107 | <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, |
| 201 | Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all | 201 | Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all |
| 202 | its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests; | 202 | its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests; |
| 203 | the thread that delegated to them does. To start or stop it yourself, use the row menu in the | 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 | A watched thread counts as working between wakes, so it stays in the **Working** section and does | 207 | A watched thread counts as working between wakes, so it stays in the **Working** section and does |
| 207 | not auto-settle. Agents stop watching when they hand the work back to you, and the thread then | 208 | not auto-settle. Agents stop watching when they hand the work back to you, and the thread then |