FN-6522: load earlier task chat messages
Preserve task-detail chat reading position while paging older agent log entries. - Add top-of-transcript pagination with an explicit Load previous messages control and loading state. - Preserve scroll anchoring when earlier log entries are prepended while keeping live bottom-follow behavior for new output. - Cover scroll-to-top loading and manual loading with regression tests and document the behavior. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskChatTab.css | 28 ++++ packages/dashboard/app/components/TaskChatTab.tsx | 82 ++++++++++- .../app/components/__tests__/TaskChatTab.test.tsx | 163 ++++++++++++++++++++- 4 files changed, 269 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6522 Fusion-Task-Lineage: b93464cf-0b9a-4a73-b4f5-4f3a5e34cc89
This commit is contained in:
@@ -760,7 +760,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
||||
|
||||
### Logs → Agent Log view
|
||||
|
||||
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the composer sends guidance through the same steering path used by comments, including active assigned `in-progress`/`in-review` sessions and messages queued when no session is currently live. On a `done` task, sending a Chat message starts a refinement task using the typed text as feedback and shows a success toast with the new task ID; the current task detail modal remains on the completed task. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
|
||||
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the composer sends guidance through the same steering path used by comments, including active assigned `in-progress`/`in-review` sessions and messages queued when no session is currently live. On a `done` task, sending a Chat message starts a refinement task using the typed text as feedback and shows a success toast with the new task ID; the current task detail modal remains on the completed task. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
|
||||
|
||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||
|
||||
|
||||
@@ -57,6 +57,25 @@ FN-6425 requires the chat expand control to stay inside the chat view as an icon
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-chat-load-previous-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-block-size: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.task-chat-load-previous,
|
||||
.task-chat-load-previous-status {
|
||||
min-block-size: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.task-chat-load-previous-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-chat-jump-to-bottom {
|
||||
position: sticky;
|
||||
right: var(--space-md);
|
||||
@@ -377,6 +396,15 @@ FN-6507 requires the Task Detail chat send glyph to scale with the larger square
|
||||
min-block-size: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.task-chat-load-previous-row {
|
||||
min-block-size: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.task-chat-load-previous,
|
||||
.task-chat-load-previous-status {
|
||||
min-block-size: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.task-chat-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const STEERING_BLOCKED_STATUSES = new Set([
|
||||
]);
|
||||
const REVIEW_STEERABLE_STATUSES = new Set(["reviewing", "merging", "merging-fix", "fixing"]);
|
||||
const BOTTOM_FOLLOW_THRESHOLD = 48;
|
||||
const TOP_LOAD_THRESHOLD = 48;
|
||||
|
||||
function isTranscriptNearBottom(container: HTMLElement): boolean {
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD;
|
||||
@@ -410,7 +411,7 @@ function TaskChatUserMessage({ message }: { message: UserChatMessage }) {
|
||||
}
|
||||
|
||||
export function TaskChatTab({ task, projectId, active, addToast, sessionLive, onTaskUpdated, expanded = false, onToggleExpanded }: TaskChatTabProps) {
|
||||
const { entries, loading } = useAgentLogs(task.id, active, projectId);
|
||||
const { entries, loading, loadMore, hasMore, loadingMore } = useAgentLogs(task.id, active, projectId);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
|
||||
@@ -418,6 +419,11 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const previousEntryCountRef = useRef(0);
|
||||
const previousScrollHeightRef = useRef(0);
|
||||
const previousFirstEntryKeyRef = useRef<string | null>(null);
|
||||
const previousAgentEntryCountRef = useRef(0);
|
||||
const pendingPrependScrollHeightRef = useRef<number | null>(null);
|
||||
const pendingPrependScrollTopRef = useRef(0);
|
||||
const loadMoreInFlightRef = useRef(false);
|
||||
const previousActiveRef = useRef(false);
|
||||
const anchorFrameRef = useRef<number | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -428,6 +434,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
);
|
||||
const transcriptItems = useMemo(() => buildTranscriptItems(entries, userMessages), [entries, userMessages]);
|
||||
const transcriptItemCount = entries.length + userMessages.length;
|
||||
const firstEntryKey = entries[0] ? getEntryKey(entries[0], 0) : null;
|
||||
const activeSession = isActiveAgentSession(task, { sessionLive });
|
||||
const isDoneTask = task.column === "done";
|
||||
const sessionHint = isDoneTask
|
||||
@@ -524,18 +531,43 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
if (!active) {
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
previousFirstEntryKeyRef.current = firstEntryKey;
|
||||
previousAgentEntryCountRef.current = entries.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (transcriptItemCount === 0) {
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
previousFirstEntryKeyRef.current = firstEntryKey;
|
||||
previousAgentEntryCountRef.current = entries.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousCount = previousEntryCountRef.current;
|
||||
const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight;
|
||||
if (transcriptItemCount > previousCount) {
|
||||
const previousFirstEntryKey = previousFirstEntryKeyRef.current;
|
||||
const previousAgentEntryCount = previousAgentEntryCountRef.current;
|
||||
const prependedOlderEntries = Boolean(
|
||||
pendingPrependScrollHeightRef.current !== null
|
||||
&& transcriptItemCount > previousCount
|
||||
&& entries.length > previousAgentEntryCount
|
||||
&& firstEntryKey
|
||||
&& (!previousFirstEntryKey || firstEntryKey !== previousFirstEntryKey),
|
||||
);
|
||||
|
||||
if (prependedOlderEntries) {
|
||||
/*
|
||||
* FNXC:TaskDetailChat 2026-06-16-23:03:
|
||||
* Task-detail chat must load older paginated agent history at the top without disturbing the reader's viewport. Treat a changed first agent-log key as a prepend so bottom-follow remains reserved for live appends at the transcript tail.
|
||||
*/
|
||||
const previousTop = pendingPrependScrollTopRef.current;
|
||||
const previousHeight = pendingPrependScrollHeightRef.current ?? previousScrollHeight;
|
||||
const heightDelta = container.scrollHeight - previousHeight;
|
||||
container.scrollTop = previousTop + Math.max(0, heightDelta);
|
||||
pendingPrependScrollHeightRef.current = null;
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
} else if (transcriptItemCount > previousCount) {
|
||||
const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD;
|
||||
if (shouldFollow) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
@@ -543,20 +575,42 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
} else {
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
}
|
||||
if (pendingPrependScrollHeightRef.current !== null) {
|
||||
pendingPrependScrollHeightRef.current = container.scrollHeight;
|
||||
pendingPrependScrollTopRef.current = container.scrollTop;
|
||||
}
|
||||
} else {
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
}
|
||||
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
}, [active, transcriptItemCount]);
|
||||
previousFirstEntryKeyRef.current = firstEntryKey;
|
||||
previousAgentEntryCountRef.current = entries.length;
|
||||
}, [active, entries.length, firstEntryKey, transcriptItemCount]);
|
||||
|
||||
const loadPreviousMessages = useCallback(async () => {
|
||||
const container = transcriptRef.current;
|
||||
if (!container || !active || !hasMore || loadingMore || loadMoreInFlightRef.current) return;
|
||||
pendingPrependScrollHeightRef.current = container.scrollHeight;
|
||||
pendingPrependScrollTopRef.current = container.scrollTop;
|
||||
loadMoreInFlightRef.current = true;
|
||||
try {
|
||||
await loadMore();
|
||||
} finally {
|
||||
loadMoreInFlightRef.current = false;
|
||||
}
|
||||
}, [active, hasMore, loadMore, loadingMore]);
|
||||
|
||||
const handleTranscriptScroll = useCallback(() => {
|
||||
const container = transcriptRef.current;
|
||||
if (!container) return;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
}, []);
|
||||
if (container.scrollTop <= TOP_LOAD_THRESHOLD) {
|
||||
void loadPreviousMessages();
|
||||
}
|
||||
}, [loadPreviousMessages]);
|
||||
|
||||
const scrollTranscriptToBottom = useCallback(() => {
|
||||
const container = transcriptRef.current;
|
||||
@@ -641,6 +695,26 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
aria-live="polite"
|
||||
data-testid="task-chat-transcript"
|
||||
>
|
||||
{hasMore || loadingMore ? (
|
||||
<div className="task-chat-load-previous-row">
|
||||
{loadingMore ? (
|
||||
<div className="task-chat-load-previous-status" role="status" data-testid="task-chat-load-previous-loading">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
<span>Loading earlier messages…</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm task-chat-load-previous"
|
||||
onClick={() => { void loadPreviousMessages(); }}
|
||||
aria-label="Load previous messages"
|
||||
data-testid="task-chat-load-previous"
|
||||
>
|
||||
Load previous messages
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{loading && transcriptItemCount === 0 ? (
|
||||
<div className="task-chat-empty" role="status">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
|
||||
@@ -96,7 +96,11 @@ function getCssAfter(css: string, marker: string): string {
|
||||
return markerIndex >= 0 ? css.slice(markerIndex) : "";
|
||||
}
|
||||
|
||||
function mockLogs(entries: AgentLogEntry[] = [], loading = false) {
|
||||
function mockLogs(
|
||||
entries: AgentLogEntry[] = [],
|
||||
loading = false,
|
||||
overrides: Partial<ReturnType<typeof useAgentLogs>> = {},
|
||||
) {
|
||||
mockedUseAgentLogs.mockReturnValue({
|
||||
entries,
|
||||
loading,
|
||||
@@ -105,6 +109,7 @@ function mockLogs(entries: AgentLogEntry[] = [], loading = false) {
|
||||
hasMore: false,
|
||||
total: entries.length,
|
||||
loadingMore: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -785,6 +790,145 @@ describe("TaskChatTab", () => {
|
||||
expect(metrics.scrollTop).toBe(120);
|
||||
});
|
||||
|
||||
it("loads previous messages on scroll-to-top and via the expanded-mode button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 1000 });
|
||||
const loadMore = vi.fn(async () => {});
|
||||
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: true, loadMore });
|
||||
|
||||
render(<TaskChatTab task={makeTask()} active expanded onToggleExpanded={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
metrics.scrollTop = 0;
|
||||
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.click(screen.getByTestId("task-chat-load-previous"));
|
||||
expect(loadMore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders load-previous affordances only for available older history", () => {
|
||||
const loadMore = vi.fn(async () => {});
|
||||
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: true, loadMore });
|
||||
const { unmount } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
const button = screen.getByTestId("task-chat-load-previous");
|
||||
expect(button).toBeVisible();
|
||||
expect(button).toHaveAccessibleName("Load previous messages");
|
||||
unmount();
|
||||
|
||||
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: true, loadingMore: true, loadMore });
|
||||
const loading = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
expect(screen.getByTestId("task-chat-load-previous-loading")).toHaveTextContent("Loading earlier messages…");
|
||||
expect(screen.queryByTestId("task-chat-load-previous")).not.toBeInTheDocument();
|
||||
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||
expect(loadMore).not.toHaveBeenCalled();
|
||||
loading.unmount();
|
||||
|
||||
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: false, loadMore });
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
expect(screen.queryByTestId("task-chat-load-previous")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("task-chat-load-previous-loading")).not.toBeInTheDocument();
|
||||
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||
expect(loadMore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves scroll position when older entries are prepended", async () => {
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
||||
const loadMoreDeferred = deferred<void>();
|
||||
const loadMore = vi.fn(() => loadMoreDeferred.promise);
|
||||
const currentEntries = [
|
||||
makeEntry({ agent: "executor", text: "current first", timestamp: "2026-06-12T00:00:02.000Z" }),
|
||||
makeEntry({ agent: "executor", text: "current latest", timestamp: "2026-06-12T00:00:03.000Z" }),
|
||||
];
|
||||
mockLogs(currentEntries, false, { hasMore: true, loadMore });
|
||||
|
||||
const { rerender } = render(<TaskChatTab task={makeTask()} active expanded onToggleExpanded={vi.fn()} addToast={vi.fn()} />);
|
||||
metrics.scrollTop = 0;
|
||||
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||
expect(loadMore).toHaveBeenCalledTimes(1);
|
||||
|
||||
metrics.scrollHeight = 1400;
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", text: "older history", timestamp: "2026-06-12T00:00:01.000Z" }),
|
||||
...currentEntries,
|
||||
], false, { hasMore: false, loadMore });
|
||||
await act(async () => {
|
||||
loadMoreDeferred.resolve();
|
||||
await loadMoreDeferred.promise;
|
||||
});
|
||||
rerender(<TaskChatTab task={makeTask()} active expanded onToggleExpanded={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
expect(metrics.scrollTop).toBe(400);
|
||||
expect(metrics.scrollTop).not.toBe(metrics.scrollHeight);
|
||||
expect(screen.getByTestId("task-chat-jump-to-bottom")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps live appends following the bottom while load-previous is in flight", async () => {
|
||||
const user = userEvent.setup();
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
||||
const loadMoreDeferred = deferred<void>();
|
||||
const loadMore = vi.fn(() => loadMoreDeferred.promise);
|
||||
const currentEntries = [makeEntry({ agent: "executor", text: "current output", timestamp: "2026-06-12T00:00:02.000Z" })];
|
||||
mockLogs(currentEntries, false, { hasMore: true, loadMore });
|
||||
|
||||
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
expect(metrics.scrollTop).toBe(1000);
|
||||
await user.click(screen.getByTestId("task-chat-load-previous"));
|
||||
expect(loadMore).toHaveBeenCalledTimes(1);
|
||||
|
||||
metrics.scrollHeight = 1300;
|
||||
mockLogs([
|
||||
...currentEntries,
|
||||
makeEntry({ agent: "executor", text: "live tail", timestamp: "2026-06-12T00:00:03.000Z" }),
|
||||
], false, { hasMore: true, loadMore });
|
||||
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
expect(metrics.scrollTop).toBe(1300);
|
||||
|
||||
metrics.scrollHeight = 1700;
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", text: "older history", timestamp: "2026-06-12T00:00:01.000Z" }),
|
||||
...currentEntries,
|
||||
makeEntry({ agent: "executor", text: "live tail", timestamp: "2026-06-12T00:00:03.000Z" }),
|
||||
], false, { hasMore: false, loadMore });
|
||||
await act(async () => {
|
||||
loadMoreDeferred.resolve();
|
||||
await loadMoreDeferred.promise;
|
||||
});
|
||||
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
expect(metrics.scrollTop).toBe(1700);
|
||||
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves steering-comment ordering after older entries are prepended", async () => {
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
||||
const loadMoreDeferred = deferred<void>();
|
||||
const loadMore = vi.fn(() => loadMoreDeferred.promise);
|
||||
const currentEntries = [makeEntry({ agent: "executor", text: "newer agent output", timestamp: "2026-06-12T00:00:03.000Z" })];
|
||||
const task = makeTask({
|
||||
steeringComments: [makeSteeringComment({ text: "middle user guidance", createdAt: "2026-06-12T00:00:02.000Z" })],
|
||||
});
|
||||
mockLogs(currentEntries, false, { hasMore: true, loadMore });
|
||||
|
||||
const { rerender } = render(<TaskChatTab task={task} active addToast={vi.fn()} />);
|
||||
metrics.scrollTop = 0;
|
||||
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||
|
||||
metrics.scrollHeight = 1400;
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", text: "older agent output", timestamp: "2026-06-12T00:00:01.000Z" }),
|
||||
...currentEntries,
|
||||
], false, { hasMore: false, loadMore });
|
||||
await act(async () => {
|
||||
loadMoreDeferred.resolve();
|
||||
await loadMoreDeferred.promise;
|
||||
});
|
||||
rerender(<TaskChatTab task={task} active addToast={vi.fn()} />);
|
||||
|
||||
const transcriptText = screen.getByTestId("task-chat-transcript").textContent ?? "";
|
||||
expect(transcriptText.indexOf("older agent output")).toBeLessThan(transcriptText.indexOf("middle user guidance"));
|
||||
expect(transcriptText.indexOf("middle user guidance")).toBeLessThan(transcriptText.indexOf("newer agent output"));
|
||||
});
|
||||
|
||||
it("does not render the jump-to-bottom button for loading or empty transcripts", () => {
|
||||
mockLogs([], true);
|
||||
const loading = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
@@ -1686,6 +1830,23 @@ describe("TaskChatTab", () => {
|
||||
expect(mobileJumpRule).toContain("min-block-size");
|
||||
});
|
||||
|
||||
it("keeps tokenized mobile touch targets for the load-previous affordance", () => {
|
||||
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
||||
const rowRule = getCssRuleBlock(css, ".task-chat-load-previous-row");
|
||||
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
||||
const mobileRowRule = getCssRuleBlock(mobileCss, ".task-chat-load-previous-row");
|
||||
const mobileButtonRule = getCssRuleBlock(mobileCss, ".task-chat-load-previous,");
|
||||
|
||||
expect(rowRule).toContain("justify-content: center");
|
||||
expect(rowRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-sm))");
|
||||
expect(css).toContain("gap: var(--space-xs)");
|
||||
expect(css).toContain("color: var(--text-muted)");
|
||||
expect(rowRule).not.toContain("px");
|
||||
expect(css).not.toContain("#");
|
||||
expect(mobileRowRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-sm))");
|
||||
expect(mobileButtonRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-sm))");
|
||||
});
|
||||
|
||||
it("scales the task chat send glyph without shrinking the desktop or mobile touch target", () => {
|
||||
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
||||
const sendRule = getCssRuleBlock(css, ".task-chat-send");
|
||||
|
||||
Reference in New Issue
Block a user