Archived
feat: add conversation position meter
This commit is contained in:
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user