Skip to content
Closed
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 @@ -698,6 +698,7 @@ export function BranchToolbarBranchSelector({
{displayMode !== "panel" ? (
<ThreadPullRequestBadgeControl
render={<ComposerControl size="xs" />}
threadRef={threadRef}
badge={prBadge}
pullRequests={serverThread?.pullRequests ?? []}
number={prNumber}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1662,6 +1662,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? (
<ThreadPullRequestBadgeControl
render={<InlineButton />}
threadRef={threadRef}
badge={prBadgeShape}
pullRequests={thread.pullRequests}
number={pr?.number ?? currentLinkedPr?.number}
Expand Down
90 changes: 84 additions & 6 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull
import {
resolveEnvironmentMachineKind,
type EnvironmentId,
type ScopedThreadRef,
type ThreadLinkedPullRequest,
type ThreadPullRequestLink,
type VcsStatusResult,
} from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
import { FolderGit2Icon, TerminalIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import { EyeIcon, EyeOffIcon, FolderGit2Icon, TerminalIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
Expand All @@ -34,6 +35,8 @@ import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
import { useEnvironmentQuery } from "../state/query";
import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
import { useThreadRunningTerminalIds } from "../state/terminalSessions";
import { threadEnvironment } from "../state/threads";
import { useAtomCommand } from "../state/use-atom-command";
import { vcsEnvironment } from "../state/vcs";
import { useUiStateStore } from "../uiStateStore";
import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
Expand All @@ -47,6 +50,7 @@ import {

import type { SidebarThreadSummary } from "../types";
import { formatWorktreePathForDisplay } from "../worktreeCleanup";
import { Button } from "./ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { pullRequestListLines } from "./pullRequest/pullRequestListLines";
import {
Expand Down Expand Up @@ -218,6 +222,7 @@ export function resolveThreadPullRequestBadgePresentation({
*/
export function ThreadPullRequestBadgeControl({
render,
threadRef,
badge,
pullRequests,
number,
Expand All @@ -227,6 +232,7 @@ export function ThreadPullRequestBadgeControl({
onOpenPullRequest,
}: {
render: ReactElement<{ render?: useRender.RenderProp }>;
threadRef: ScopedThreadRef;
badge: ThreadPullRequestBadge | null;
pullRequests: ReadonlyArray<ThreadPullRequestLink>;
number?: number | undefined;
Expand All @@ -240,6 +246,7 @@ export function ThreadPullRequestBadgeControl({
return (
<PullRequestBadge
render={render}
threadRef={threadRef}
presentation={presentation}
opensList={badge !== null && (badge.kind === "stack" || badge.others > 0)}
url={url}
Expand All @@ -254,6 +261,7 @@ export function ThreadPullRequestBadgeControl({

function PullRequestBadge({
render,
threadRef,
presentation,
opensList,
url,
Expand All @@ -264,6 +272,7 @@ function PullRequestBadge({
onOpenPullRequest,
}: {
render: ReactElement<{ render?: useRender.RenderProp }>;
threadRef: ScopedThreadRef;
presentation: NonNullable<ReturnType<typeof resolveThreadPullRequestBadgePresentation>>;
opensList: boolean;
url: string | undefined;
Expand Down Expand Up @@ -317,6 +326,7 @@ function PullRequestBadge({
{visibleThreadPullRequests(pullRequests).length > 0 ? (
<ThreadPullRequestsMiniList
pullRequests={pullRequests}
threadRef={threadRef}
onOpenPullRequest={onOpenPullRequest}
/>
) : number !== undefined && url !== undefined ? (
Expand All @@ -337,13 +347,16 @@ function PullRequestBadge({

/**
* A miniature of the pull-requests panel for the thread tooltip: same order, same indentation,
* so the hover answers "what is in here" without opening the surface.
* so the hover answers "what is in here" without opening the surface. Watched pull requests
* show an eye. With `threadRef`, the popup is interactive and the eye stops the watch.
*/
export function ThreadPullRequestsMiniList({
pullRequests,
threadRef,
onOpenPullRequest,
}: {
pullRequests: ReadonlyArray<ThreadPullRequestLink>;
threadRef?: ScopedThreadRef;
onOpenPullRequest?: (event: MouseEvent<HTMLAnchorElement>, url: string) => void;
}) {
const lines = useMemo(
Expand All @@ -360,6 +373,9 @@ export function ThreadPullRequestsMiniList({
snapshot === null
? null
: resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft });
// Same rule as the panel: a watch only matters while the pull request is open.
const watching =
line.link.watch !== undefined && (snapshot === null || snapshot.state === "open");
return (
<ThreadPullRequestMiniListItem
key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
Expand All @@ -369,6 +385,17 @@ export function ThreadPullRequestsMiniList({
presentation={presentation}
depth={line.depth}
onOpenPullRequest={onOpenPullRequest}
action={
!watching ? null : threadRef ? (
<StopWatchingButton threadRef={threadRef} link={line.link} />
) : (
<EyeIcon
role="img"
aria-label="Watching"
className="size-3 shrink-0 text-foreground/75"
/>
)
}
>
{line.stack ? (
<span className="ml-auto shrink-0 pl-1 text-3xs">
Expand All @@ -389,6 +416,7 @@ function ThreadPullRequestMiniListItem({
presentation,
depth = 0,
onOpenPullRequest,
action,
children,
}: {
number: number;
Expand All @@ -397,6 +425,8 @@ function ThreadPullRequestMiniListItem({
presentation: Pick<ThreadPullRequestBadgePresentation, "Icon" | "toneClassName"> | null;
depth?: number;
onOpenPullRequest?: ((event: MouseEvent<HTMLAnchorElement>, url: string) => void) | undefined;
/** Trails the row, outside the link so it can be its own control. */
action?: ReactNode;
children?: ReactNode;
}) {
const Icon = presentation?.Icon ?? PullRequestGlyph.pullRequest;
Expand All @@ -412,25 +442,73 @@ function ThreadPullRequestMiniListItem({
</>
);
return (
<li style={{ paddingLeft: `${Math.min(depth, 3) * 0.75}rem` }}>
<li
className="flex min-w-0 items-center gap-1"
style={{ paddingLeft: `${Math.min(depth, 3) * 0.75}rem` }}
>
{onOpenPullRequest ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 items-center gap-2 rounded-sm px-1 py-1 hover:bg-accent focus-visible:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm px-1 py-1 hover:bg-accent focus-visible:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => onOpenPullRequest(event, url)}
>
{content}
</a>
) : (
<div className="flex min-w-0 items-center gap-2">{content}</div>
<div className="flex min-w-0 flex-1 items-center gap-2">{content}</div>
)}
{action}
</li>
);
}

/**
* The eye on a watched pull request in an interactive popup. Hover shows the crossed eye, and a
* click stops the watch; the row loses the eye once the server records it.
*/
function StopWatchingButton({
threadRef,
link,
}: {
threadRef: ScopedThreadRef;
link: ThreadPullRequestLink;
}) {
const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
const [stopping, setStopping] = useState(false);
return (
<Button
variant="ghost"
size="icon-micro"
aria-label={`Stop watching #${link.number}`}
disabled={stopping}
className="group/watch shrink-0"
// The popup portals out, but React still bubbles to the sidebar row behind it.
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setStopping(true);
void watch({
environmentId: threadRef.environmentId,
input: {
threadId: threadRef.threadId,
host: link.host,
repository: link.repository,
number: link.number,
watching: false,
},
}).finally(() => setStopping(false));
}}
>
<EyeIcon aria-hidden className="size-3 group-hover/watch:hidden" />
<EyeOffIcon aria-hidden className="hidden size-3 group-hover/watch:block" />
</Button>
);
}

export function prStatusIndicator(
pr: ThreadPr,
provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
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. Watched pull requests show an eye when you hover the pull request
badge; click the eye 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