Archived
Handle reconnecting mid-stream
This commit is contained in:
@@ -10,6 +10,7 @@ export interface AppState {
|
|||||||
messagePageStart: number;
|
messagePageStart: number;
|
||||||
messagePageTotal: number;
|
messagePageTotal: number;
|
||||||
isLoadingEarlierMessages: boolean;
|
isLoadingEarlierMessages: boolean;
|
||||||
|
isReceivingPartialStream: boolean;
|
||||||
selectedProject: Project | undefined;
|
selectedProject: Project | undefined;
|
||||||
selectedWorkspace: Workspace | undefined;
|
selectedWorkspace: Workspace | undefined;
|
||||||
selectedSession: SessionInfo | undefined;
|
selectedSession: SessionInfo | undefined;
|
||||||
@@ -44,6 +45,7 @@ export function initialAppState(): AppState {
|
|||||||
messagePageStart: 0,
|
messagePageStart: 0,
|
||||||
messagePageTotal: 0,
|
messagePageTotal: 0,
|
||||||
isLoadingEarlierMessages: false,
|
isLoadingEarlierMessages: false,
|
||||||
|
isReceivingPartialStream: false,
|
||||||
selectedProject: undefined,
|
selectedProject: undefined,
|
||||||
selectedWorkspace: undefined,
|
selectedWorkspace: undefined,
|
||||||
selectedSession: undefined,
|
selectedSession: undefined,
|
||||||
|
|||||||
@@ -12,9 +12,22 @@ describe("mergeChatHistory", () => {
|
|||||||
expect(merged).toEqual(page(0, 5, ["a", "b", "c", "d", "e"]));
|
expect(merged).toEqual(page(0, 5, ["a", "b", "c", "d", "e"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses incoming history when totals changed", () => {
|
it("keeps cached history when new messages were appended", () => {
|
||||||
|
const existing = page(0, 3, ["a", "b", "c"]);
|
||||||
|
const incoming = page(1, 4, ["b", "c", "d"]);
|
||||||
|
|
||||||
|
expect(mergeChatHistory(existing, incoming)).toEqual(page(0, 4, ["a", "b", "c", "d"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses incoming history when totals shrink", () => {
|
||||||
const incoming = page(0, 2, ["fresh-a", "fresh-b"]);
|
const incoming = page(0, 2, ["fresh-a", "fresh-b"]);
|
||||||
|
|
||||||
expect(mergeChatHistory(page(0, 1, ["stale"]), incoming)).toEqual(incoming);
|
expect(mergeChatHistory(page(0, 3, ["stale-a", "stale-b", "stale-c"]), incoming)).toEqual(incoming);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses incoming history instead of creating a gapped page", () => {
|
||||||
|
const incoming = page(8, 10, ["i", "j"]);
|
||||||
|
|
||||||
|
expect(mergeChatHistory(page(0, 10, ["a", "b"]), incoming)).toEqual(incoming);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
|
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
|
||||||
if (existing?.total !== incoming.total) return incoming;
|
if (existing === undefined) return incoming;
|
||||||
|
if (existing.total > incoming.total) return incoming;
|
||||||
|
|
||||||
const start = Math.min(existing.start, incoming.start);
|
const start = Math.min(existing.start, incoming.start);
|
||||||
const end = Math.max(existing.start + existing.messages.length, incoming.start + incoming.messages.length);
|
const end = Math.max(existing.start + existing.messages.length, incoming.start + incoming.messages.length);
|
||||||
@@ -44,7 +45,15 @@ export function mergeChatHistory(existing: RawMessagePage | undefined, incoming:
|
|||||||
copyInto(messages, start, existing);
|
copyInto(messages, start, existing);
|
||||||
copyInto(messages, start, incoming);
|
copyInto(messages, start, incoming);
|
||||||
|
|
||||||
return { start, total: incoming.total, messages: messages.filter((message) => message !== undefined) };
|
if (hasSparseEntries(messages)) return incoming;
|
||||||
|
return { start, total: incoming.total, messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSparseEntries(messages: unknown[]): boolean {
|
||||||
|
for (let index = 0; index < messages.length; index += 1) {
|
||||||
|
if (!(index in messages) || messages[index] === undefined) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyInto(target: unknown[], targetStart: number, page: RawMessagePage): void {
|
function copyInto(target: unknown[], targetStart: number, page: RawMessagePage): void {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class ChatView extends LitElement {
|
|||||||
@property({ type: Number }) messageTotal = 0;
|
@property({ type: Number }) messageTotal = 0;
|
||||||
@property({ type: Boolean }) hasMore = false;
|
@property({ type: Boolean }) hasMore = false;
|
||||||
@property({ type: Boolean }) loadingMore = false;
|
@property({ type: Boolean }) loadingMore = false;
|
||||||
|
@property({ type: Boolean }) isReceivingPartialStream = false;
|
||||||
@property({ type: Boolean }) isCompacting = false;
|
@property({ type: Boolean }) isCompacting = false;
|
||||||
@property({ type: Number }) pendingMessageCount = 0;
|
@property({ type: Number }) pendingMessageCount = 0;
|
||||||
@property({ attribute: false }) status?: SessionStatus;
|
@property({ attribute: false }) status?: SessionStatus;
|
||||||
@@ -85,6 +86,12 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderSessionActivity() {
|
private renderSessionActivity() {
|
||||||
|
if (this.isReceivingPartialStream) return html`
|
||||||
|
<aside class="session-activity receiving" aria-live="polite">
|
||||||
|
<strong>Receiving answer…</strong>
|
||||||
|
<span>This session was reconnected mid-response. The answer will appear when complete.</span>
|
||||||
|
</aside>
|
||||||
|
`;
|
||||||
if (!this.isCompacting) return null;
|
if (!this.isCompacting) return null;
|
||||||
return html`
|
return html`
|
||||||
<aside class="session-activity compacting" aria-live="polite">
|
<aside class="session-activity compacting" aria-live="polite">
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export class PiWebApp extends LitElement {
|
|||||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||||
<div class="mobile-navigation-panel">${this.renderNavigationPanel(true)}</div>
|
<div class="mobile-navigation-panel">${this.renderNavigationPanel(true)}</div>
|
||||||
${state.selectedSession ? html`
|
${state.selectedSession ? html`
|
||||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor>
|
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor>
|
||||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
||||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ export const chatStyles = css`
|
|||||||
.history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; }
|
.history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; }
|
||||||
.session-activity { display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; color: #e6edf3; }
|
.session-activity { display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; color: #e6edf3; }
|
||||||
.session-activity.compacting { border-color: #a371f7; background: #21132f; }
|
.session-activity.compacting { border-color: #a371f7; background: #21132f; }
|
||||||
|
.session-activity.receiving { border-color: #238636; background: #0f1b12; }
|
||||||
.session-activity strong { color: #d2a8ff; }
|
.session-activity strong { color: #d2a8ff; }
|
||||||
|
.session-activity.receiving strong { color: #3fb950; }
|
||||||
.session-activity span, .session-activity small { color: #8b949e; }
|
.session-activity span, .session-activity small { color: #8b949e; }
|
||||||
.history-boundary small { color: #6e7681; }
|
.history-boundary small { color: #6e7681; }
|
||||||
.label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
|
.label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class SessionController {
|
|||||||
private readonly socket = new SessionSocket();
|
private readonly socket = new SessionSocket();
|
||||||
private readonly globalSocket = new GlobalSessionSocket();
|
private readonly globalSocket = new GlobalSessionSocket();
|
||||||
private selectionSeq = 0;
|
private selectionSeq = 0;
|
||||||
|
private catchupStreamSessionId: string | undefined;
|
||||||
|
|
||||||
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
|
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ export class SessionController {
|
|||||||
|
|
||||||
clearActiveSession() {
|
clearActiveSession() {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
|
this.catchupStreamSessionId = undefined;
|
||||||
|
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
async startSession() {
|
async startSession() {
|
||||||
@@ -49,6 +51,7 @@ export class SessionController {
|
|||||||
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
||||||
const seq = ++this.selectionSeq;
|
const seq = ++this.selectionSeq;
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
|
this.catchupStreamSessionId = undefined;
|
||||||
const cached = readChatHistoryCache(session.id);
|
const cached = readChatHistoryCache(session.id);
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedSession: session,
|
selectedSession: session,
|
||||||
@@ -56,6 +59,7 @@ export class SessionController {
|
|||||||
messagePageStart: cached?.start ?? 0,
|
messagePageStart: cached?.start ?? 0,
|
||||||
messagePageTotal: cached?.total ?? 0,
|
messagePageTotal: cached?.total ?? 0,
|
||||||
isLoadingEarlierMessages: false,
|
isLoadingEarlierMessages: false,
|
||||||
|
isReceivingPartialStream: false,
|
||||||
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
|
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
|
||||||
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
|
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
|
||||||
});
|
});
|
||||||
@@ -64,7 +68,7 @@ export class SessionController {
|
|||||||
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
|
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||||
const history = this.mergeAndCacheHistory(session.id, page);
|
const history = this.mergeAndCacheHistory(session.id, page);
|
||||||
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
|
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||||
if (options?.updateUrl !== false) this.updateUrl();
|
if (options?.updateUrl !== false) this.updateUrl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,7 +77,9 @@ export class SessionController {
|
|||||||
const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
|
const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||||
const history = this.mergeAndCacheHistory(session.id, page);
|
const history = this.mergeAndCacheHistory(session.id, page);
|
||||||
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, status, activity: this.getState().sessionActivities[session.id] });
|
const isReceivingPartialStream = status.isStreaming === true;
|
||||||
|
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
|
||||||
|
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
|
||||||
this.applyStatus(status);
|
this.applyStatus(status);
|
||||||
for (const event of buffered) this.applyEvent(event);
|
for (const event of buffered) this.applyEvent(event);
|
||||||
this.socket.setHandler((event) => { this.applyEvent(event); });
|
this.socket.setHandler((event) => { this.applyEvent(event); });
|
||||||
@@ -238,6 +244,7 @@ export class SessionController {
|
|||||||
sessionStatuses: { ...this.getState().sessionStatuses, [status.sessionId]: status },
|
sessionStatuses: { ...this.getState().sessionStatuses, [status.sessionId]: status },
|
||||||
status: this.getState().selectedSession?.id === status.sessionId ? status : this.getState().status,
|
status: this.getState().selectedSession?.id === status.sessionId ? status : this.getState().status,
|
||||||
});
|
});
|
||||||
|
if (this.catchupStreamSessionId === status.sessionId && !status.isStreaming) this.finishStreamCatchup(status.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySessionName(sessionId: string, name: string | undefined) {
|
private applySessionName(sessionId: string, name: string | undefined) {
|
||||||
@@ -256,6 +263,15 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyEvent(event: SessionUiEvent) {
|
private applyEvent(event: SessionUiEvent) {
|
||||||
|
const selectedSessionId = this.getState().selectedSession?.id;
|
||||||
|
if (this.catchupStreamSessionId !== undefined && this.catchupStreamSessionId === selectedSessionId) {
|
||||||
|
if (event.type === "message.end" || event.type === "agent.end") {
|
||||||
|
this.finishStreamCatchup(this.catchupStreamSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTranscriptEvent(event)) return;
|
||||||
|
}
|
||||||
|
|
||||||
const transcript = applyTranscriptEvent(this.getState().messages, event);
|
const transcript = applyTranscriptEvent(this.getState().messages, event);
|
||||||
if (transcript) {
|
if (transcript) {
|
||||||
this.setState({ messages: transcript });
|
this.setState({ messages: transcript });
|
||||||
@@ -267,5 +283,27 @@ export class SessionController {
|
|||||||
this.applySessionName(event.sessionId, event.name);
|
this.applySessionName(event.sessionId, event.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private finishStreamCatchup(sessionId: string) {
|
||||||
|
if (this.catchupStreamSessionId !== sessionId) return;
|
||||||
|
this.catchupStreamSessionId = undefined;
|
||||||
|
if (this.getState().selectedSession?.id === sessionId) this.setState({ isReceivingPartialStream: false });
|
||||||
|
void this.refreshMessages(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshMessages(sessionId: string) {
|
||||||
|
try {
|
||||||
|
const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
|
||||||
|
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||||
|
const history = this.mergeAndCacheHistory(sessionId, page);
|
||||||
|
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total });
|
||||||
|
} catch (error) {
|
||||||
|
if (this.getState().selectedSession?.id === sessionId) this.setState({ error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTranscriptEvent(event: SessionUiEvent): boolean {
|
||||||
|
return ["message.append", "assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "command.output", "session.error"].includes(event.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user