diff --git a/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx b/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx
index f8aba0d423..8d2ee4d0f1 100644
--- a/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx
+++ b/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx
@@ -326,6 +326,88 @@ describe('task modal log window', () => {
expect(list.querySelectorAll('[data-index]').length).toBeLessThan(40);
});
+ it.each(['blurred', 'focused'] as const)(
+ 'retains the same %s offscreen editor when the discussion crosses the window threshold',
+ async (focus) => {
+ const comments = state.comments.slice(0, 120);
+ state.comments = comments.slice(0, 90);
+ const view = render();
+ const scroller = screen.getByTestId('scrollport');
+ const list = scroller.querySelector('ul');
+ if (list === null) throw new Error('Missing comment list');
+ expect(list.querySelectorAll('[data-index]')).toHaveLength(90);
+ await scrollTo(scroller, listStart(list, scroller));
+ const comment = await screen.findByText('Comment 0 opening line.');
+ const row = comment.closest('li');
+ if (row === null) throw new Error('Missing comment row');
+ await userEvent.click(
+ within(row).getByRole('button', { name: 'actions.edit' }),
+ );
+ const field = within(row).getByRole('textbox');
+ const draft = 'Draft retained across windowing';
+ await userEvent.fill(field, draft);
+ expect(field).toHaveFocus();
+ if (focus === 'blurred') {
+ await userEvent.click(
+ screen.getByRole('button', { name: 'detail.showEarlierComments' }),
+ );
+ expect(field).not.toHaveFocus();
+ expect(row.contains(document.activeElement)).toBe(false);
+ }
+ await scrollTo(
+ scroller,
+ listStart(list, scroller) + list.offsetHeight - scroller.clientHeight,
+ );
+ await waitFor(() => {
+ expect(screen.getByText('Comment 89 opening line.')).toBeVisible();
+ expect(row.getBoundingClientRect().bottom).toBeLessThan(
+ scroller.getBoundingClientRect().top,
+ );
+ });
+ // Allow the shared focus bridge's deferred blur release to finish before
+ // crossing. Otherwise its focus pin could mask missing edit registration.
+ await act(async () => {
+ for (let frame = 0; frame < 3; frame++) {
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+ }
+ });
+ if (focus === 'focused') expect(field).toHaveFocus();
+ else expect(row.contains(document.activeElement)).toBe(false);
+ state.comments = comments;
+ view.rerender();
+ await waitFor(() => {
+ expect(list.querySelectorAll('[data-index]').length).toBeGreaterThan(0);
+ expect(list.querySelectorAll('[data-index]').length).toBeLessThan(40);
+ // A nearby unpinned row must leave the range as well: bounded rows
+ // alone would not prove that the edit is outside the visible window.
+ expect(screen.queryByText('Comment 10 opening line.')).toBeNull();
+ expect(row.getBoundingClientRect().bottom).toBeLessThan(
+ scroller.getBoundingClientRect().top,
+ );
+ });
+ await act(async () => {
+ for (let frame = 0; frame < 3; frame++) {
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+ }
+ });
+ expect(list.querySelectorAll('[data-index]').length).toBeLessThan(40);
+ expect(field).toBeInTheDocument();
+ expect(within(row).getByRole('textbox')).toBe(field);
+ expect(field).toHaveValue(draft);
+ if (focus === 'focused') {
+ expect(field).toHaveFocus();
+ expect(document.activeElement).not.toBe(document.body);
+ } else {
+ expect(field).not.toHaveFocus();
+ expect(row.contains(document.activeElement)).toBe(false);
+ }
+ },
+ );
+
it('keeps an edit draft mounted after scrolling into older pages and moving focus', async () => {
render();
const scroller = screen.getByTestId('scrollport');
diff --git a/services/platform/app/features/tasks/hooks/use-task-log-window.tsx b/services/platform/app/features/tasks/hooks/use-task-log-window.tsx
index cd1f3a5995..ec4ef9c9d0 100644
--- a/services/platform/app/features/tasks/hooks/use-task-log-window.tsx
+++ b/services/platform/app/features/tasks/hooks/use-task-log-window.tsx
@@ -20,7 +20,6 @@ const TaskLogViewportContext =
type RowKey = string | number;
type SetRowActive = (rowKey: RowKey, source: string, active: boolean) => void;
-const NOOP_SET_ROW_ACTIVE: SetRowActive = () => undefined;
const TaskLogRowActivityContext = createContext<
((source: string, active: boolean) => void) | null
>(null);
@@ -170,12 +169,12 @@ export function useTaskLogWindow({
return () => observer.disconnect();
}, [getScrollElement, window.scrollElement, listElement]);
- // Small discussions render every row and do not need an activity pin. A
- // no-op keeps editor focus from causing a second list reconciliation in that
- // common case; large windows still pin dirty or portaled rows as they leave
- // the viewport.
+ // Register activity in every mode. A row can become active while the list is
+ // below the window threshold and remain active when new rows turn
+ // virtualization on; keeping the registration avoids dropping its draft on
+ // that crossing render.
return Object.assign(window, {
listRef: attachList,
- setRowActive: window.virtualized ? setRowActive : NOOP_SET_ROW_ACTIVE,
+ setRowActive,
});
}