Skip to content
Merged
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
16 changes: 15 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## Rebrand Note

This project was forked from T3 Code and fully rebranded to MarCode. When merging upstream changes, always check for and replace any remaining T3 references:
This project was forked from T3 Code and fully rebranded to MarCode. When merging upstream changes, always check for and replace any remaining T3 references, and **reject reintroduction of JS virtualization in `MessagesTimeline.tsx`** (see "Timeline rendering" section under Performance):

- Package imports: `@marcode/contracts`, `@marcode/shared/*` (never `@t3tools`)
- Env vars: `MARCODE_` prefix (never `T3CODE_`)
Expand Down Expand Up @@ -103,6 +103,20 @@ ChatView uses **fine-grained Zustand selectors** (one per thread/project ID) ins
- Its volatile dependencies (`activePendingProgress`, `activePendingUserInput`, `composerTerminalContexts`, `composerJiraTaskContexts`) are accessed via **refs** in callbacks, not in the `useCallback` dependency array.
- Fallback empty arrays use **module-level constants** (`EMPTY_TERMINAL_CONTEXT_DRAFTS`, `EMPTY_JIRA_TASK_DRAFTS`) instead of inline `[]`.

### Timeline rendering: NO JS virtualization (`MessagesTimeline.tsx`)

**CRITICAL — DO NOT REINTRODUCE `@tanstack/react-virtual` or any JS virtualizer for the messages timeline.** This has been deliberately removed twice. Upstream (T3 Code) uses `useVirtualizer` with absolute positioning + `transform: translateY()`, but it causes persistent message overlap and scroll lag in MarCode because:

- Variable-height messages (markdown, code blocks, images, expandable diffs, quoted contexts) make height estimation fundamentally inaccurate
- Async content (Suspense code highlighting, image loads) changes height after initial measurement
- Expandable/collapsible sections (Show full diff, work groups) change height without virtualizer notification
- `ChatView.tsx` directly manipulates `scrollTop` for interaction anchoring and auto-scroll, which desynchronizes from the virtualizer's internal scroll state
- `SelectionReplyToolbar` wraps every assistant message in extra DOM, adding unmeasured height

**Instead, we use CSS `content-visibility: auto`** with `contain-intrinsic-block-size` hints. All rows render in normal document flow — overlap is physically impossible. The browser natively skips painting offscreen content, giving equivalent performance without the positioning bugs. Height estimates in `timelineHeight.ts` feed into `containIntrinsicBlockSize` for accurate scrollbar sizing.

When merging upstream changes that touch `MessagesTimeline.tsx`, **reject any reintroduction of `useVirtualizer`, `measureElement`, `VirtualItem`, absolute-positioned row containers, or `shouldAdjustScrollPositionOnItemSizeChange`**. Keep the `content-visibility: auto` rendering path.

### Timeline row memoization (`MessagesTimeline.tsx`)

Each timeline row renders through a `memo`'d `TimelineRowContent` component (not an inline function). When adding new row types or modifying row rendering, keep the logic inside `TimelineRowContent` to preserve per-row memoization.
Expand Down
9 changes: 2 additions & 7 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1211,11 +1211,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
});

try {
const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
const { measuredRowHeightPx, timelineWidthMeasuredPx } =
await mounted.measureUserRow(targetMessageId);

expect(renderedInVirtualizedRegion).toBe(true);

const estimatedHeightPx = estimateTimelineMessageHeight(
{ role: "user", text: userText, attachments: [] },
{ timelineWidthPx: timelineWidthMeasuredPx },
Expand Down Expand Up @@ -1254,7 +1252,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
{ timelineWidthPx: measurement.timelineWidthMeasuredPx },
);

expect(measurement.renderedInVirtualizedRegion).toBe(true);
expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(
viewport.textTolerancePx,
);
Expand Down Expand Up @@ -1331,11 +1328,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
});

try {
const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
const { measuredRowHeightPx, timelineWidthMeasuredPx } =
await mounted.measureUserRow(targetMessageId);

expect(renderedInVirtualizedRegion).toBe(true);

const estimatedHeightPx = estimateTimelineMessageHeight(
{
role: "user",
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1215,8 +1215,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
() =>
deriveWorkLogEntries(timelineThreadActivities, timelineLatestTurn?.turnId ?? undefined, {
excludeTodoToolCalls: showTodosInComposer,
isSessionRunning: phase === "running",
}),
[timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer],
[timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer, phase],
);
const timelineLatestTurnHasToolActivity = useMemo(
() => hasToolActivityForTurn(timelineThreadActivities, timelineLatestTurn?.turnId),
Expand Down Expand Up @@ -4615,7 +4616,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Messages */}
<div
ref={setMessagesScrollContainerRef}
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4"
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain [overflow-anchor:none] px-3 py-3 sm:px-5 sm:py-4"
onScroll={onMessagesScroll}
onClickCapture={onMessagesClickCapture}
onWheel={onMessagesWheel}
Expand Down Expand Up @@ -4835,6 +4836,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
key={ctx.id}
preview={formatQuotedContextPreview(ctx)}
tooltipText={formatQuotedContextTooltip(ctx)}
isDiff={Boolean(ctx.filePath)}
onRemove={() => removeComposerDraftQuotedContext(threadId, ctx.id)}
/>
))}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import {
} from "react";
import { openInPreferredEditor } from "../editorPreferences";
import { useGitStatus } from "~/lib/gitStatusState";
import { useComposerDraftStore } from "../composerDraftStore";
import type { QuotedContext } from "../lib/quotedContext";
import { DiffSelectionReplyToolbar } from "./DiffSelectionReplyToolbar";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
Expand Down Expand Up @@ -197,6 +200,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
const gitStatusQuery = useGitStatus(activeCwd ?? null);
const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
const addQuotedContext = useComposerDraftStore((store) => store.addQuotedContext);
const onDiffReplyToSelection = useCallback(
(context: QuotedContext) => {
if (!activeThreadId) return;
addQuotedContext(activeThreadId, context);
},
[activeThreadId, addQuotedContext],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const orderedTurnDiffSummaries = useMemo(
Expand Down Expand Up @@ -732,6 +743,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
</div>
)}
</div>
<DiffSelectionReplyToolbar
turnId={selectedTurn?.turnId ?? null}
viewportRef={patchViewportRef}
onReply={onDiffReplyToSelection}
/>
</>
)}
</DiffPanelShell>
Expand Down
Loading
Loading