Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
4a60105
feat(context): persist typed inline context references
chrisdeeming Sep 5, 2026
b1fb714
fix(context): address foundation review findings and unused exports
chrisdeeming Sep 6, 2026
34a7d67
fix(context): preserve reference targets and reject ambiguous identities
chrisdeeming Sep 6, 2026
d252a43
fix(context): bound future context payloads
chrisdeeming Sep 6, 2026
3d6de89
fix(context): load context in the optimized turn-start query
chrisdeeming Sep 6, 2026
7a29062
fix(context): preserve malformed legacy context as text
chrisdeeming Sep 6, 2026
1579298
fix(context): distinguish preview boundaries from literal opening tags
chrisdeeming Sep 6, 2026
418987f
fix(context): preserve invalid legacy input and reject ambiguous uploads
chrisdeeming Sep 7, 2026
64ac961
fix(contracts): retain structured preview style edits for context tra…
chrisdeeming Sep 7, 2026
fd2c2f8
fix(context): normalize labels on manually entered references
chrisdeeming Sep 7, 2026
085daf4
fix(context): bound aggregate payloads and preserve terminal token bo…
chrisdeeming Sep 7, 2026
3ff30e6
fix(shared): upgrade terminal labels before punctuation
chrisdeeming Sep 7, 2026
14c3840
fix(shared): preserve malformed legacy context and token boundaries
chrisdeeming Sep 7, 2026
7ec4e55
fix(shared): preserve malformed legacy context atomically
chrisdeeming Sep 7, 2026
ab6b5bb
fix(shared): preserve review-shaped legacy context payloads
chrisdeeming Sep 7, 2026
1c97d83
fix(server): scope duplicate attachment IDs to turns
chrisdeeming Sep 8, 2026
1f9834a
fix(server): project context before provider send
chrisdeeming Sep 8, 2026
a42b841
test(server): use effect test runtime for context projection
chrisdeeming Sep 8, 2026
1b6287c
fix(shared): preserve markdown hard breaks during context upgrade
chrisdeeming Sep 8, 2026
294c85a
fix(server): tolerate renumbered context migration
chrisdeeming Sep 9, 2026
6e8a227
feat(web): move composer context into inline references
chrisdeeming Sep 5, 2026
1620735
refactor(web): remove unused context helper exports
chrisdeeming Sep 6, 2026
51b87fc
refactor(web): defer paste helpers to the clipboard layer
chrisdeeming Sep 6, 2026
bd49625
fix(web): keep composer context references and draft records in sync
chrisdeeming Sep 7, 2026
fb4adb0
fix(web): serialize exact preview style targets and values
chrisdeeming Sep 7, 2026
d725b3f
test(web): cover structured annotation output in the composer layer
chrisdeeming Sep 7, 2026
c8a7105
fix(web): normalize legacy terminal identities and context-only titles
chrisdeeming Sep 7, 2026
a517d47
fix(web): preserve malformed history and focus tooltip-only context
chrisdeeming Sep 7, 2026
16512b7
fix(web): namespace context identities and migrate persisted references
chrisdeeming Sep 7, 2026
f4df60b
fix(web): resolve scoped references in the draft chip registry
chrisdeeming Sep 7, 2026
948888c
fix(web): hide standalone images already referenced inline
chrisdeeming Sep 7, 2026
8c288ab
fix(web): report replacement attachments as accepted
chrisdeeming Sep 7, 2026
b453046
fix(web): recall preview comments containing literal tag lines
chrisdeeming Sep 7, 2026
21f0988
fix(web): preserve composer context identity and insertion behavior
chrisdeeming Sep 7, 2026
3920318
fix(web): prefer canonical context links during draft migration
chrisdeeming Sep 7, 2026
7e36b42
fix(web): bind annotation screenshots only to images
chrisdeeming Sep 7, 2026
22fc0e4
fix(web): restore file references with draft attachments
chrisdeeming Sep 7, 2026
1dbed04
fix(web): synchronize bulk preview annotation references
chrisdeeming Sep 7, 2026
652ea04
fix(web): preserve composer context across servers, undo and large se…
chrisdeeming Sep 7, 2026
cf2aba5
fix(web): preserve single-image draft contract
chrisdeeming Sep 8, 2026
f387444
fix(web): retain file attachment list after rebase
chrisdeeming Sep 8, 2026
3abe85c
fix(web): retain attachment context through undo
chrisdeeming Sep 8, 2026
fdbd38a
fix(web): keep inline files inside composer
chrisdeeming Sep 9, 2026
e98224f
feat(context): preserve references across copy paste and stash
chrisdeeming Sep 5, 2026
d689d9b
fix(context): export record limit with its clipboard consumer
chrisdeeming Sep 6, 2026
6741d19
fix(web): preserve context popovers in copied selections
chrisdeeming Sep 6, 2026
230c797
refactor(context): introduce paste helpers with their consumers
chrisdeeming Sep 6, 2026
3c283eb
fix(clipboard): align fragment budgets and refresh stash context call…
chrisdeeming Sep 7, 2026
cf503c9
fix(clipboard): restore preview styles from structured edit data
chrisdeeming Sep 7, 2026
36e92f3
fix(mobile): mask context labels in the compatibility review parser
chrisdeeming Sep 7, 2026
e06758b
fix(clipboard): protect plain text and bound attachment downloads
chrisdeeming Sep 7, 2026
1c568fa
fix(clipboard): import draft context without an active thread
chrisdeeming Sep 7, 2026
2d4ce93
fix(clipboard): retain canonical positions for structured message copy
chrisdeeming Sep 7, 2026
3d18aff
fix(web): preserve imported context ids and memoized chip rendering
chrisdeeming Sep 7, 2026
bd1e7dd
fix(web): keep context renderer dependencies precise
chrisdeeming Sep 7, 2026
f424c4b
fix(web): preserve restorable attachment context in copy and stash
chrisdeeming Sep 7, 2026
1e8a179
fix(web): keep pasted context distinct and annotation screenshots intact
chrisdeeming Sep 7, 2026
ac8b3b6
fix(web): preserve dependent clipboard context
chrisdeeming Sep 8, 2026
af4dc13
fix(clipboard): reuse attachments already in draft
chrisdeeming Sep 9, 2026
1104cb8
fix(server): renumber the context migration behind upstream's
chrisdeeming Sep 10, 2026
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
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
16 changes: 13 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
TurnId,
} from "@t3tools/contracts";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import {
codexArtifactTemplatePresentationLabel,
type CodexArtifactTemplate,
Expand Down Expand Up @@ -1677,13 +1678,22 @@ function UserMessageContent(props: {
readonly linkHandlers: MarkdownLinkHandlers;
readonly renderImage: MarkdownImageRenderer;
}) {
const segments = parseReviewCommentMessageSegments(props.text);
// Inline context references render as their labels until mobile grows chips for them.
const text = replaceComposerContextReferences(props.text, (occurrence) => occurrence.label);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()) {
return (
<SelectableMarkdownText
markdown={props.text}
markdown={text}
skills={props.skills}
textStyle={props.markdownStyles.nativeTextStyle}
preserveSoftBreaks
Expand All @@ -1699,7 +1709,7 @@ function UserMessageContent(props: {
styles={props.markdownStyles.styles}
theme={props.markdownStyles.theme}
>
{props.text}
{text}
</Markdown>
);
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export const make = Effect.gen(function* () {
questionAttachments: true,
fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
pullRequests: true,
inlineMessageContext: true,
threadSettlement: true,
threadAutoSettlement: true,
threadRestartContinuation: true,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
role: event.payload.role,
text: event.payload.text,
...(attachments !== undefined ? { attachments: [...attachments] } : {}),
...(event.payload.context !== undefined ? { context: event.payload.context } : {}),
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
});
Expand Down Expand Up @@ -1159,6 +1160,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
role: event.payload.role,
text: nextText,
...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
...((event.payload.context ?? previousMessage?.context) !== undefined
? { context: event.payload.context ?? previousMessage?.context }
: {}),
isStreaming: false,
createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
updatedAt: event.payload.updatedAt,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type AgentSessionImportSource,
ChatAttachment,
ComposerContextId,
CheckpointRef,
EventId,
MessageId,
Expand All @@ -10,6 +11,7 @@ import {
ThreadLinkedPullRequest,
TurnId,
ProviderInstanceId,
OrchestrationMessageContext,
} from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand Down Expand Up @@ -41,6 +43,9 @@ const encodeChatAttachments = Schema.encodeEffect(
const encodeThreadLinkedPullRequest = Schema.encodeSync(
Schema.fromJsonString(ThreadLinkedPullRequest),
);
const encodeMessageContext = Schema.encodeEffect(
Schema.fromJsonString(OrchestrationMessageContext),
);

const projectionSnapshotLayer = it.layer(
OrchestrationProjectionSnapshotQueryLive.pipe(
Expand Down Expand Up @@ -725,23 +730,39 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
},
];
const attachmentsJson = yield* encodeChatAttachments(attachments);
const messageContext: OrchestrationMessageContext = {
version: 1,
records: [
{
version: 1,
contextId: ComposerContextId.make("notes-context"),
kind: "file",
label: "notes.txt",
attachmentId: "notes",
name: "notes.txt",
mimeType: "text/plain",
sizeBytes: 8,
},
],
};
const contextJson = yield* encodeMessageContext(messageContext);
yield* sql`
WITH RECURSIVE history(n) AS (
VALUES (1) UNION ALL SELECT n + 1 FROM history WHERE n < 2000
)
INSERT INTO projection_thread_messages (
message_id, thread_id, turn_id, role, text, attachments_json,
message_id, thread_id, turn_id, role, text, attachments_json, context_json,
is_streaming, created_at, updated_at
)
SELECT 'turn-start-history:' || n, ${threadId}, 'old-turn:' || n, 'assistant',
'Unrelated assistant output', 'not-json', 0, ${createdAt}, ${createdAt}
'Unrelated assistant output', 'not-json', 'not-json', 0, ${createdAt}, ${createdAt}
FROM history
`;
yield* sql`
INSERT INTO projection_thread_messages (
message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at
message_id, thread_id, role, text, attachments_json, context_json, is_streaming, created_at, updated_at
) VALUES (${messageId}, ${threadId}, 'user', 'Read these notes',
${attachmentsJson}, 0, ${createdAt}, ${createdAt})
${attachmentsJson}, ${contextJson}, 0, ${createdAt}, ${createdAt})
`;
yield* sql`
INSERT INTO projection_thread_messages (
Expand All @@ -767,6 +788,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
createdAt,
updatedAt: createdAt,
attachments,
context: messageContext,
},
hasOtherUserMessages: false,
}),
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AgentSessionImportSource,
ApprovalRequestId,
ChatAttachment,
OrchestrationMessageContext,
CheckpointRef,
IsoDateTime,
MessageId,
Expand Down Expand Up @@ -111,6 +112,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
Struct.assign({
isStreaming: Schema.Number,
attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
context: Schema.NullOr(Schema.fromJsonString(OrchestrationMessageContext)),
}),
);
const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema.mapFields(
Expand Down Expand Up @@ -677,6 +679,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role,
text,
attachments_json AS "attachments",
context_json AS "context",
is_streaming AS "isStreaming",
created_at AS "createdAt",
updated_at AS "updatedAt"
Expand Down Expand Up @@ -1272,6 +1275,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role,
text,
attachments_json AS "attachments",
context_json AS "context",
is_streaming AS "isStreaming",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -1304,6 +1308,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role,
text,
attachments_json AS "attachments",
context_json AS "context",
is_streaming AS "isStreaming",
created_at AS "createdAt",
updated_at AS "updatedAt"
Expand Down Expand Up @@ -1682,6 +1687,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role,
text,
attachments_json AS "attachments",
context_json AS "context",
is_streaming AS "isStreaming",
created_at AS "createdAt",
updated_at AS "updatedAt"
Expand Down Expand Up @@ -2091,6 +2097,7 @@ pending_approval_requests AS (
role: row.role,
text: row.text,
...(row.attachments !== null ? { attachments: row.attachments } : {}),
...(row.context !== null ? { context: row.context } : {}),
turnId: row.turnId,
streaming: row.isStreaming === 1,
createdAt: row.createdAt,
Expand Down Expand Up @@ -3194,6 +3201,7 @@ pending_approval_requests AS (
createdAt: row.createdAt,
updatedAt: row.updatedAt,
...(row.attachments !== null ? { attachments: row.attachments } : {}),
...(row.context !== null ? { context: row.context } : {}),
},
hasOtherUserMessages: row.hasOtherUserMessages === 1,
}));
Expand Down Expand Up @@ -3453,7 +3461,10 @@ pending_approval_requests AS (
updatedAt: row.updatedAt,
};
if (row.attachments !== null) {
return Object.assign(message, { attachments: row.attachments });
Object.assign(message, { attachments: row.attachments });
}
if (row.context !== null) {
Object.assign(message, { context: row.context });
}
return message;
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createModelSelection } from "@t3tools/shared/model";
import {
ApprovalRequestId,
CommandId,
ComposerContextId,
DEFAULT_PROVIDER_INTERACTION_MODE,
EnvironmentId,
EventId,
Expand Down Expand Up @@ -882,6 +883,51 @@ describe("ProviderCommandReactor", () => {
expect(thread?.session?.runtimeMode).toBe("approval-required");
});

effectIt.effect("projects inline context before sending the provider turn", () =>
Effect.gen(function* () {
const harness = yield* Effect.promise(() => createHarness());

yield* harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-with-context"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-with-context"),
role: "user",
text: "Inspect [build](t3-context://v1/terminal/terminal-1)",
attachments: [],
context: {
version: 1,
records: [
{
version: 1,
kind: "terminal",
contextId: ComposerContextId.make("terminal-1"),
label: "build",
terminalId: "terminal-1",
terminalLabel: "Build",
lineStart: 7,
lineEnd: 7,
text: "compiled successfully",
},
],
},
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: "2026-01-01T00:00:00.000Z",
});

yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1));
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
input: expect.stringContaining("[Terminal: build; ref=terminal-1]"),
});
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
input: expect.stringContaining('<context kind="terminal" id="terminal-1">'),
});
}),
);

effectIt.effect("retains a turn dispatched immediately after start until activation", () =>
Effect.gen(function* () {
const activation = yield* Deferred.make<void>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type TurnId,
} from "@t3tools/contracts";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";
import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -1545,7 +1546,10 @@ const make = Effect.gen(function* () {
}
const sendTurnRequest = yield* buildSendTurnRequestForThread({
threadId: event.payload.threadId,
messageText: message.text,
messageText: projectComposerContextForProvider({
text: message.text,
records: message.context?.records ?? [],
}),
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
...(event.payload.modelSelection !== undefined
? { modelSelection: event.payload.modelSelection }
Expand Down
Loading
Loading