Skip to content

Commit e8ebe88

Browse files
authored
Fix for Claude Code Scroll to Top Bug (#2956)
There may be more cases here that I don't know about, but this fixes a good chunk of them. This catches the CC "repaint" transaction and forces a scrollToBottom. That should handle context repaints and resize repaints. Also adds a new (hidden) terminal escape sequence debugger, and (in dev mode) adds a last 50 writes cache that can be used to look at and debug output.
1 parent 1ab58ea commit e8ebe88

5 files changed

Lines changed: 122 additions & 13 deletions

File tree

emain/emain-window.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
setWasInFg,
1717
} from "./emain-activity";
1818
import { log } from "./emain-log";
19-
import { getElectronAppBasePath, unamePlatform } from "./emain-platform";
19+
import { getElectronAppBasePath, isDev, unamePlatform } from "./emain-platform";
2020
import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview";
2121
import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util";
2222
import { ElectronWshClient } from "./emain-wsh";
2323
import { updater } from "./updater";
2424

25+
const DevInitTimeoutMs = 5000;
26+
2527
export type WindowOpts = {
2628
unamePlatform: NodeJS.Platform;
2729
isPrimaryStartupWindow?: boolean;
@@ -389,7 +391,7 @@ export class WaveBrowserWindow extends BaseWindow {
389391

390392
private async initializeTab(tabView: WaveTabView, primaryStartupTab: boolean) {
391393
const clientId = await getClientId();
392-
await tabView.initPromise;
394+
await this.awaitWithDevTimeout(tabView.initPromise, "initPromise", tabView.waveTabId);
393395
this.contentView.addChildView(tabView);
394396
const initOpts: WaveInitOpts = {
395397
tabId: tabView.waveTabId,
@@ -410,10 +412,36 @@ export class WaveBrowserWindow extends BaseWindow {
410412
primaryStartupTab ? "(primary startup)" : ""
411413
);
412414
tabView.webContents.send("wave-init", initOpts);
413-
await tabView.waveReadyPromise;
415+
await this.awaitWithDevTimeout(tabView.waveReadyPromise, "waveReadyPromise", tabView.waveTabId);
414416
console.log("wave-ready init time", Date.now() - startTime + "ms");
415417
}
416418

419+
private async awaitWithDevTimeout<T>(promise: Promise<T>, name: string, tabId: string): Promise<T> {
420+
if (!isDev) {
421+
return promise;
422+
}
423+
let timeoutHandle: ReturnType<typeof setTimeout> = null;
424+
const timeoutPromise = new Promise<never>((_, reject) => {
425+
timeoutHandle = setTimeout(() => {
426+
console.log(
427+
`[dev] ${name} timed out after ${DevInitTimeoutMs}ms for tab ${tabId}, showing window for devtools`
428+
);
429+
if (!this.isDestroyed() && !this.isVisible()) {
430+
this.show();
431+
}
432+
if (this.activeTabView?.webContents && !this.activeTabView.webContents.isDevToolsOpened()) {
433+
this.activeTabView.webContents.openDevTools();
434+
}
435+
reject(new Error(`[dev] ${name} timed out after ${DevInitTimeoutMs}ms`));
436+
}, DevInitTimeoutMs);
437+
});
438+
try {
439+
return await Promise.race([promise, timeoutPromise]);
440+
} finally {
441+
clearTimeout(timeoutHandle);
442+
}
443+
}
444+
417445
private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean, primaryStartupTab = false) {
418446
if (this.activeTabView == tabView) {
419447
return;

frontend/app/store/keymodel.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,6 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
419419
}
420420
const nativeEvent = (waveEvent as any).nativeEvent;
421421
if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) {
422-
console.log("lastHandledEvent return false");
423422
return false;
424423
}
425424
lastHandledEvent = nativeEvent;

frontend/app/view/term/termwrap.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getOverrideConfigAtom,
1212
getSettingsKeyAtom,
1313
globalStore,
14+
isDev,
1415
openLink,
1516
setTabIndicator,
1617
WOS,
@@ -43,6 +44,7 @@ const TermCacheFileName = "cache:term:full";
4344
const MinDataProcessedForCache = 100 * 1024;
4445
export const SupportsImageInput = true;
4546
const IMEDedupWindowMs = 20;
47+
const MaxRepaintTransactionMs = 2000;
4648

4749
// detect webgl support
4850
function detectWebGLSupport(): boolean {
@@ -104,9 +106,23 @@ export class TermWrap {
104106
// xterm.js paste() method triggers onData event, which can cause duplicate sends
105107
lastPasteData: string = "";
106108
lastPasteTime: number = 0;
109+
110+
// for scrollToBottom support during a resize
107111
lastAtBottomTime: number = Date.now();
108112
lastScrollAtBottom: boolean = true;
109113
cachedAtBottomForResize: boolean | null = null;
114+
viewportScrollTop: number = 0;
115+
116+
// dev only (for debugging)
117+
recentWrites: { idx: number; data: string; ts: number }[] = [];
118+
recentWritesCounter: number = 0;
119+
120+
// for repaint transaction scrolling behavior
121+
lastClearScrollbackTs: number = 0;
122+
lastMode2026SetTs: number = 0;
123+
lastMode2026ResetTs: number = 0;
124+
inSyncTransaction: boolean = false;
125+
inRepaintTransaction: boolean = false;
110126

111127
constructor(
112128
tabId: string,
@@ -187,6 +203,44 @@ export class TermWrap {
187203
this.terminal.parser.registerOscHandler(16162, (data: string) => {
188204
return handleOsc16162Command(data, this.blockId, this.loaded, this);
189205
});
206+
this.toDispose.push(
207+
this.terminal.parser.registerCsiHandler({ final: "J" }, (params) => {
208+
if (params[0] === 3) {
209+
this.lastClearScrollbackTs = Date.now();
210+
if (this.inSyncTransaction) {
211+
console.log("[termwrap] repaint transaction starting");
212+
this.inRepaintTransaction = true;
213+
}
214+
}
215+
return false;
216+
})
217+
);
218+
this.toDispose.push(
219+
this.terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
220+
if (params[0] === 2026) {
221+
this.lastMode2026SetTs = Date.now();
222+
this.inSyncTransaction = true;
223+
}
224+
return false;
225+
})
226+
);
227+
this.toDispose.push(
228+
this.terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
229+
if (params[0] === 2026) {
230+
this.lastMode2026ResetTs = Date.now();
231+
this.inSyncTransaction = false;
232+
const wasRepaint = this.inRepaintTransaction;
233+
this.inRepaintTransaction = false;
234+
if (wasRepaint && Date.now() - this.lastClearScrollbackTs <= MaxRepaintTransactionMs) {
235+
setTimeout(() => {
236+
console.log("[termwrap] repaint transaction complete, scrolling to bottom");
237+
this.terminal.scrollToBottom();
238+
}, 20);
239+
}
240+
}
241+
return false;
242+
})
243+
);
190244
this.toDispose.push(
191245
this.terminal.onBell(() => {
192246
if (!this.loaded) {
@@ -231,9 +285,8 @@ export class TermWrap {
231285
});
232286
const viewportElem = this.connectElem.querySelector(".xterm-viewport") as HTMLElement;
233287
if (viewportElem) {
234-
const scrollHandler = () => {
235-
const atBottom = viewportElem.scrollTop + viewportElem.clientHeight >= viewportElem.scrollHeight - 20;
236-
this.setAtBottom(atBottom);
288+
const scrollHandler = (e: any) => {
289+
this.handleViewportScroll(viewportElem);
237290
};
238291
viewportElem.addEventListener("scroll", scrollHandler);
239292
this.toDispose.push({
@@ -416,6 +469,13 @@ export class TermWrap {
416469
}
417470

418471
doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise<void> {
472+
if (isDev() && this.loaded) {
473+
const dataStr = data instanceof Uint8Array ? new TextDecoder().decode(data) : data;
474+
this.recentWrites.push({ idx: this.recentWritesCounter++, ts: Date.now(), data: dataStr });
475+
if (this.recentWrites.length > 50) {
476+
this.recentWrites.shift();
477+
}
478+
}
419479
let resolve: () => void = null;
420480
let prtn = new Promise<void>((presolve, _) => {
421481
resolve = presolve;
@@ -498,6 +558,19 @@ export class TermWrap {
498558
return Date.now() - this.lastAtBottomTime <= 1000;
499559
}
500560

561+
handleViewportScroll(viewportElem: HTMLElement) {
562+
const { scrollTop, scrollHeight, clientHeight } = viewportElem;
563+
const atBottom = scrollTop + clientHeight >= scrollHeight - clientHeight * 0.5;
564+
this.setAtBottom(atBottom);
565+
const delta = this.viewportScrollTop - scrollTop;
566+
if (isDev() && delta >= 500) {
567+
console.log(
568+
`[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${atBottom}`
569+
);
570+
}
571+
this.viewportScrollTop = scrollTop;
572+
}
573+
501574
handleResize() {
502575
const oldRows = this.terminal.rows;
503576
const oldCols = this.terminal.cols;
@@ -508,6 +581,14 @@ export class TermWrap {
508581
this.fitAddon.fit();
509582
if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) {
510583
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
584+
console.log(
585+
"[termwrap] resize",
586+
`${oldRows}x${oldCols}`,
587+
"->",
588+
`${this.terminal.rows}x${this.terminal.cols}`,
589+
"atBottom:",
590+
atBottom
591+
);
511592
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize });
512593
}
513594
dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized);
@@ -517,6 +598,7 @@ export class TermWrap {
517598
}
518599
if (atBottom) {
519600
setTimeout(() => {
601+
console.log("[termwrap] resize scroll-to-bottom");
520602
this.cachedAtBottomForResize = null;
521603
this.terminal.scrollToBottom();
522604
this.setAtBottom(true);

pkg/blockcontroller/blockcontroller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const (
4242
)
4343

4444
const (
45-
DefaultTermMaxFileSize = 256 * 1024
45+
DefaultTermMaxFileSize = 2 * 1024 * 1024
4646
DefaultHtmlMaxFileSize = 256 * 1024
4747
MaxInitScriptSize = 50 * 1024
4848
)

pkg/telemetry/telemetrydata/telemetrydata.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,11 @@ type TEventProps struct {
126126
WshCmd string `json:"wsh:cmd,omitempty"`
127127
WshHadError bool `json:"wsh:haderror,omitempty"`
128128

129-
ConnType string `json:"conn:conntype,omitempty"`
130-
ConnWshErrorCode string `json:"conn:wsherrorcode,omitempty"`
131-
ConnErrorCode string `json:"conn:errorcode,omitempty"`
132-
ConnSubErrorCode string `json:"conn:suberrorcode,omitempty"`
133-
ConnContextError bool `json:"conn:contexterror,omitempty"`
129+
ConnType string `json:"conn:conntype,omitempty"`
130+
ConnWshErrorCode string `json:"conn:wsherrorcode,omitempty"`
131+
ConnErrorCode string `json:"conn:errorcode,omitempty"`
132+
ConnSubErrorCode string `json:"conn:suberrorcode,omitempty"`
133+
ConnContextError bool `json:"conn:contexterror,omitempty"`
134134

135135
OnboardingFeature string `json:"onboarding:feature,omitempty" tstype:"\"waveai\" | \"durable\" | \"magnify\" | \"wsh\""`
136136
OnboardingVersion string `json:"onboarding:version,omitempty"`

0 commit comments

Comments
 (0)