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
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,3 @@ node_modules/
*.log
.env*
!.env.example

# pnpm content-addressable store; never belongs in the repo.
.pnpm-store/
17 changes: 17 additions & 0 deletions apps/mobile/src/features/review/reviewCommentSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ function makeTarget(): ReviewCommentTarget {
}

describe("review comment serialization", () => {
it("keeps closing-tag text inside a chip label within a real review body", () => {
const body = "Before [</review_comment>](t3-context://v1/mention/context-1) after";
const serialized = `<review_comment sectionId="s" filePath="app.ts" startIndex="0" endIndex="0">${body}</review_comment>`;
const segments = parseReviewCommentMessageSegments(`${serialized} tail`);
expect(segments).toEqual([
{ kind: "review-comment", comment: expect.objectContaining({ text: body }) },
{ kind: "text", id: `review-comment-text:${serialized.length}`, text: " tail" },
]);
});

it("treats legacy markup inside a context label as opaque text", () => {
const text =
'[<review_comment sectionId="s" filePath="app.ts" startIndex="0" endIndex="0">Review this</review_comment>](t3-context://v1/mention/context-1)';
expect(parseReviewCommentMessageSegments(text)).toEqual([
{ kind: "text", id: "review-comment-text:0", text },
]);
});
it("preserves enough metadata for inline diff rendering", () => {
const serialized = formatReviewCommentContext(makeTarget(), "Please keep this configurable.");

Expand Down
23 changes: 18 additions & 5 deletions apps/mobile/src/features/review/reviewCommentSelection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useSyncExternalStore } from "react";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";

import type { ReviewRenderableLineRow } from "./reviewModel";

Expand Down Expand Up @@ -286,9 +287,12 @@ export function parseReviewCommentMessageSegments(
const segments: ReviewCommentMessageSegment[] = [];
let cursor = 0;
let parsedCommentIndex = 0;

for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {
const matchIndex = match.index ?? 0;
// Labels are opaque text, even when they contain legacy review markup. Keep offsets intact.
const masked = replaceComposerContextReferences(value, (reference) =>
" ".repeat(reference.source.length),
);
for (const match of masked.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {
const matchIndex = match.index;
const beforeText = value.slice(cursor, matchIndex);
if (beforeText.length > 0) {
segments.push({
Expand All @@ -298,15 +302,24 @@ export function parseReviewCommentMessageSegments(
});
}

const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", parsedCommentIndex);
// Use the masked delimiters but read the original payload. Re-parsing raw text could
// mistake a closing tag inside a chip label for the end of the review.
const raw = value.slice(matchIndex, matchIndex + match[0].length);
const attributeStart = "<review_comment".length;
const attributeEnd = attributeStart + (match[1]?.length ?? 0);
const comment = parseReviewInlineComment(
raw.slice(attributeStart, attributeEnd),
raw.slice(attributeEnd + 1, -"</review_comment>".length),
parsedCommentIndex,
);
if (comment) {
segments.push({ kind: "review-comment", comment });
parsedCommentIndex += 1;
} else {
segments.push({
kind: "text",
id: `review-comment-invalid:${matchIndex}`,
text: match[0],
text: value.slice(matchIndex, matchIndex + match[0].length),
});
}

Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1680,7 +1680,14 @@ function UserMessageContent(props: {
}) {
// Inline context references render as their labels until mobile grows chips for them.
const text = replaceComposerContextReferences(props.text, (occurrence) => occurrence.label);
const segments = parseReviewCommentMessageSegments(text);
const segments = parseReviewCommentMessageSegments(props.text).map((segment) =>
segment.kind === "text"
? {
...segment,
text: replaceComposerContextReferences(segment.text, (occurrence) => occurrence.label),
}
: segment,
);
const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
if (!hasReviewComment) {
if (hasNativeSelectableMarkdownText()) {
Expand Down
163 changes: 161 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ function pasteText(editor: ReturnType<typeof createEditor>, text: string) {
class TestClipboardEvent extends Event {
readonly clipboardData: DataTransfer;

constructor(text: string) {
constructor(text: string, extra: Record<string, string> = {}) {
super("paste", { cancelable: true });
this.clipboardData = {
files: [],
getData: (type: string) => (type === "text/plain" ? text : ""),
getData: (type: string) => (type === "text/plain" ? text : (extra[type] ?? "")),
} as unknown as DataTransfer;
}
}
Expand Down Expand Up @@ -492,6 +492,165 @@ describe("registerComposerInlineTokenPaste", () => {
});

describe("context reference paste", () => {
it("imports a structured fragment and rewrites ids the importer changed", () => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createEditor({ nodes: [ComposerCitationNode] });
editor.update(
() => {
const paragraph = $createParagraphNode();
$getRoot().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
const imported: string[] = [];
registerComposerInlineTokenPaste(editor, {
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
createCitationNode: $createComposerCitationNode,
createContextReferenceNode: (reference) =>
$createTextNode(`<context:${reference.contextId}>`),
getExpandedAbsoluteOffsetForPoint: () => 0,
importContextFragment: (fragment) => {
imported.push(...fragment.records.map((record) => record.contextId));
return new Map([["img-old", "img-new"]]);
},
});
const event = new TestClipboardEvent(
"![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)",
{
"web application/x-t3-context-fragment+json": JSON.stringify({
version: 1,
source: { environmentId: "env-1" },
records: [
{
version: 1,
contextId: "img-old",
kind: "image",
label: "shot",
attachmentId: "a",
name: "shot.png",
mimeType: "image/png",
sizeBytes: 1,
},
{
version: 1,
contextId: "ctx-t",
kind: "terminal",
label: "T",
terminalId: "t",
terminalLabel: "T",
lineStart: 1,
lineEnd: 1,
text: "x",
},
{
version: 1,
contextId: "img-unrelated",
kind: "image",
label: "other",
attachmentId: "b",
name: "other.png",
mimeType: "image/png",
sizeBytes: 1,
},
],
}),
},
);
editor.update(
() => {
editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
},
{ discrete: true },
);
expect(imported).toEqual(["img-old", "ctx-t"]);
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
"<context:img-new> and <context:ctx-t>",
);
});

it("imports an annotation's dependent screenshot when only its chip is pasted", () => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createEditor({ nodes: [ComposerCitationNode] });
editor.update(
() => {
const paragraph = $createParagraphNode();
$getRoot().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
const imported: string[] = [];
registerComposerInlineTokenPaste(editor, {
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
createCitationNode: $createComposerCitationNode,
createContextReferenceNode: (reference) =>
$createTextNode(`<context:${reference.contextId}>`),
getExpandedAbsoluteOffsetForPoint: () => 0,
importContextFragment: (fragment) => {
imported.push(...fragment.records.map((record) => record.contextId));
return new Map();
},
});
const annotationId = "preview-annotation_ann-1";
const screenshotId = "image_ann-1";
const event = new TestClipboardEvent(
`[Fix button](t3-context://v1/preview-annotation/${annotationId})`,
{
"web application/x-t3-context-fragment+json": JSON.stringify({
version: 1,
source: { environmentId: "env-1" },
records: [
{
version: 1,
contextId: annotationId,
kind: "preview-annotation",
label: "Fix button",
annotationId: "ann-1",
pageUrl: "https://example.com",
pageTitle: "Example",
comment: "Fix button",
targetSummary: "1 selected element",
styleChanges: [],
screenshotContextId: screenshotId,
},
{
version: 1,
contextId: screenshotId,
kind: "image",
label: "annotation.png",
attachmentId: "attachment-1",
name: "annotation.png",
mimeType: "image/png",
sizeBytes: 10,
},
{
version: 1,
contextId: "image_unrelated",
kind: "image",
label: "unrelated.png",
attachmentId: "attachment-2",
name: "unrelated.png",
mimeType: "image/png",
sizeBytes: 10,
},
],
}),
},
);
editor.update(
() => {
editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
},
{ discrete: true },
);

expect(imported).toEqual([annotationId, screenshotId]);
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
`<context:${annotationId}>`,
);
});

it("turns pasted context links into reference nodes", () => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createCitationEditor("see ");
Expand Down
Loading
Loading