diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 18908302d577e..359ea229cb8cc 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -435,7 +435,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; - const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); + const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); const columnMetrics = useNativeColumnLayoutMetrics(); @@ -475,6 +475,7 @@ export function ReviewSheet(props: ReviewSheetProps) { cwd: selectedThreadCwd, selectedSection, draftMessage, + draftContext, }); // Expo may return null before registration or in older binaries; use the fallback. const NativeReviewDiffView = resolveNativeReviewDiffView(); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index 59a0bb6bcb683..81eb0bba4083a 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; + +import { pullRequestComposerContext } from "../../lib/composerContext"; import { clearReviewCommentTarget, @@ -50,6 +54,74 @@ function makeTarget(): ReviewCommentTarget { } describe("review comment serialization", () => { + it("excludes a linked pull request chip from draft review comments", () => { + const record = pullRequestComposerContext( + { + number: 42, + title: "Fix checkout", + url: "https://github.com/example/repo/pull/42", + headBranch: "fix-checkout", + baseBranch: "main", + state: "open", + isDraft: false, + }, + "pr-42", + ); + + expect( + parseReviewInlineComments(formatComposerContextReference(record), { + version: 1, + records: [record], + }), + ).toEqual([]); + }); + + it("reads an added review comment after the composer upgrades it to a context chip", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Please keep this configurable."), + ); + const comments = parseReviewInlineComments(upgraded.text, { + version: 1, + records: upgraded.records, + }); + + expect(comments).toEqual([ + { + id: upgraded.records[0]!.contextId, + sectionId: "section-1", + sectionTitle: "Working tree", + filePath: "apps/demo/src/main.ts", + startIndex: 0, + endIndex: 1, + rangeLabel: "7", + text: "Please keep this configurable.", + diff: "@@ -7,1 +7,1 @@\n-const retryLimit = 2;\n+const retryLimit = 4;", + fenceLanguage: "diff", + }, + ]); + }); + + it("keeps legacy comments and counts a typed comment only while its chip is present", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Typed comment"), + ); + const context = { version: 1, records: upgraded.records } as const; + const legacy = formatReviewCommentContext(makeTarget(), "Legacy comment"); + const comments = parseReviewInlineComments( + `${legacy}\n${upgraded.text}\n${upgraded.text}`, + context, + ); + + expect(comments.map((comment) => comment.text)).toEqual(["Legacy comment", "Typed comment"]); + expect(parseReviewInlineComments(legacy, context).map((comment) => comment.text)).toEqual([ + "Legacy comment", + ]); + expect(parseReviewInlineComments("", context)).toEqual([]); + expect( + parseReviewInlineComments(upgraded.text.replace("/review-comment/", "/mention/"), context), + ).toEqual([]); + }); + it("keeps closing-tag text inside a chip label within a real review body", () => { const body = "Before [](t3-context://v1/mention/context-1) after"; const serialized = `${body}`; diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 4e3b22407f5a0..70f226d604e04 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -1,5 +1,9 @@ import { useSyncExternalStore } from "react"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import type { OrchestrationMessageContext } from "@t3tools/contracts"; +import { + collectComposerContextReferences, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import type { ReviewRenderableLineRow } from "./reviewModel"; @@ -357,11 +361,10 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: ].join("\n"); } -export function countReviewCommentContexts(value: string): number { - return Array.from(value.matchAll(/ { +export function parseReviewInlineComments( + value: string, + context?: OrchestrationMessageContext, +): ReadonlyArray { const comments: ReviewInlineComment[] = []; // Mask chip delimiters: labels containing would otherwise truncate the block. const masked = replaceComposerContextReferences(value, (reference) => @@ -385,6 +388,33 @@ export function parseReviewInlineComments(value: string): ReadonlyArray reference.kind === "review-comment") + .map((reference) => reference.contextId), + ); + for (const record of context?.records ?? []) { + if ( + record.kind !== "review-comment" || + "payload" in record || + record.sectionId.startsWith("pull-request:") || + !referencedIds.has(record.contextId) + ) { + continue; + } + comments.push({ + id: record.contextId, + sectionId: record.sectionId, + sectionTitle: record.sectionTitle, + filePath: record.filePath, + startIndex: record.startIndex, + endIndex: record.endIndex, + rangeLabel: record.rangeLabel, + text: record.text, + diff: record.diff, + ...(record.fenceLanguage !== undefined ? { fenceLanguage: record.fenceLanguage } : {}), + }); + } return comments; } diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 6ccc5ae3f495c..d9ec656cbc773 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,7 +1,7 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; +import { parseReviewInlineComments } from "./reviewCommentSelection"; import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { getCachedReviewParsedDiff } from "./reviewState"; import { @@ -14,6 +14,7 @@ import { import type { EnvironmentId, + OrchestrationMessageContext, ReviewDiffFileStat, ReviewDiffPreviewSource, } from "@t3tools/contracts"; @@ -126,8 +127,9 @@ export function useReviewDiffData(input: { readonly cwd: string | null; readonly selectedSection: ReviewSectionItem | null; readonly draftMessage: string; + readonly draftContext: OrchestrationMessageContext | undefined; }) { - const { draftMessage, selectedSection, threadKey } = input; + const { draftMessage, draftContext, selectedSection, threadKey } = input; const selectedSectionId = selectedSection?.id ?? null; const source = selectedSection?.source; const lazySource = source?.truncated && source.files ? source : null; @@ -230,7 +232,7 @@ export function useReviewDiffData(input: { () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files), [parsedDiff, selectedSection?.files], ); - const inlineReviewComments = parseReviewInlineComments(draftMessage); + const inlineReviewComments = parseReviewInlineComments(draftMessage, draftContext); const selectedSectionInlineComments = useMemo(() => { if (!selectedSectionId || inlineReviewComments.length === 0) { return EMPTY_INLINE_REVIEW_COMMENTS; @@ -241,7 +243,7 @@ export function useReviewDiffData(input: { parsedDiff, comments: selectedSectionInlineComments, }); - const pendingReviewCommentCount = countReviewCommentContexts(draftMessage); + const pendingReviewCommentCount = inlineReviewComments.length; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records review perf marks and diagnostic logs once a parsed diff has committed useEffect(() => { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4c14fb56254cf..70957f63c74da 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -163,6 +163,7 @@ export function useThreadDraftForThread(input: { return { draftMessage: draft.text, + draftContext: draft.context, draftAttachments: draft.attachments, }; }