Skip to content

Commit e6ee1c8

Browse files
committed
fix(chat): improve auto-scroll behavior and test listing snapshot suppor
- Refactor auto-scroll logic to check shouldAutoScrollRef before scrolling - Add enableAutoScroll parameter to scrollMessagesToBottom for explicit control - Implement getListingSnapshot and getThread RPC methods in test harnesses - Add toThreadSummary helper for thread-to-summary conversion - Improve wsRpcHarness with proper PubSub cleanup and ping/pong handling - Clarify GitLab CLI token scopes in Installation guide
1 parent e84571a commit e6ee1c8

5 files changed

Lines changed: 142 additions & 40 deletions

File tree

apps/landing/src/components/Installation.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ const STEPS: Step[] = [
5454
<span className="text-foreground font-medium">
5555
System Settings &rarr; Privacy &amp; Security
5656
</span>{" "}
57-
&rarr; scroll down &rarr;{" "}
58-
<span className="text-foreground font-medium">Open Anyway</span>. The code is{" "}
57+
&rarr; scroll down &rarr; <span className="text-foreground font-medium">Open Anyway</span>.
58+
The code is{" "}
5959
<ExternalLink href="https://github.com/tyulyukov/marcode" color="fresh-syntax">
6060
fully open source
6161
</ExternalLink>{" "}
@@ -74,10 +74,7 @@ const STEPS: Step[] = [
7474
<ExternalLink href="https://cli.github.com" color="curious-sky">
7575
GitHub CLI
7676
</ExternalLink>
77-
<ExternalLink
78-
href="https://gitlab.com/gitlab-org/cli#installation"
79-
color="curious-sky"
80-
>
77+
<ExternalLink href="https://gitlab.com/gitlab-org/cli#installation" color="curious-sky">
8178
GitLab CLI
8279
</ExternalLink>
8380
</span>
@@ -88,7 +85,16 @@ const STEPS: Step[] = [
8885
color="muted"
8986
>
9087
Personal Access Token
91-
</ExternalLink>
88+
</ExternalLink>{" "}
89+
with at least{" "}
90+
<code className="rounded bg-white/5 px-1 py-0.5 font-mono text-[11px] text-muted-foreground">
91+
api
92+
</code>{" "}
93+
and{" "}
94+
<code className="rounded bg-white/5 px-1 py-0.5 font-mono text-[11px] text-muted-foreground">
95+
write_repository
96+
</code>{" "}
97+
scopes.
9298
</span>
9399
</>
94100
),
@@ -185,18 +191,12 @@ export function Installation() {
185191
className="mb-14 text-center text-3xl font-medium tracking-tight sm:text-4xl"
186192
style={{ letterSpacing: "-0.02em" }}
187193
>
188-
Up and running{" "}
189-
<span className="text-muted-foreground">in minutes</span>
194+
Up and running <span className="text-muted-foreground">in minutes</span>
190195
</h2>
191196

192197
<div>
193198
{STEPS.map((step, i) => (
194-
<TimelineStep
195-
key={step.title}
196-
step={step}
197-
index={i}
198-
isLast={i === STEPS.length - 1}
199-
/>
199+
<TimelineStep key={step.title} step={step} index={i} isLast={i === STEPS.length - 1} />
200200
))}
201201
</div>
202202
</section>

apps/web/src/components/ChatView.browser.tsx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,14 @@ function createSnapshotForTargetUser(options: {
287287
branch: "main",
288288
worktreePath: null,
289289
additionalDirectories: [],
290-
latestTurn: null,
290+
latestTurn: {
291+
turnId: "turn-1" as TurnId,
292+
state: "completed" as const,
293+
requestedAt: NOW_ISO,
294+
startedAt: NOW_ISO,
295+
completedAt: NOW_ISO,
296+
assistantMessageId: null,
297+
},
291298
createdAt: NOW_ISO,
292299
updatedAt: NOW_ISO,
293300
archivedAt: null,
@@ -661,12 +668,48 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
661668
};
662669
}
663670

671+
function toThreadSummary(thread: OrchestrationReadModel["threads"][number]) {
672+
return {
673+
id: thread.id,
674+
projectId: thread.projectId,
675+
title: thread.title,
676+
modelSelection: thread.modelSelection,
677+
runtimeMode: thread.runtimeMode,
678+
interactionMode: thread.interactionMode,
679+
branch: thread.branch,
680+
worktreePath: thread.worktreePath,
681+
additionalDirectories: thread.additionalDirectories,
682+
latestTurn: thread.latestTurn,
683+
session: thread.session,
684+
createdAt: thread.createdAt,
685+
updatedAt: thread.updatedAt,
686+
archivedAt: thread.archivedAt,
687+
deletedAt: thread.deletedAt,
688+
latestUserMessageAt: null,
689+
hasPendingApprovals: false,
690+
hasPendingUserInput: false,
691+
hasActionableProposedPlan: false,
692+
};
693+
}
694+
664695
function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
665696
const customResult = customWsRpcResolver?.(body);
666697
if (customResult !== undefined) {
667698
return customResult;
668699
}
669700
const tag = body._tag;
701+
if (tag === ORCHESTRATION_WS_METHODS.getListingSnapshot) {
702+
return {
703+
snapshotSequence: fixture.snapshot.snapshotSequence,
704+
projects: fixture.snapshot.projects,
705+
threads: fixture.snapshot.threads.map(toThreadSummary),
706+
updatedAt: fixture.snapshot.updatedAt,
707+
};
708+
}
709+
if (tag === ORCHESTRATION_WS_METHODS.getThread) {
710+
const threadId = body.threadId as string;
711+
return fixture.snapshot.threads.find((t) => t.id === threadId) ?? null;
712+
}
670713
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
671714
return fixture.snapshot;
672715
}
@@ -1081,6 +1124,16 @@ async function mountChatView(options: {
10811124
customWsRpcResolver = null;
10821125
await screen.unmount();
10831126
host.remove();
1127+
wsRequests.length = 0;
1128+
await __resetNativeApiForTests();
1129+
useStore.setState({ projects: [], threads: [], bootstrapComplete: false });
1130+
useComposerDraftStore.setState({
1131+
draftsByThreadId: {},
1132+
draftThreadsByThreadId: {},
1133+
projectDraftThreadIdByProjectId: {},
1134+
stickyModelSelectionByProvider: {},
1135+
stickyActiveProvider: null,
1136+
});
10841137
};
10851138

10861139
return {

apps/web/src/components/ChatView.tsx

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
844844
} | null>(null);
845845
const pendingInteractionAnchorFrameRef = useRef<number | null>(null);
846846
const lastContentHeightRef = useRef(0);
847-
const lastSmoothScrollTimestampRef = useRef(0);
848847
const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
849848
const composerFormRef = useRef<HTMLFormElement>(null);
850849
const composerFormHeightRef = useRef(0);
@@ -2300,13 +2299,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
23002299

23012300
// Auto-scroll on new messages
23022301
const messageCount = timelineMessages.length;
2303-
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
2304-
const scrollContainer = messagesScrollRef.current;
2305-
if (!scrollContainer) return;
2306-
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
2307-
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
2308-
shouldAutoScrollRef.current = true;
2309-
}, []);
2302+
const scrollMessagesToBottom = useCallback(
2303+
(behavior: ScrollBehavior = "auto", { enableAutoScroll = false } = {}) => {
2304+
const scrollContainer = messagesScrollRef.current;
2305+
if (!scrollContainer) return;
2306+
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
2307+
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
2308+
if (enableAutoScroll) {
2309+
shouldAutoScrollRef.current = true;
2310+
}
2311+
},
2312+
[],
2313+
);
23102314
const cancelPendingStickToBottom = useCallback(() => {
23112315
const pendingFrame = pendingAutoScrollFrameRef.current;
23122316
if (pendingFrame === null) return;
@@ -2323,6 +2327,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
23232327
if (pendingAutoScrollFrameRef.current !== null) return;
23242328
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
23252329
pendingAutoScrollFrameRef.current = null;
2330+
if (!shouldAutoScrollRef.current) return;
23262331
if (pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current) return;
23272332
scrollMessagesToBottom();
23282333
});
@@ -2409,19 +2414,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
24092414
pendingUserScrollUpIntentRef.current = false;
24102415
} else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
24112416
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
2412-
if (scrolledUp && !isNearBottom) {
2417+
if (scrolledUp) {
24132418
shouldAutoScrollRef.current = false;
24142419
pendingUserScrollUpIntentRef.current = false;
24152420
} else if (!scrolledUp) {
24162421
pendingUserScrollUpIntentRef.current = false;
24172422
}
24182423
} else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) {
24192424
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
2420-
if (scrolledUp && !isNearBottom) {
2425+
if (scrolledUp) {
24212426
shouldAutoScrollRef.current = false;
24222427
}
24232428
} else if (shouldAutoScrollRef.current && !isNearBottom) {
2424-
// Catch-all for keyboard/assistive scroll interactions.
24252429
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
24262430
if (scrolledUp) {
24272431
shouldAutoScrollRef.current = false;
@@ -2434,6 +2438,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
24342438
const onMessagesWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
24352439
if (event.deltaY < 0) {
24362440
pendingUserScrollUpIntentRef.current = true;
2441+
const scrollContainer = messagesScrollRef.current;
2442+
if (scrollContainer) {
2443+
scrollContainer.scrollTo({ top: scrollContainer.scrollTop });
2444+
}
24372445
}
24382446
}, []);
24392447
const onMessagesPointerDown = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
@@ -2586,14 +2594,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25862594
if (pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current) return;
25872595
cancelPendingStickToBottom();
25882596
pendingAutoScrollFrameRef.current = null;
2589-
const heightDelta = nextHeight - previousHeight;
2590-
const now = performance.now();
2591-
const recentlySmoothed = now - lastSmoothScrollTimestampRef.current < 400;
2592-
const useSmoothScroll = heightDelta > 100 && !recentlySmoothed;
2593-
if (useSmoothScroll) {
2594-
lastSmoothScrollTimestampRef.current = now;
2595-
}
2596-
scrollMessagesToBottom(useSmoothScroll ? "smooth" : "auto");
2597+
scrollMessagesToBottom();
25972598
});
25982599

25992600
observer.observe(contentElement);
@@ -4750,7 +4751,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
47504751
<div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5">
47514752
<button
47524753
type="button"
4753-
onClick={() => scrollMessagesToBottom("smooth")}
4754+
onClick={() => scrollMessagesToBottom("smooth", { enableAutoScroll: true })}
47544755
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer"
47554756
>
47564757
<ChevronDownIcon className="size-3.5" />

apps/web/src/components/KeybindingsToast.browser.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,44 @@ function buildFixture(): TestFixture {
165165
};
166166
}
167167

168-
function resolveWsRpc(tag: string): unknown {
168+
function toThreadSummary(thread: OrchestrationReadModel["threads"][number]) {
169+
return {
170+
id: thread.id,
171+
projectId: thread.projectId,
172+
title: thread.title,
173+
modelSelection: thread.modelSelection,
174+
runtimeMode: thread.runtimeMode,
175+
interactionMode: thread.interactionMode,
176+
branch: thread.branch,
177+
worktreePath: thread.worktreePath,
178+
additionalDirectories: thread.additionalDirectories,
179+
latestTurn: thread.latestTurn,
180+
session: thread.session,
181+
createdAt: thread.createdAt,
182+
updatedAt: thread.updatedAt,
183+
archivedAt: thread.archivedAt,
184+
deletedAt: thread.deletedAt,
185+
latestUserMessageAt: null,
186+
hasPendingApprovals: false,
187+
hasPendingUserInput: false,
188+
hasActionableProposedPlan: false,
189+
};
190+
}
191+
192+
function resolveWsRpc(request: { _tag: string; [key: string]: unknown }): unknown {
193+
const tag = request._tag;
194+
if (tag === ORCHESTRATION_WS_METHODS.getListingSnapshot) {
195+
return {
196+
snapshotSequence: fixture.snapshot.snapshotSequence,
197+
projects: fixture.snapshot.projects,
198+
threads: fixture.snapshot.threads.map(toThreadSummary),
199+
updatedAt: fixture.snapshot.updatedAt,
200+
};
201+
}
202+
if (tag === ORCHESTRATION_WS_METHODS.getThread) {
203+
const threadId = request.threadId as string;
204+
return fixture.snapshot.threads.find((t) => t.id === threadId) ?? null;
205+
}
169206
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
170207
return fixture.snapshot;
171208
}
@@ -325,7 +362,7 @@ describe("Keybindings update toast", () => {
325362

326363
beforeEach(async () => {
327364
await rpcHarness.reset({
328-
resolveUnary: (request) => resolveWsRpc(request._tag),
365+
resolveUnary: resolveWsRpc,
329366
getInitialStreamValues: (request) => {
330367
if (request._tag === WS_METHODS.subscribeServerLifecycle) {
331368
return [

apps/web/test/wsRpcHarness.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Effect, Exit, PubSub, Scope, Stream } from "effect";
22
import { WS_METHODS, WsRpcGroup } from "@marcode/contracts";
33
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
4+
import { constPong } from "effect/unstable/rpc/RpcMessage";
45

56
type RpcServerInstance = RpcServer.RpcServer<any>;
67

@@ -77,9 +78,11 @@ export class BrowserWsRpcHarness {
7778
if (this.scope) {
7879
void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
7980
}
80-
if (this.streamPubSubs.size === 0) {
81-
this.initializeStreamPubSubs();
81+
for (const pubsub of this.streamPubSubs.values()) {
82+
Effect.runSync(PubSub.shutdown(pubsub));
8283
}
84+
this.streamPubSubs.clear();
85+
this.initializeStreamPubSubs();
8386
this.client = client;
8487
this.scope = Effect.runSync(Scope.make());
8588
this.serverReady = Effect.runPromise(
@@ -115,6 +118,14 @@ export class BrowserWsRpcHarness {
115118
}
116119
const messages = this.parser.decode(rawData);
117120
for (const message of messages) {
121+
const msg = message as { _tag?: string };
122+
if (msg._tag === "Ping") {
123+
const pong = this.parser.encode(constPong);
124+
if (typeof pong === "string") {
125+
this.client?.send(pong);
126+
}
127+
continue;
128+
}
118129
await Effect.runPromise(server.write(0, message as never));
119130
}
120131
}

0 commit comments

Comments
 (0)