feat: add conversation position meter

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 09:20:35 +02:00
parent 6f7713f270
commit c5dc6550cc
4 changed files with 111 additions and 19 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Replace the chat history banner with a count-based conversation position meter that shows approximate message position without extra requests.
+56 -13
View File
@@ -7,6 +7,7 @@ import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import type { SessionActivity, SessionStatus } from "../api";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
import "./ConversationMeter";
import "./FormattedText";
import "./ToolExecutionView";
@@ -34,6 +35,15 @@ function isScrollPosition(value: unknown): value is { index?: number; key?: stri
&& (("key" in value && typeof value.key === "string") || ("index" in value && typeof value.index === "number"));
}
function clampPercent(value: number): number {
return clampNumber(value, 0, 100);
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
@customElement("chat-view")
export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = [];
@@ -53,11 +63,13 @@ export class ChatView extends LitElement {
@state() private openGroupKeys = new Set<string>();
@state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined;
@state() private currentConversationIndex: number | undefined;
private suppressScrollSave = false;
private suppressLoadMoreRequests = false;
private saveScrollTimer?: number;
private loadMoreCheckFrame: number | undefined;
private scrollToBottomFrame: number | undefined;
private conversationRailFrame: number | undefined;
private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0;
private groupedMessagesCache: ChatGroup[] = [];
@@ -87,6 +99,7 @@ export class ChatView extends LitElement {
window.clearTimeout(this.saveScrollTimer);
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
window.removeEventListener("resize", this.onViewportResize);
window.visualViewport?.removeEventListener("resize", this.onViewportResize);
super.disconnectedCallback();
@@ -108,6 +121,7 @@ export class ChatView extends LitElement {
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
}
@@ -115,7 +129,7 @@ export class ChatView extends LitElement {
const groups = this.groupedMessages();
return html`
<div class="chat-wrap">
${this.renderHistoryIndicator()}
${this.renderConversationRail()}
<div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}>
${this.renderHistoryBoundary()}
${repeat(
@@ -226,19 +240,24 @@ export class ChatView extends LitElement {
return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
}
private renderHistoryIndicator() {
private renderConversationRail() {
if (!this.messages.length || this.messageTotal <= 0) return null;
const loadedCount = this.messages.length;
const loadedPercent = Math.min(100, Math.round((loadedCount / this.messageTotal) * 100));
const olderCount = this.messageStart;
const fullHistory = olderCount <= 0
? "full history loaded"
: `${String(olderCount)} older not loaded · ${String(loadedPercent)}% loaded`;
return html`
<div class="history-indicator">
<div>${fullHistory}</div>
</div>
`;
const total = this.conversationDisplayTotal();
const position = this.conversationPositionPercent(total);
const loadedPercent = this.hasMore ? clampPercent((this.messages.length / total) * 100) : 100;
return html`<conversation-meter .positionPercent=${position} .loadedPercent=${loadedPercent}></conversation-meter>`;
}
private conversationDisplayTotal(): number {
if (!this.hasMore && this.messageStart === 0) return Math.max(1, this.messages.length);
return Math.max(1, this.messageTotal, this.messageStart + this.messages.length);
}
private conversationPositionPercent(total = this.conversationDisplayTotal()): number {
if (total <= 1) return 100;
const fallbackIndex = this.pinnedToBottom ? this.messageStart + this.messages.length - 1 : this.messageStart;
const index = clampNumber(this.currentConversationIndex ?? fallbackIndex, 0, total - 1);
return clampPercent((index / (total - 1)) * 100);
}
private renderHistoryBoundary() {
@@ -449,6 +468,7 @@ export class ChatView extends LitElement {
private onScroll() {
this.requestLoadMoreIfNeeded();
this.updatePinnedToBottomFromScroll();
this.scheduleConversationRailUpdate();
if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
}
@@ -646,6 +666,29 @@ export class ChatView extends LitElement {
this.saveScrollTimer = window.setTimeout(() => { this.saveScrollPosition(); }, 180);
}
private scheduleConversationRailUpdate(): void {
if (this.conversationRailFrame !== undefined) return;
this.conversationRailFrame = requestAnimationFrame(() => {
this.conversationRailFrame = undefined;
this.updateConversationRailPosition();
});
}
private updateConversationRailPosition(): void {
if (!this.messages.length || this.messageTotal <= 0) {
this.currentConversationIndex = undefined;
return;
}
const total = this.conversationDisplayTotal();
const article = this.firstVisibleArticle();
const index = Number(article?.dataset["index"]);
if (Number.isFinite(index)) {
this.currentConversationIndex = clampNumber(index, 0, Math.max(0, total - 1));
return;
}
this.currentConversationIndex = clampNumber(this.pinnedToBottom ? this.messageStart + this.messages.length - 1 : this.messageStart, 0, Math.max(0, total - 1));
}
private readStoredScrollPosition(): { index?: number; key?: string; offset: number } | undefined {
if (this.sessionId === "") return undefined;
try {
@@ -0,0 +1,45 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("conversation-meter")
export class ConversationMeter extends LitElement {
@property({ type: Number }) positionPercent = 0;
@property({ type: Number }) loadedPercent = 100;
override render() {
const position = clampPercent(this.positionPercent);
const loaded = clampPercent(this.loadedPercent);
const label = `Message position: about ${String(Math.round(position))}% through conversation. ${String(Math.round(loaded))}% of messages loaded.`;
return html`
<div
class="meter"
style=${`--position:${position.toFixed(2)}%;`}
role="meter"
aria-label=${label}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${String(Math.round(position))}
title=${label}
>
<div class="track" aria-hidden="true">
<div class="progress"></div>
<div class="marker"></div>
</div>
</div>
`;
}
static override styles = css`
:host { position: absolute; top: -4px; left: 16px; right: 16px; z-index: 6; display: block; height: 12px; opacity: .58; transition: opacity .15s ease; }
:host(:hover), :host(:focus-within) { opacity: .92; }
.meter { height: 100%; }
.track { position: relative; height: 4px; margin-top: 4px; border-radius: 999px; background: color-mix(in srgb, var(--pi-border-muted) 34%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--pi-bg) 55%, transparent); }
.progress { position: absolute; left: 0; width: var(--position); top: 0; bottom: 0; border-radius: 999px; background: color-mix(in srgb, var(--pi-accent) 42%, var(--pi-border-muted)); }
.marker { position: absolute; left: var(--position); top: 50%; width: 10px; height: 10px; border: 2px solid var(--pi-bg); border-radius: 50%; background: var(--pi-accent); box-shadow: 0 2px 8px var(--pi-shadow); transform: translate(-50%, -50%); }
`;
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(100, Math.max(0, value));
}
+5 -6
View File
@@ -173,9 +173,8 @@ export const listStyles = css`
export const chatStyles = css`
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 16px 16px 64px; box-sizing: border-box; }
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
.history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg-overlay-soft); color: var(--pi-muted); padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px var(--pi-shadow-soft); }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
.activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); }
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -190,7 +189,7 @@ export const chatStyles = css`
.msg.skill { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); }
.msg.event-group { padding: 0; border-color: var(--pi-border); background: var(--pi-bg); color: var(--pi-muted); }
.msg.event-group.live { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
.msg.event-group > summary { position: sticky; top: -16px; z-index: 3; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 9px 9px 0 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); }
.msg.event-group > summary { position: sticky; top: -26px; z-index: 5; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 9px 9px 0 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); }
.msg.event-group.live > summary { border-bottom-color: var(--pi-success-border); background: var(--pi-success-bg); color: var(--pi-success); }
.msg.event-group > summary .label { margin: 0; }
.group-body { padding: 0 12px 12px; }
@@ -199,7 +198,7 @@ export const chatStyles = css`
.group-msg.tool-execution-shell { color: var(--pi-text); }
.group-msg.system { color: var(--pi-danger); }
.group-msg.bash { color: var(--pi-success); }
.history-boundary { display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: var(--pi-muted); font-size: 12px; text-align: center; }
.history-boundary { position: relative; z-index: 5; display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: var(--pi-muted); font-size: 12px; text-align: center; }
.history-load-button { border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text-secondary); padding: 5px 12px; font: 12px system-ui, sans-serif; cursor: pointer; }
.history-load-button:hover, .history-load-button:focus { border-color: var(--pi-accent); color: var(--pi-text-bright); }
.history-load-button:disabled { cursor: default; opacity: .55; }
@@ -218,12 +217,12 @@ export const chatStyles = css`
.session-activity span, .session-activity small { color: var(--pi-muted); }
.history-boundary small { color: var(--pi-dim); }
.msg-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 22px; margin-bottom: 8px; }
.msg > .msg-header { position: sticky; top: -16px; z-index: 2; margin: -12px -12px 8px; padding: 7px 10px 6px; border-radius: 9px 9px 0 0; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-surface); box-shadow: 0 8px 18px var(--pi-shadow-soft); }
.msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -12px -12px 8px; padding: 7px 10px 6px; border-radius: 9px 9px 0 0; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-surface); box-shadow: 0 8px 18px var(--pi-shadow-soft); }
.msg.user > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-accent-border) 35%, transparent); background: var(--pi-selection-bg); }
.msg.tool > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-warning-border) 35%, transparent); background: var(--pi-warning-surface); }
.msg.bash > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-success) 35%, transparent); background: var(--pi-success-bg); }
.msg.skill > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-purple-border) 35%, transparent); background: var(--pi-purple-surface); }
.group-msg > .msg-header { position: sticky; top: -16px; z-index: 2; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); }
.group-msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); }
.msg-header-trailing { min-width: 0; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; }
.msg-actions { display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; }
.msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; }