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
14 changes: 13 additions & 1 deletion crates/loom/frontend/src/components/AcpConversation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
import MarkdownView from './MarkdownView.vue';
import AgentUsage from './AgentUsage.vue';
import ToolContentPreview from './ToolContentPreview.vue';
import ScrollToEndButton from './ScrollToEndButton.vue';

// The Conversation surface for an *ACP* session (`protocol='acp'`): typeset
// dialogue, not chat bubbles. Serif prose for the humans and the agent; the
Expand Down Expand Up @@ -1579,11 +1580,20 @@ const progressLabel = computed(() => {
// ── Follow-the-foot scroll (lib/followFoot.ts): pinned-at-the-newest-exchange. ──
const convScroll = ref<HTMLElement | null>(null);
const convBody = ref<HTMLElement | null>(null);
const { pinned, scrollToBottom, autoFollow, trackPin } = useFollowFoot(convScroll, convBody);
const { pinned, scrollToBottom, scrollToEnd, autoFollow, trackPin } = useFollowFoot(
convScroll,
convBody,
);
function onScroll() {
trackPin();
updateActive();
}
// Activating the floating button unmounts it. Hand focus to the transcript so a
// keyboard reader stays in the conversation instead of falling back to <body>.
function jumpToEnd() {
scrollToEnd();
convScroll.value?.focus({ preventScroll: true });
}

// ── Jump-list scroll-spy ─────────────────────────────────────────────────────
const activeAnchor = ref('');
Expand Down Expand Up @@ -1665,6 +1675,7 @@ async function goToActivity(target: {
ref="convScroll"
data-testid="acp-conversation"
class="acp-scroll min-h-0 flex-1 overflow-auto pb-6 pr-1"
tabindex="-1"
@scroll.passive="onScroll"
>
<div ref="convBody">
Expand Down Expand Up @@ -2108,6 +2119,7 @@ async function goToActivity(target: {
>
</div>
</div>
<ScrollToEndButton :visible="!pinned" @click="jumpToEnd" />
</div>

<!-- Optional context: turns, images, failures, and the current plan. -->
Expand Down
73 changes: 73 additions & 0 deletions crates/loom/frontend/src/components/ScrollToEndButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
// A small floating affordance that appears at the transcript's lower-right when
// the reader has scrolled away from the newest exchange. Clicking it re-pins the
// view and jumps to the foot. Shared by both conversation surfaces (ACP and
// terminal), which own the scroll container and pass `visible` from their
// follow-the-foot pin state.
defineProps<{ visible: boolean }>();
defineEmits<{ click: [] }>();
</script>

<template>
<button
v-if="visible"
type="button"
data-testid="scroll-to-end"
class="scroll-to-end"
title="Scroll to the latest message"
aria-label="Scroll to the latest message"
@click="$emit('click')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m7 6 5 5 5-5" />
<path d="m7 13 5 5 5-5" />
</svg>
</button>
</template>

<style scoped>
.scroll-to-end {
/* Sticky, not absolute: sitting at the end of the transcript it stays glued to
the scrollport's lower-right while the reader is up in the history, without
needing a positioned wrapper around the scroller. */
position: sticky;
bottom: 1rem;
z-index: 10;
display: grid;
width: 2rem;
height: 2rem;
margin-top: 0.5rem;
margin-right: 0.5rem;
margin-left: auto;
place-items: center;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.16);
cursor: pointer;
transition:
color 0.12s ease,
background-color 0.12s ease,
border-color 0.12s ease;
}
.scroll-to-end:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: var(--subtle-hover);
color: var(--fg);
}
.scroll-to-end:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
outline-offset: 2px;
}
</style>
11 changes: 10 additions & 1 deletion crates/loom/frontend/src/components/TerminalConversation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useFollowFoot } from '../lib/followFoot';
import { openSessionEvents, type SessionEventsHandle } from '../lib/sessionEvents';
import { localTime } from '../lib/time';
import MarkdownView from './MarkdownView.vue';
import ScrollToEndButton from './ScrollToEndButton.vue';

// The Conversation tab for a *terminal-backend* session (`protocol='terminal'`):
// the agent's chat with the model, rendered for review and — while the agent is
Expand Down Expand Up @@ -416,11 +417,17 @@ function toggleAll() {
// ── Follow-the-foot scroll (lib/followFoot.ts): pinned-at-the-newest-exchange. ──
const convScroll = ref<HTMLElement | null>(null);
const convBody = ref<HTMLElement | null>(null);
const { pinned, scrollToBottom, trackPin } = useFollowFoot(convScroll, convBody);
const { pinned, scrollToBottom, scrollToEnd, trackPin } = useFollowFoot(convScroll, convBody);
function onScroll() {
trackPin();
updateActive();
}
// Activating the floating button unmounts it. Hand focus to the transcript so a
// keyboard reader stays in the conversation instead of falling back to <body>.
function jumpToEnd() {
scrollToEnd();
convScroll.value?.focus({ preventScroll: true });
}

// ── Prompt jump-list (scroll-spy) ───────────────────────────────────────────
const activeAnchor = ref('');
Expand Down Expand Up @@ -568,6 +575,7 @@ const groupHasError = (g: ToolGroup) => g.items.some((it) => it.result?.is_error
ref="convScroll"
data-testid="conversation"
class="conv-scroll chat-prose min-h-0 flex-1 overflow-auto pb-6 pr-1"
tabindex="-1"
@scroll.passive="onScroll"
>
<div ref="convBody" class="space-y-2">
Expand Down Expand Up @@ -702,6 +710,7 @@ const groupHasError = (g: ToolGroup) => g.items.some((it) => it.result?.is_error
<p v-else-if="row.type === 'image'" class="text-xs italic text-muted">[image]</p>
</template>
</div>
<ScrollToEndButton :visible="!pinned" @click="jumpToEnd" />
</div>

<!-- Prompt jump-list: one entry per user turn, with scroll-spy highlight. -->
Expand Down
8 changes: 7 additions & 1 deletion crates/loom/frontend/src/lib/followFoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export function useFollowFoot(scrollEl: Ref<HTMLElement | null>, bodyEl: Ref<HTM
function autoFollow() {
if (pinned.value) nextTick(scrollToBottom);
}
/** Re-arm the pin and jump to the foot — the reader asked for the end
* explicitly, so following resumes from here. */
function scrollToEnd() {
pinned.value = true;
scrollToBottom();
}
/** The scroll-handler half: re-derive the pin from the reader's position. */
function trackPin() {
const el = scrollEl.value;
Expand Down Expand Up @@ -72,5 +78,5 @@ export function useFollowFoot(scrollEl: Ref<HTMLElement | null>, bodyEl: Ref<HTM
bodyRO = null;
});

return { pinned, scrollToBottom, autoFollow, trackPin };
return { pinned, scrollToBottom, scrollToEnd, autoFollow, trackPin };
}
31 changes: 31 additions & 0 deletions e2e/tests/conversation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,37 @@ test.describe('conversation view', () => {
expect(await conv.evaluate((el) => el.scrollTop)).toBeLessThan(300);
});

// The floating scroll-to-end affordance: gone while the newest exchange is in
// view, present once the reader is up in the history, and one click returns
// them to the foot.
test('a scroll-to-end button appears off the foot and returns to the end', async ({
page,
weaver,
}) => {
await openConversation(page, weaver, { name: 'conv-scroll-end', log: longLog() });
const conv = page.getByTestId('conversation');
const button = page.getByTestId('scroll-to-end');

// At the foot the end is visible — no button.
await expect.poll(() => footDistance(conv), { timeout: 30_000 }).toBeLessThan(120);
await expect(button).toBeHidden();

// Scrolled up, the end is out of sight — the button appears.
await conv.evaluate((el) => {
el.scrollTop = 0;
});
await expect(button).toBeVisible();

// Clicking scrolls all the way to the end — exact landing, not merely inside
// the pin's 120px "near the foot" window — and hides the button again.
await button.click();
await expect.poll(() => footDistance(conv)).toBeLessThan(2);
await expect(page.getByText('Progress note 40', { exact: false })).toBeVisible();
await expect(button).toBeHidden();
// The button unmounts on activation; focus stays in the conversation.
await expect(conv).toBeFocused();
});

test('folds tool activity and run-length collapses repeated calls', async ({ page, weaver }) => {
await openConversation(page, weaver);
const conv = page.getByTestId('conversation');
Expand Down
Loading