From 6bda6fcf9be24dafba4f2e2f16fb0a6f23a6087c Mon Sep 17 00:00:00 2001 From: Sorra Date: Fri, 27 Mar 2026 13:32:27 -0700 Subject: [PATCH 1/2] TUI: preserve detail pane scroll position across re-renders (WL-0MN8QL3AO0008ZW3) --- src/tui/controller.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 26471a6..5691096 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -685,6 +685,12 @@ export class TuiController { }; let paneFocusIndex = 0; let lastPaneFocusIndex = 0; + // Track the last work item id rendered in the detail pane so we only + // reset scroll when navigating to a different item. Preserving the + // scroll position when re-rendering the same item prevents the + // description panel from snapping back to the top after the user + // scrolls. + let lastDetailRenderedItemId: string | null = null; const getFocusPanes = (): Pane[] => { const panes: Pane[] = [list as unknown as Pane, detail as unknown as Pane]; @@ -1757,7 +1763,19 @@ export class TuiController { const escaped = escapeBlessedTags(text); const brightened = brightenDetailIdLine(escaped); detail.setContent(decorateIdsForClick(brightened)); - detail.setScroll(0); + // Only reset scroll when navigating to a different work item. This + // preserves the user's manual scroll position when the same item is + // re-rendered (caused by unrelated UI updates) and prevents the + // description panel from snapping back to the top after scrolling. + try { + const currentItemId = node.item.id; + if (lastDetailRenderedItemId !== currentItemId) { + if (typeof (detail as any).setScroll === 'function') (detail as any).setScroll(0); + } + lastDetailRenderedItemId = currentItemId; + } catch (_) { + // best-effort: ignore if widget doesn't support scrolling APIs + } // Update metadata pane with current item's metadata if (metadataPaneComponent) { const commentCount = db ? db.getCommentsForWorkItem(node.item.id).length : 0; From de9210ea67263b7096f64db471013633bf16473d Mon Sep 17 00:00:00 2001 From: Sorra Date: Fri, 27 Mar 2026 15:16:40 -0700 Subject: [PATCH 2/2] WL-0MN9F9UOO00258C4: add regression test to preserve detail pane scroll and implement preservation logic --- src/tui/controller.ts | 26 ++- tests/tui/tui-detail-scroll-preserve.test.ts | 212 +++++++++++++++++++ 2 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 tests/tui/tui-detail-scroll-preserve.test.ts diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 26471a6..2b371df 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -748,10 +748,13 @@ export class TuiController { setOpencodeBorderFocusStyle(pane === opencodeDialog); }; - let suppressNextP = false; // Flag to suppress 'p' handler after Ctrl-W p - let suppressNextPTimeout: ReturnType | null = null; - let lastCtrlWKeyHandled = false; // Flag to suppress widget key handling after Ctrl-W command - let lastCtrlWKeyHandledTimeout: ReturnType | null = null; + let suppressNextP = false; // Flag to suppress 'p' handler after Ctrl-W p + let suppressNextPTimeout: ReturnType | null = null; + let lastCtrlWKeyHandled = false; // Flag to suppress widget key handling after Ctrl-W command + let lastCtrlWKeyHandledTimeout: ReturnType | null = null; + // Track the last item shown in the detail pane so we can preserve + // the user's scroll position when the same item is re-rendered. + let lastDetailItemId: string | null = null; @@ -1756,8 +1759,21 @@ export class TuiController { const text = humanFormatWorkItem(node.item, db, 'detail-pane'); const escaped = escapeBlessedTags(text); const brightened = brightenDetailIdLine(escaped); + // If we are switching to a different item, reset scroll to top. + // If the same item is being re-rendered (e.g. list refresh), preserve + // the user's current scroll position to avoid jarring jumps. detail.setContent(decorateIdsForClick(brightened)); - detail.setScroll(0); + try { + const currentId = node.item.id; + const prevId = lastDetailItemId; + if (prevId === null || prevId !== currentId) { + // different item: reset scroll + if (typeof detail.setScroll === 'function') detail.setScroll(0); + } // else: same item — preserve scroll + lastDetailItemId = currentId; + } catch (_) { + try { if (typeof detail.setScroll === 'function') detail.setScroll(0); } catch (_) {} + } // Update metadata pane with current item's metadata if (metadataPaneComponent) { const commentCount = db ? db.getCommentsForWorkItem(node.item.id).length : 0; diff --git a/tests/tui/tui-detail-scroll-preserve.test.ts b/tests/tui/tui-detail-scroll-preserve.test.ts new file mode 100644 index 0000000..eb14892 --- /dev/null +++ b/tests/tui/tui-detail-scroll-preserve.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TuiController } from '../../src/tui/controller.js'; + +// Minimal blessed mocks (copied pattern from existing TUI tests) +const makeBox = () => ({ + hidden: true, + width: 0, + height: 0, + style: { border: {} as Record, label: {} as Record, selected: {} }, + show: vi.fn(function () { (this as any).hidden = false; }), + hide: vi.fn(function () { (this as any).hidden = true; }), + focus: vi.fn(), + setFront: vi.fn(), + setContent: vi.fn(), + getContent: vi.fn(() => ''), + setLabel: vi.fn(), + setItems: vi.fn(), + select: vi.fn(), + getItem: vi.fn(() => undefined), + on: vi.fn(), + key: vi.fn(), + setScroll: vi.fn(), + setScrollPerc: vi.fn(), + getScroll: vi.fn(() => 0), + pushLine: vi.fn(), + clearValue: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn(() => ''), + moveCursor: vi.fn(), +}); + +const makeList = () => { + const list = makeBox() as any; + let selected = 0; + let items: string[] = []; + list.setItems = vi.fn((next: string[]) => { + items = next.slice(); + list.items = items.map(value => ({ getContent: () => value })); + }); + list.select = vi.fn((idx: number) => { selected = idx; }); + Object.defineProperty(list, 'selected', { + get: () => selected, + set: (value: number) => { selected = value; }, + }); + list.getItem = vi.fn((idx: number) => { + const value = items[idx]; + return value ? { getContent: () => value } : undefined; + }); + list.items = [] as any[]; + return list; +}; + +const makeScreen = () => ({ + height: 40, + width: 120, + focused: null as any, + render: vi.fn(), + destroy: vi.fn(), + key: vi.fn(), + on: vi.fn(), +}); + +function makeItem(id: string) { + const now = new Date().toISOString(); + return { + id, + title: `Item ${id}`, + description: Array(50).fill('Line of description').join('\n'), + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: now, + updatedAt: now, + tags: ['test'], + assignee: 'alice', + stage: 'prd_complete', + issueType: 'task', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + needsProducerReview: false, + }; +} + +function buildLayout(screen: any) { + const list = makeList(); + const footer = makeBox(); + const detail = makeBox(); + const copyIdButton = makeBox(); + const metadataBox = makeBox(); + const updateFromItemMock = vi.fn(); + const overlays = { + detailOverlay: makeBox(), + closeOverlay: makeBox(), + updateOverlay: makeBox(), + }; + const dialogs = { + detailModal: makeBox(), + detailClose: makeBox(), + closeDialog: makeBox(), + closeDialogText: makeBox(), + closeDialogOptions: makeList(), + updateDialog: makeBox(), + updateDialogText: makeBox(), + updateDialogOptions: makeList(), + updateDialogStageOptions: makeList(), + updateDialogStatusOptions: makeList(), + updateDialogPriorityOptions: makeList(), + updateDialogComment: makeBox(), + }; + const helpMenu = { isVisible: vi.fn(() => false), show: vi.fn(), hide: vi.fn() }; + const modalDialogs = { selectList: vi.fn(async () => 0), editTextarea: vi.fn(async () => null), confirmTextbox: vi.fn(async () => false), forceCleanup: vi.fn() }; + const opencodeUi = { serverStatusBox: makeBox(), dialog: makeBox(), textarea: makeBox(), suggestionHint: makeBox(), sendButton: makeBox(), cancelButton: makeBox(), ensureResponsePane: vi.fn(() => makeBox()) }; + + return { + screen, + list, + detail, + metadataBox, + updateFromItemMock, + opencodeDialog: opencodeUi.dialog, + opencodeText: opencodeUi.textarea, + layout: { + screen, + listComponent: { getList: () => list, getFooter: () => footer }, + detailComponent: { getDetail: () => detail, getCopyIdButton: () => copyIdButton }, + metadataPaneComponent: { getBox: () => metadataBox, updateFromItem: updateFromItemMock }, + toastComponent: { show: vi.fn() } as any, + overlaysComponent: overlays, + dialogsComponent: dialogs, + helpMenu, + modalDialogs, + opencodeUi, + nextDialog: { overlay: makeBox(), dialog: makeBox(), close: makeBox(), text: makeBox(), options: makeList() }, + }, + }; +} + +function buildCtx(items: any[], comments: any[] = []) { + const createCommentMock = vi.fn(); + const getCommentsMock = vi.fn(() => comments); + return { + ctx: { + program: { opts: () => ({ verbose: false }) }, + utils: { + requireInitialized: vi.fn(), + getDatabase: vi.fn(() => ({ + list: () => items, + getPrefix: () => 'test-prefix', + getCommentsForWorkItem: getCommentsMock, + update: () => ({}), + createComment: createCommentMock, + get: (id: string) => items.find(i => i.id === id) ?? null, + })), + }, + } as any, + createCommentMock, + getCommentsMock, + }; +} + +class FakeOpencodeClient { + getStatus() { return { status: 'stopped', port: 9999 }; } + startServer() { return Promise.resolve(true); } + stopServer() { return undefined; } + sendPrompt() { return Promise.resolve(); } +} + +describe('TUI detail-scroll preservation', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('preserves detail pane scroll when re-rendering the same item', async () => { + const item = makeItem('WL-TEST-1'); + const screen = makeScreen(); + const { layout, detail, list } = buildLayout(screen) as any; + const { ctx } = buildCtx([item]); + + // Instrument the detail box to observe setScroll calls + // `detail` returned from buildLayout is the same as layout.detail + const detailBox = detail as any; + let storedScroll = 0; + detailBox.setScroll = vi.fn((n: number) => { storedScroll = n; }); + detailBox.getScroll = vi.fn(() => storedScroll); + + const controller = new TuiController(ctx, { + createLayout: () => layout as any, + OpencodeClient: FakeOpencodeClient as any, + resolveWorklogDir: () => '/tmp/test-worklog', + createPersistence: () => ({ loadPersistedState: async () => null, savePersistedState: async () => undefined, statePath: '/tmp/tui-state.json' }), + }); + + await controller.start({}); + + // Initial render should reset scroll (called at least once) + expect(detail.setScroll).toHaveBeenCalled(); + + // Clear recorded calls and simulate a user scroll position change + (detail.setScroll as any).mockClear(); + storedScroll = 5; + + // Trigger the registered select handler to force a re-render of the same item + const listBox = list as any; + const selectHandler = (listBox as any).__opencode_select; + if (typeof selectHandler === 'function') selectHandler(null, list.selected); + + // Since the same item is being re-rendered, setScroll should NOT be called again + expect(detail.setScroll).not.toHaveBeenCalled(); + }); +});