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,
};
}