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
1 change: 1 addition & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const CHAT_STRINGS = {
emptyTimelineDescription: "Say something to get the conversation going.",
emptyAgentTimelineDescription:
"They're ready — send the first message to get started.",
jumpToLatestAction: "Jump to latest",
mentionEmpty: "No matches",
mentionAgentsGroupLabel: "Agents",
mentionPeopleGroupLabel: "People",
Expand Down
71 changes: 71 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,77 @@
padding: 0.75rem clamp(0.85rem, 3vw, 1.75rem) 1rem;
}

/* Positions jump-to-latest over the live tail without putting it in the
scroll flow. Claims the same flex:1 slot `.chat-timeline` used to as
`.chat-main`'s column child. */
.chat-timeline-shell {
position: relative;
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}

.chat-jump-to-latest {
position: absolute;
z-index: 2;
left: 50%;
bottom: 0.75rem;
display: inline-flex;
min-height: 2.5rem;
align-items: center;
justify-content: center;
gap: 0.35rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--background);
padding: 0 0.85rem;
color: var(--foreground);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
box-shadow: 0 2px 10px color-mix(in srgb, black 14%, transparent);
cursor: pointer;
transform: translateX(-50%);
}

.chat-jump-to-latest svg {
width: 0.9rem;
height: 0.9rem;
}

.chat-jump-to-latest:hover {
background: color-mix(in srgb, var(--foreground) 4%, var(--background));
}

.chat-jump-to-latest:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}

.chat-jump-to-latest:active {
transform: translateX(-50%) scale(0.97);
}

@media (prefers-reduced-motion: no-preference) {
@keyframes chat-jump-to-latest-enter {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

.chat-jump-to-latest {
animation: chat-jump-to-latest-enter var(--duration-standard, 200ms)
var(--chat-ease);
}
}

/* The async-mint setup state: three staggered orange squares (the
brand's zero-radius language) pulsing while the launches finish.
`flex: 1` claims the rest of `.chat-main`'s column instead of sitting
Expand Down
176 changes: 105 additions & 71 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { groupTimelineParts } from "./tool-activity";
import { ToolActivityGroup } from "./tool-activity-view";
import {
ArrowBendUpLeft,
ArrowDown,
ChatCircle,
Clock,
Copy,
Expand Down Expand Up @@ -2200,7 +2201,10 @@ export function WorkbenchTimeline({
// Starts pinned (true) unless a restored snapshot says otherwise — a
// workbench's first-ever render always lands at the bottom, but remounting
// after Settings closes restores exactly how the reader left it.
const pinnedRef = useRef(scrollRestore?.pinned ?? true);
const [pinnedToLatest, setPinnedToLatest] = useState(
scrollRestore?.pinned ?? true,
);
const pinnedRef = useRef(pinnedToLatest);

// Kept current every render (never a dependency) so the unmount cleanup
// below always calls the host's latest callback, not a stale one closed
Expand All @@ -2215,7 +2219,19 @@ export function WorkbenchTimeline({
if (container === null) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
pinnedRef.current = distanceFromBottom <= BOTTOM_PIN_THRESHOLD_PX;
const nextPinned = distanceFromBottom <= BOTTOM_PIN_THRESHOLD_PX;
pinnedRef.current = nextPinned;
setPinnedToLatest((current) =>
current === nextPinned ? current : nextPinned,
);
};

const jumpToLatest = () => {
const container = containerRef.current;
if (container === null) return;
pinnedRef.current = true;
setPinnedToLatest(true);
container.scrollTop = container.scrollHeight;
};

useEffect(() => {
Expand Down Expand Up @@ -2317,87 +2333,105 @@ export function WorkbenchTimeline({
}

return (
<div className="chat-timeline" ref={containerRef} onScroll={handleScroll}>
{items.map((item, index) => {
if (joinRuns.absorbedIds.has(item.id)) return null;
const previous = index > 0 ? items[index - 1] : undefined;
const showDayDivider =
previous === undefined ||
!isSameCalendarDay(
new Date(previous.createdAt),
new Date(item.createdAt),
<div className="chat-timeline-shell">
<div className="chat-timeline" ref={containerRef} onScroll={handleScroll}>
{items.map((item, index) => {
if (joinRuns.absorbedIds.has(item.id)) return null;
const previous = index > 0 ? items[index - 1] : undefined;
const showDayDivider =
previous === undefined ||
!isSameCalendarDay(
new Date(previous.createdAt),
new Date(item.createdAt),
);
// Keyed by `clientId` (falling back to `id`) when present: a
// pending send and the confirmed message that later reconciles
// it (CL-6251's wire `clientId`) share this key, so React
// updates the same DOM node in place — avatar, header and all —
// rather than unmounting a "sending" bubble and mounting an
// unrelated "confirmed" one, which is what used to read as an
// unsent→sent swap (CL-6251, reopened).
const key = item.clientId ?? item.id;
if (item.streaming === true) {
return (
<StreamingMessageGroup
key={key}
item={item}
participants={participants}
currentUser={currentUser}
showDayDivider={showDayDivider}
{...(agentDisplayNames !== undefined
? { agentDisplayNames }
: {})}
/>
);
}
const showHeader = !isGroupedWithPrevious(
item,
previous,
showDayDivider,
);
// Keyed by `clientId` (falling back to `id`) when present: a
// pending send and the confirmed message that later reconciles
// it (CL-6251's wire `clientId`) share this key, so React
// updates the same DOM node in place — avatar, header and all —
// rather than unmounting a "sending" bubble and mounting an
// unrelated "confirmed" one, which is what used to read as an
// unsent→sent swap (CL-6251, reopened).
const key = item.clientId ?? item.id;
if (item.streaming === true) {
const collapsedJoinText = joinRuns.textByLeadId.get(item.id);
return (
<StreamingMessageGroup
<MessageParts
key={key}
item={item}
items={items}
participants={participants}
currentUser={currentUser}
showDayDivider={showDayDivider}
showHeader={showHeader}
{...(agentDisplayNames !== undefined
? { agentDisplayNames }
: {})}
{...(collapsedJoinText !== undefined
? { collapsedJoinText }
: {})}
threadMeta={threadMetaByMessageId?.get(item.id)}
threadAffordanceMode={threadAffordanceMode}
{...(onOpenThread !== undefined ? { onOpenThread } : {})}
{...(onEditMessage !== undefined ? { onEditMessage } : {})}
{...(onOpenProfile !== undefined ? { onOpenProfile } : {})}
{...(onOpenArtifact !== undefined ? { onOpenArtifact } : {})}
{...(onFixConnection !== undefined ? { onFixConnection } : {})}
{...(onOpenArtifactInLibrary !== undefined
? { onOpenArtifactInLibrary }
: {})}
{...(approvalActions !== undefined ? { approvalActions } : {})}
{...(blockResponses !== undefined ? { blockResponses } : {})}
{...(connectGithubActions !== undefined
? { connectGithubActions }
: {})}
{...(connectServiceActions !== undefined
? { connectServiceActions }
: {})}
{...(reactionActions !== undefined ? { reactionActions } : {})}
{...(pinActions !== undefined ? { pinActions } : {})}
{...(pendingActions !== undefined ? { pendingActions } : {})}
{...(onRetryFailedTurn !== undefined
? { onRetryFailedTurn }
: {})}
{...(onWhatHappenedFailedTurn !== undefined
? { onWhatHappenedFailedTurn }
: {})}
{...(failedTurnRecovery !== undefined
? { failedTurnRecovery }
: {})}
/>
);
}
const showHeader = !isGroupedWithPrevious(
item,
previous,
showDayDivider,
);
const collapsedJoinText = joinRuns.textByLeadId.get(item.id);
return (
<MessageParts
key={key}
item={item}
items={items}
participants={participants}
currentUser={currentUser}
showDayDivider={showDayDivider}
showHeader={showHeader}
{...(agentDisplayNames !== undefined ? { agentDisplayNames } : {})}
{...(collapsedJoinText !== undefined ? { collapsedJoinText } : {})}
threadMeta={threadMetaByMessageId?.get(item.id)}
threadAffordanceMode={threadAffordanceMode}
{...(onOpenThread !== undefined ? { onOpenThread } : {})}
{...(onEditMessage !== undefined ? { onEditMessage } : {})}
{...(onOpenProfile !== undefined ? { onOpenProfile } : {})}
{...(onOpenArtifact !== undefined ? { onOpenArtifact } : {})}
{...(onFixConnection !== undefined ? { onFixConnection } : {})}
{...(onOpenArtifactInLibrary !== undefined
? { onOpenArtifactInLibrary }
: {})}
{...(approvalActions !== undefined ? { approvalActions } : {})}
{...(blockResponses !== undefined ? { blockResponses } : {})}
{...(connectGithubActions !== undefined
? { connectGithubActions }
: {})}
{...(connectServiceActions !== undefined
? { connectServiceActions }
: {})}
{...(reactionActions !== undefined ? { reactionActions } : {})}
{...(pinActions !== undefined ? { pinActions } : {})}
{...(pendingActions !== undefined ? { pendingActions } : {})}
{...(onRetryFailedTurn !== undefined ? { onRetryFailedTurn } : {})}
{...(onWhatHappenedFailedTurn !== undefined
? { onWhatHappenedFailedTurn }
: {})}
{...(failedTurnRecovery !== undefined
? { failedTurnRecovery }
: {})}
/>
);
})}
{footer}
})}
{footer}
</div>
{pinnedToLatest ? null : (
<button
type="button"
className="chat-jump-to-latest"
onClick={jumpToLatest}
>
<ArrowDown aria-hidden="true" />
{CHAT_STRINGS.jumpToLatestAction}
</button>
)}
</div>
);
}
Loading
Loading