From ff8cbc90753166e0529bad5df960350e327b7cdb Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:21:22 +0000 Subject: [PATCH 01/41] fix(web): keep pull request review annotations in the app font --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 93418e7c301..6eb3a7d81de 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -742,7 +742,7 @@ export function PullRequestCodeTab({ const renderAnnotation = useCallback( (annotation: ReviewAnnotation) => ( -
+
{annotation.metadata.threads.map(renderThreadCard)} {annotation.metadata.pending.map((comment) => ( Date: Mon, 10 Aug 2026 19:21:40 +0000 Subject: [PATCH 02/41] feat(web): collapse pull request chrome on scroll in the right panel --- apps/web/src/components/ChatView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 762dac559f2..9b8b1892007 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6055,6 +6055,7 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } + chromeVariant="collapse" onStateChange={handlePullRequestTabStatusChange} /> ) : activeRightPanelSurface?.kind === "agents" ? ( From 58bc0ba6ac06759f4dcbbc8dabd7b431ae4d6894 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:21:56 +0000 Subject: [PATCH 03/41] feat(web): hand pull request tasks to the open thread beside the panel --- apps/web/src/components/ChatView.tsx | 1 + .../pullRequest/PullRequestDetailPanel.tsx | 75 ++++++++++++++----- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9b8b1892007..94e4f88f1e2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6056,6 +6056,7 @@ function ChatViewContent(props: ChatViewProps) { : "page" } chromeVariant="collapse" + composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> ) : activeRightPanelSurface?.kind === "agents" ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4ac18128010..01f1ea14492 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, @@ -6,6 +6,7 @@ import type { PullRequestMergeMethod, PullRequestRef, PullRequestState, + ScopedThreadRef, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -159,7 +160,10 @@ const PullRequestCodeTab = lazy(loadCodeTab); * is closed by the time the next one opens. It is how a prompt the reader has since edited is told * apart from the one they were handed: only the sentence still exactly as written may be replaced. */ -const lastHandoffPromptByDraft = new Map(); +const lastHandoffPromptByDraft = new Map(); + +const composerTargetKey = (target: ScopedThreadRef | DraftId): string => + typeof target === "string" ? target : scopedThreadKey(target); export function PullRequestDetailPanel({ environmentId, @@ -170,6 +174,7 @@ export function PullRequestDetailPanel({ onStateChange, context = "page", chromeVariant = "full", + composerDraftTarget, }: { environmentId: EnvironmentId; reference: PullRequestRef; @@ -206,6 +211,11 @@ export function PullRequestDetailPanel({ * top — the chrome spends its height on what is being read. */ chromeVariant?: "full" | "collapse"; + /** + * The open thread's composer. Beside the thread whose own pull request this is, hand-offs + * land here instead of opening a new thread — the branch is already under the reader's feet. + */ + composerDraftTarget?: ScopedThreadRef | DraftId; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; const [tab, setTab] = useState("summary"); @@ -393,6 +403,27 @@ export function PullRequestDetailPanel({ reviewComments?: ReadonlyArray; }; + // Beside the thread whose own pull request this is, a task belongs in that thread's composer: + // the branch is already checked out under it, so opening a second thread would only scatter + // the work. + const attachTarget = context === "thread" ? (composerDraftTarget ?? null) : null; + + const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(target); + const key = composerTargetKey(target); + const prompt = handoffPrompt( + { prompt: draft?.prompt ?? "", lastHandoffPrompt: lastHandoffPromptByDraft.get(key) }, + task.prompt, + ); + lastHandoffPromptByDraft.set(key, task.prompt); + store.setPrompt(target, prompt); + store.setReviewComments( + target, + handoffReviewComments(draft?.reviewComments ?? [], task.reviewComments ?? []), + ); + }; + /** * Opens a thread on this project and leaves the task in its composer for the reader to send. * @@ -412,35 +443,30 @@ export function PullRequestDetailPanel({ () => null, )); if (session === null) return null; - const store = useComposerDraftStore.getState(); if (task === null) return session; // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips // both, rather than stacking a second one under the first. What the reader typed themselves // survives — the composer they are handed is not always a fresh one, and a prompt they have // since edited is theirs rather than the hand-off's. - const draft = store.getComposerDraft(session.draftId); - const existingComments = draft?.reviewComments ?? []; - const prompt = handoffPrompt( - { - prompt: draft?.prompt ?? "", - lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId), - }, - task.prompt, - ); - // Remember the hand-off's own contribution, not the merged prompt: only that sentence is - // this session's to take back next time, and the reader's text around it is not. - lastHandoffPromptByDraft.set(session.draftId, task.prompt); - store.setPrompt(session.draftId, prompt); - store.setReviewComments( - session.draftId, - handoffReviewComments(existingComments, task.reviewComments ?? []), - ); + writeTaskToComposer(session.draftId, task); return session; }; /** A question about the change, which needs a thread and nothing else. */ const startAsk = async (kind: string, task: ThreadTask) => { if (!detail || handoff !== null) return; + if (attachTarget !== null) { + writeTaskToComposer(attachTarget, task); + toastManager.add({ + type: "success", + title: "Added to the composer", + description: + task.prompt.length > 0 + ? "The question is in the composer — read it over, then send." + : "The pull request is in the composer — type your question, then send.", + }); + return; + } setHandoff(kind); const projectRef = scopeProjectRef(environmentId, detail.projectId); const opened = await openThreadWithTask(projectRef, task); @@ -477,6 +503,15 @@ export function PullRequestDetailPanel({ mode: "worktree" | "local" = "worktree", ) => { if (!detail || handoff !== null) return; + if (attachTarget !== null && task !== null) { + writeTaskToComposer(attachTarget, task); + toastManager.add({ + type: "success", + title: "Added to the composer", + description: "The task is in the composer — read it over, then send.", + }); + return; + } setHandoff(kind); // The menu closes on the press and takes its "Preparing..." label with it, so this is the // only thing answering for the checkout. It carries no timeout of its own: a loading toast From e9cbc088c93628e53f6f0e9613b3623fe1d502ca Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:49:02 +0000 Subject: [PATCH 04/41] feat(web): show labels and resolved conversations on the pull request summary An approval review is a verdict rather than a finding, so its comment no longer offers the fix-in-a-thread hand-off. --- .../pullRequest/PullRequestSummaryTab.tsx | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 345753fe880..755f74fdfbf 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -1,10 +1,12 @@ import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; import { ArrowDownUpIcon, + CheckCircle2Icon, ChevronRightIcon, HammerIcon, MessageSquareIcon, SendIcon, + TagIcon, UsersIcon, } from "lucide-react"; import { useState, type ReactNode } from "react"; @@ -36,6 +38,12 @@ import { import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; +/** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ +function labelDotColor(color: string | null): string | null { + const hex = color?.trim().replace(/^#/, "") ?? ""; + return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; +} + function MetaRow({ icon, label, @@ -261,6 +269,28 @@ export function PullRequestSummaryTab({ ) : null} + {detail.labels.length > 0 ? ( + } label="Labels"> + + {detail.labels.map((label) => { + const dot = labelDotColor(label.color); + return ( + + + {label.name} + + ); + })} + + + ) : null} } label="Comments"> {activityPending ? "Loading conversation…" @@ -385,8 +415,10 @@ export function PullRequestSummaryTab({ ) : null} {visibleComments.map((comment) => { const thread = threadByCommentId.get(comment.id); + // An approval is a verdict, not a finding: there is nothing in it to fix. const finding: PullRequestFinding | null = - comment.kind !== "review" && comment.kind !== "review-comment" + (comment.kind !== "review" && comment.kind !== "review-comment") || + comment.reviewState?.toLowerCase() === "approved" ? null : thread === undefined ? { kind: "comment", comment } @@ -410,6 +442,12 @@ export function PullRequestSummaryTab({ {comment.reviewState ? ( {comment.reviewState.toLowerCase()} ) : null} + {thread?.isResolved ? ( + + + Resolved + + ) : null} {/* Review remarks only. A plain conversation comment is talk, not a finding, and offering to fix one would promise more than it says. */} From 40959176708bb49c41165ea9ed9a000e0d3b5302 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:49:09 +0000 Subject: [PATCH 05/41] fix(web): report host line counts for files with withheld hunks GitHub omits hunks for files it will not inline, so the viewer's hunk-sum counts read -0 +0. The host's own additions/deletions now ride the diff slice, and the code tab draws every file's counts itself. --- .../src/pullRequest/GitHubPullRequestCli.ts | 6 +++ .../src/pullRequest/PullRequestProvider.ts | 3 ++ .../src/pullRequest/gitHubPullRequestJson.ts | 12 ++++- .../components/diffs/StyledDiffCodeView.tsx | 10 +++- .../pullRequest/PullRequestCodeTab.tsx | 49 +++++++++++++++++++ packages/contracts/src/pullRequest.ts | 13 +++++ 6 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 392fac8564f..416f503b4c5 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -9,6 +9,7 @@ import type { PullRequestInvolvement, PullRequestListState, PullRequestMergeMethod, + PullRequestOmittedFileStat, PullRequestReviewCommentDraft, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -270,6 +271,8 @@ export interface GitHubPullRequestDiffSlice { readonly truncated: boolean; /** Where the next slice starts, or null once the patch is whole. */ readonly nextCursor: string | null; + /** GitHub's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; } export class GitHubPullRequestCli extends Context.Service< @@ -796,6 +799,9 @@ export const make = Effect.gen(function* () { patch: decoded.success.patch, truncated: decoded.success.truncated, nextCursor: morePages ? String(input.page + 1) : null, + ...(decoded.success.omittedFileStats.length === 0 + ? {} + : { omittedFileStats: decoded.success.omittedFileStats }), }); }), ); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 34ec28b4106..274cde69f7c 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -13,6 +13,7 @@ import type { PullRequestMergeCapabilities, PullRequestMergeMethod, PullRequestMergeability, + PullRequestOmittedFileStat, PullRequestReviewCommentDraft, PullRequestReviewThread, PullRequestReviewVerdict, @@ -165,6 +166,8 @@ export interface ProviderDiffSlice { /** Something in this slice could not be shown, as opposed to there being more slices. */ readonly truncated: boolean; readonly nextCursor: string | null; + /** The host's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; } export interface ProviderDiffFileContents { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 8668d840ce1..6de132b4607 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -10,6 +10,7 @@ import type { PullRequestCommit, PullRequestLabel, PullRequestMergeCapabilities, + PullRequestOmittedFileStat, PullRequestMergeability, PullRequestReviewCommentDraft, PullRequestReviewThread, @@ -1539,6 +1540,8 @@ export interface GitHubPullRequestFilesPatch { readonly truncated: boolean; /** Files GitHub returned, counted before decoding, so the caller can page. */ readonly rawCount: number; + /** GitHub's own counts for the files whose hunks it withheld. */ + readonly omittedFileStats: ReadonlyArray; } /** @@ -1554,6 +1557,7 @@ export function decodePullRequestFilesJson( return Result.fail(decoded.failure); } const sections: string[] = []; + const omittedFileStats: PullRequestOmittedFileStat[] = []; let truncated = false; for (const entry of decoded.success) { const file = decodeFileEntry(entry); @@ -1565,7 +1569,12 @@ export function decodePullRequestFilesJson( // A file with no hunks is still a file that changed: a pure rename has none to give, and // a binary one has none that can be shown. Both are listed, and only the second is a hole // in the patch — leaving them out entirely would drop them from the change altogether. - if ((value.additions ?? 0) + (value.deletions ?? 0) > 0) truncated = true; + const additions = value.additions ?? 0; + const deletions = value.deletions ?? 0; + if (additions + deletions > 0) { + truncated = true; + omittedFileStats.push({ path: value.filename, additions, deletions }); + } } // A rename counts its hunks against the old path, which is the only place it is named. const oldPath = @@ -1586,5 +1595,6 @@ export function decodePullRequestFilesJson( patch: sections.join(""), truncated, rawCount: decoded.success.length, + omittedFileStats, }); } diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index f422c7aebbd..37ce2085b71 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -269,6 +269,11 @@ type StyledDiffCodeViewProps = ( ) & { readonly options?: StyledDiffCodeViewOptions; readonly viewerRef?: Ref>; + /** + * Appended to the shared stylesheet inside the viewer's shadow root, for a surface that has + * to restyle chrome the viewer owns — such as replacing its per-file line counts. + */ + readonly unsafeCSSExtra?: string; }; /** The shared web CodeView surface: app styling and virtualized geometry stay paired here. */ @@ -276,6 +281,7 @@ export function StyledDiffCodeView({ options, viewerRef, className, + unsafeCSSExtra, ...props }: StyledDiffCodeViewProps) { return ( @@ -291,7 +297,9 @@ export function StyledDiffCodeView({ } options={{ ...options, - unsafeCSS: DIFF_VIEW_UNSAFE_CSS, + unsafeCSS: unsafeCSSExtra + ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}` + : DIFF_VIEW_UNSAFE_CSS, itemMetrics: { diffHeaderHeight: 32, hunkSeparatorHeight: 24, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 6eb3a7d81de..5bb4b5779bc 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -4,6 +4,7 @@ import type { EnvironmentId, PullRequestDetailView, PullRequestDiffSide, + PullRequestOmittedFileStat, PullRequestRef, PullRequestReviewThread, } from "@t3tools/contracts"; @@ -95,8 +96,20 @@ interface DiffSlice { readonly patch: string; readonly truncated: boolean; readonly nextCursor: string | null; + readonly omittedFileStats: ReadonlyArray; } +/** + * The viewer's own per-file counts are hidden and drawn from this side of its shadow root + * instead: its counts are hunk sums, and a file whose hunks the host withheld would read as + * an empty change rather than as the counts the host did report. + */ +const REPLACE_FILE_COUNTS_CSS = ` +[data-diffs-header] [data-additions-count], +[data-diffs-header] [data-deletions-count] { + display: none !important; +}`; + /** Nothing loaded yet, as one identity, so the memos below do not see a new array every render. */ const NO_SLICES: ReadonlyArray = []; @@ -246,6 +259,7 @@ export function PullRequestCodeTab({ patch: data.patch, truncated: data.truncated, nextCursor: data.nextCursor, + omittedFileStats: data.omittedFileStats ?? [], }; const index = slices.findIndex((slice) => slice.cursor === cursor); if (index === -1) { @@ -471,6 +485,15 @@ export function PullRequestCodeTab({ ], ); const lineStat = useMemo(() => getDiffLineStat(files), [files]); + const omittedFileStats = useMemo( + () => + new Map( + loadedSlices.flatMap((slice) => + slice.omittedFileStats.map((file) => [file.path, file] as const), + ), + ), + [loadedSlices], + ); const fileKeys = useMemo(() => items.map((item) => item.id), [items]); const collapsedFileKeys = useMemo( () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)), @@ -649,6 +672,30 @@ export function PullRequestCodeTab({ [toggleFile], ); + const renderHeaderMetadata = useCallback( + (item: CodeViewItem) => { + if (item.type !== "diff") return null; + let additions = 0; + let deletions = 0; + for (const hunk of item.fileDiff.hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + if (additions === 0 && deletions === 0) { + const withheld = omittedFileStats.get(resolveFileDiffPath(item.fileDiff)); + if (withheld) ({ additions, deletions } = withheld); + } + return ( + + ); + }, + [omittedFileStats], + ); + const diffViewOptions = useMemo( () => ({ diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const), @@ -1222,7 +1269,9 @@ export function PullRequestCodeTab({ // is running out of diff. renderCodeViewFooter={renderCodeViewFooter} renderHeaderPrefix={renderHeaderPrefix} + renderHeaderMetadata={renderHeaderMetadata} renderAnnotation={renderAnnotation} + unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} /> {reviewOverlay}
diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 707ec540d15..cd8a7a9ef75 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -562,6 +562,14 @@ export const PullRequestDiffInput = Schema.Struct({ }); export type PullRequestDiffInput = typeof PullRequestDiffInput.Type; +/** Real line counts for a file whose hunks the host withheld from the patch. */ +export const PullRequestOmittedFileStat = Schema.Struct({ + path: TrimmedNonEmptyString, + additions: Schema.Number, + deletions: Schema.Number, +}); +export type PullRequestOmittedFileStat = typeof PullRequestOmittedFileStat.Type; + export const PullRequestDiffResult = Schema.Struct({ patch: Schema.String, /** @@ -571,6 +579,11 @@ export const PullRequestDiffResult = Schema.Struct({ truncated: Schema.Boolean, /** Where the next slice starts, or null once the diff is whole. */ nextCursor: Schema.NullOr(TrimmedNonEmptyString), + /** + * The host's own counts for the files whose hunks it withheld, so a file the patch cannot + * show still reports what changed instead of a zero the diff never had. + */ + omittedFileStats: Schema.optional(Schema.Array(PullRequestOmittedFileStat)), }); export type PullRequestDiffResult = typeof PullRequestDiffResult.Type; From b27a2dde60725367f05e5e5ae5b6a431460c7a4c Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:55:42 +0000 Subject: [PATCH 06/41] fix(web): hide the tab strip scrollbar in narrow pull request panels --- apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 01f1ea14492..18352b37c74 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1213,7 +1213,7 @@ export function PullRequestDetailPanel({ {detail ? (