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
35 changes: 30 additions & 5 deletions src/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -748,10 +754,13 @@ export class TuiController {
setOpencodeBorderFocusStyle(pane === opencodeDialog);
};

let suppressNextP = false; // Flag to suppress 'p' handler after Ctrl-W p
let suppressNextPTimeout: ReturnType<typeof setTimeout> | null = null;
let lastCtrlWKeyHandled = false; // Flag to suppress widget key handling after Ctrl-W command
let lastCtrlWKeyHandledTimeout: ReturnType<typeof setTimeout> | null = null;
let suppressNextP = false; // Flag to suppress 'p' handler after Ctrl-W p
let suppressNextPTimeout: ReturnType<typeof setTimeout> | null = null;
let lastCtrlWKeyHandled = false; // Flag to suppress widget key handling after Ctrl-W command
let lastCtrlWKeyHandledTimeout: ReturnType<typeof setTimeout> | 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;



Expand Down Expand Up @@ -1756,8 +1765,24 @@ 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);
// Reset scroll only when navigating to a different item. Preserve the
// user's scroll position when the same item is re-rendered to avoid
// jarring jumps.
try {
const currentId = node.item.id;
const prevId = lastDetailItemId;
if (prevId === null || prevId !== currentId) {
if (typeof detail.setScroll === 'function') detail.setScroll(0);
}
lastDetailItemId = currentId;
} catch (_) {
// best-effort fallback: try to reset scroll when APIs are available
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;
Expand Down
212 changes: 212 additions & 0 deletions tests/tui/tui-detail-scroll-preserve.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>, label: {} as Record<string, any>, 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();
});
});
Loading