Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ export function BranchToolbarBranchSelector({
</span>
{displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
<ThreadDetailsPrRows
threadRef={threadRef}
links={serverThread?.pullRequests ?? []}
currentLink={currentLinkedPr}
onOpenLink={openPrLink}
Expand Down
68 changes: 67 additions & 1 deletion apps/web/src/components/chat/ThreadDetailsPrRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts";
import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
import { ArrowUpRightIcon, FileDiffIcon, GitBranchIcon, TriangleAlertIcon } from "lucide-react";
import {
ArrowUpRightIcon,
EyeIcon,
EyeOffIcon,
FileDiffIcon,
GitBranchIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useState, type MouseEvent as ReactMouseEvent } from "react";

import { useLiveRefresh } from "~/hooks/useLiveRefresh";
Expand Down Expand Up @@ -82,6 +89,7 @@ export function ThreadDetailsPrRow({
openAriaLabel,
onOpen,
onActed,
onStopWatching,
}: {
environmentId: EnvironmentId;
pr: ThreadPr;
Expand All @@ -95,6 +103,8 @@ export function ThreadDetailsPrRow({
onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
/** An action changed the pull request on the host, so the vcs status behind the row is stale. */
onActed?: () => void;
/** Set while the server watches this pull request for the thread; stops the watch. */
onStopWatching?: (() => void) | undefined;
}) {
const serverConfigs = useServerConfigs();
const supportsPullRequests =
Expand Down Expand Up @@ -341,6 +351,40 @@ export function ThreadDetailsPrRow({
</>
);

// The server ends a watch when the pull request closes, so only an open one shows the eye.
// It takes the row's rounded end when nothing follows it.
const watchIsLast =
detail === null ||
((checksRollup === null || conflicting || detail.isDraft) && trailingAction === null);
const watchSegment =
onStopWatching && (detail?.state ?? pr?.state ?? "open") === "open" ? (
<>
<span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
<Tooltip>
<TooltipTrigger
render={
<ThreadDetailsControl
type="button"
variant="ghost"
size="sm"
part={watchIsLast ? "secondary" : "checks"}
className="group/watch"
aria-label={`Stop watching #${number}`}
onClick={onStopWatching}
/>
}
>
<EyeIcon aria-hidden className="size-4 group-hover/watch:hidden" />
<EyeOffIcon aria-hidden className="hidden size-4 group-hover/watch:block" />
</TooltipTrigger>
<TooltipPopup side="top">
Watching: the agent wakes when checks finish, someone comments, or the branch conflicts.
Click to stop.
</TooltipPopup>
</Tooltip>
</>
) : null;

return (
<>
{detail ? (
Expand All @@ -362,6 +406,7 @@ export function ThreadDetailsPrRow({
</TooltipTrigger>
{rowTooltip}
</Tooltip>
{watchSegment}
{checksRollup !== null && !conflicting && !detail.isDraft ? (
<>
<span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
Expand Down Expand Up @@ -398,6 +443,27 @@ export function ThreadDetailsPrRow({
</>
) : null}
</div>
) : watchSegment ? (
<div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
<Tooltip>
<TooltipTrigger
render={
<ThreadDetailsControl
type="button"
variant="ghost"
size="sm"
part="link-primary"
aria-label={openAriaLabel}
onClick={onOpen}
/>
}
>
{rowContent}
</TooltipTrigger>
{rowTooltip}
</Tooltip>
{watchSegment}
</div>
) : (
<Tooltip>
<TooltipTrigger
Expand Down
61 changes: 59 additions & 2 deletions apps/web/src/components/chat/ThreadDetailsPrRows.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, expect, it, vi } from "vite-plus/test";

const watchCommand = vi.hoisted(() => vi.fn());

vi.mock("./ThreadDetailsPrRow", () => ({
ThreadDetailsPrRow: ({ number }: { number: number }) => <span data-row={String(number)} />,
ThreadDetailsPrRow: ({
number,
onStopWatching,
}: {
number: number;
onStopWatching?: () => void;
}) => (
<span
data-row={String(number)}
data-watched={onStopWatching ? "" : undefined}
onClick={onStopWatching}
/>
),
}));
vi.mock("~/state/entities", () => ({ useProjects: () => [] }));
vi.mock("~/state/threads", () => ({ threadEnvironment: {} }));
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => watchCommand }));
vi.mock("~/lib/openPullRequestLink", () => ({
parseChangeRequestUrl: () => null,
findProjectOnChangeRequestHost: () => undefined,
Expand Down Expand Up @@ -55,6 +71,10 @@ function render(links: ReadonlyArray<ThreadPullRequestLink>, current: ThreadPull
act(() => {
renderer = create(
<ThreadDetailsPrRows
threadRef={{
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
}}
links={links}
currentLink={current}
onOpenLink={vi.fn()}
Expand Down Expand Up @@ -105,3 +125,40 @@ it("keeps the single row untouched when the thread links one pull request", () =
expect(rows()).toEqual(["1"]);
expect(toggleLabel()).toBeUndefined();
});

it("lets only watched pull requests stop their watch", () => {
const watched: ThreadPullRequestLink = {
...bottom,
watch: {
startedAt: "2026-01-01T00:00:30.000Z",
headSha: null,
failedChecks: [],
passed: false,
passedChecks: [],
remarksThrough: "2026-01-01T00:00:30.000Z",
remarkIds: [],
conflicting: false,
wakes: 0,
},
};
render([other, watched, top], top);
toggle();
const spans = renderer.root.findAllByType("span");
expect(
spans
.filter((node) => node.props["data-watched"] !== undefined)
.map((node) => node.props["data-row"]),
).toEqual(["1"]);

act(() => spans.find((node) => node.props["data-row"] === "1")!.props.onClick());
expect(watchCommand).toHaveBeenCalledWith({
environmentId: EnvironmentId.make("environment"),
input: {
threadId: ThreadId.make("thread"),
host: "github.com",
repository: "pingdotgg/t3code",
number: 1,
watching: false,
},
});
});
31 changes: 28 additions & 3 deletions apps/web/src/components/chat/ThreadDetailsPrRows.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ThreadDetailsControl } from "./ThreadDetailsControl";
import type { EnvironmentId, ThreadPullRequestLink } from "@t3tools/contracts";
import type { EnvironmentId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
import {
resolveThreadPullRequestChains,
threadPullRequestKeyOf,
Expand All @@ -11,6 +11,8 @@ import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } fro
import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink";

import { useProjects } from "~/state/entities";
import { threadEnvironment } from "~/state/threads";
import { useAtomCommand } from "~/state/use-atom-command";

import { pullRequestListLines } from "../pullRequest/pullRequestListLines";
import { MorphIcon } from "~/components/MorphIcon";
Expand All @@ -23,11 +25,13 @@ function ThreadDetailsPrLinkRow({
link,
onOpen,
onActed,
onStopWatching,
}: {
environmentId: EnvironmentId;
link: ThreadPullRequestLink;
onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
onActed?: (() => void) | undefined;
onStopWatching?: (() => void) | undefined;
}) {
const projects = useProjects();
const parsed = parseChangeRequestUrl(link.url);
Expand All @@ -51,33 +55,53 @@ function ThreadDetailsPrLinkRow({
label={`#${link.number}${link.snapshot === null ? "" : `: ${link.snapshot.title}`}`}
openAriaLabel={link.url}
onOpen={onOpen}
onStopWatching={onStopWatching}
{...(onActed ? { onActed } : {})}
/>
);
}

export function ThreadDetailsPrRows({
threadRef,
links,
currentLink,
onOpenLink,
...row
}: ComponentProps<typeof ThreadDetailsPrRow> & {
threadRef: ScopedThreadRef;
links: ReadonlyArray<ThreadPullRequestLink>;
currentLink: ThreadPullRequestLink | null;
onOpenLink: (event: ReactMouseEvent<HTMLElement>, url: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
// Only watched links get the eye; the row hides it once the server records the stop.
const stopWatching = (link: ThreadPullRequestLink | null) =>
link?.watch === undefined
? undefined
: () =>
void watch({
environmentId: threadRef.environmentId,
input: {
threadId: threadRef.threadId,
host: link.host,
repository: link.repository,
number: link.number,
watching: false,
},
});
const currentRow = <ThreadDetailsPrRow {...row} onStopWatching={stopWatching(currentLink)} />;
const rest =
currentLink === null
? []
: pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links)))
.map((line) => line.link)
.filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink));
if (rest.length === 0) return <ThreadDetailsPrRow {...row} />;
if (rest.length === 0) return currentRow;

return (
<>
<ThreadDetailsPrRow {...row} />
{currentRow}
{expanded
? rest.map((link) => (
<ThreadDetailsPrLinkRow
Expand All @@ -86,6 +110,7 @@ export function ThreadDetailsPrRows({
link={link}
onOpen={(event) => onOpenLink(event, link.url)}
onActed={row.onActed}
onStopWatching={stopWatching(link)}
/>
))
: null}
Expand Down
3 changes: 2 additions & 1 deletion docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ after 10 wakes in a row that bring only comments, after 8 failed reads in a row,
Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all
its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests;
the thread that delegated to them does. To start or stop it yourself, use the row menu in the
**Linked pull requests** panel.
**Linked pull requests** panel. In the thread details card, a watched pull request shows an eye;
click it to stop watching.

A watched thread counts as working between wakes, so it stays in the **Working** section and does
not auto-settle. Agents stop watching when they hand the work back to you, and the thread then
Expand Down
Loading