diff --git a/docs/reference.md b/docs/reference.md index 66cb79939..36bd60563 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -268,9 +268,10 @@ sequences and displays at most five rendered output rows, retaining head and tail context. The web client displays a five-line preview and provides a control to expand retained results that exceed five lines. -Selecting a Session in the web chat opens it at the latest retained message. -Reconnecting preserves an intentional upward scroll position and shows the -history that remains available on the Session workspace. +Selecting a Session in the web chat opens a bounded page at the latest retained +message. Use **Load earlier messages** to prepend the previous page without +moving away from the current scroll position. Reconnecting preserves an +intentional upward scroll position and the loaded conversation view. The Session sidebar shows compact relative activity times, and the selected Session header shows whether it is active now, when it was last active, or when diff --git a/internal/manifests/charts/kelos/README.md b/internal/manifests/charts/kelos/README.md index 1fbe67068..44e2c5ce1 100644 --- a/internal/manifests/charts/kelos/README.md +++ b/internal/manifests/charts/kelos/README.md @@ -207,8 +207,10 @@ the underlying PersistentVolume. The shared Session server serves the web application, bridges each chat to its Session Pod through Kubernetes exec, and can request a destructive Session -workspace reset after user confirmation. It is disabled by default and requires -a non-empty static token in an existing Secret: +workspace reset after user confirmation. The web application opens long +conversations at a bounded recent page and loads earlier messages on demand. It +is disabled by default and requires a non-empty static token in an existing +Secret: ```bash kubectl create secret generic kelos-session-auth \ diff --git a/internal/sessionruntime/history.go b/internal/sessionruntime/history.go index 23315ecde..ff0da628a 100644 --- a/internal/sessionruntime/history.go +++ b/internal/sessionruntime/history.go @@ -62,6 +62,7 @@ func projectHistory(source []Event) ([]historyItem, HistoryState, []Event) { turns := make(map[string]*historyTurn) pendingInputs := make(map[string]Event) + fileDiff := "" for index := range events { event := events[index] if event.TurnID != "" { @@ -98,10 +99,12 @@ func projectHistory(source []Event) ([]historyItem, HistoryState, []Event) { } case EventInputResolved: delete(pendingInputs, event.InputID) + case EventFileDiff: + fileDiff = boundedHistoryText(event.Diff, maxHistoryDiffBytes) } } - state := HistoryState{} + state := HistoryState{FileDiff: fileDiff} queued := make([]HistoryQueuedTurn, 0) queuedEventIDs := make(map[string]int64) var activeEventID int64 diff --git a/internal/sessionruntime/protocol.go b/internal/sessionruntime/protocol.go index 694f6b824..42bd603a7 100644 --- a/internal/sessionruntime/protocol.go +++ b/internal/sessionruntime/protocol.go @@ -62,6 +62,7 @@ type HistoryState struct { TurnInterrupting bool `json:"turnInterrupting,omitempty"` WaitingForInput bool `json:"waitingForInput,omitempty"` QueuedTurns []HistoryQueuedTurn `json:"queuedTurns,omitempty"` + FileDiff string `json:"fileDiff,omitempty"` } // HistoryQueuedTurn describes one user message waiting to run. diff --git a/internal/sessionruntime/server_test.go b/internal/sessionruntime/server_test.go index fa7163131..fe363bae5 100644 --- a/internal/sessionruntime/server_test.go +++ b/internal/sessionruntime/server_test.go @@ -1597,6 +1597,7 @@ func TestServerPreservesStateOutsideProjectedHistoryPage(t *testing.T) { startedAt := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) queuedText := strings.Repeat("queued ", maxHistoryMessageBytes) for _, event := range []Event{ + {Type: EventFileDiff, Diff: "diff --git a/old.txt b/old.txt\n-old\n+new"}, {Type: EventUserMessage, TurnID: "turn-1", Text: "active request"}, {Type: EventTurnStarted, TurnID: "turn-1", Timestamp: &startedAt, Status: "running"}, {Type: EventAssistantDelta, TurnID: "turn-1", Text: "partial answer"}, @@ -1659,6 +1660,9 @@ func TestServerPreservesStateOutsideProjectedHistoryPage(t *testing.T) { if len(state.QueuedTurns) != 1 || state.QueuedTurns[0].TurnID != "turn-2" { t.Fatalf("queued turns = %#v, want turn-2", state.QueuedTurns) } + if state.FileDiff != "diff --git a/old.txt b/old.txt\n-old\n+new" { + t.Fatalf("file diff = %q, want state outside projected history page", state.FileDiff) + } if text := state.QueuedTurns[0].Text; len(text) > maxHistoryMessageBytes || !strings.Contains(text, historyTruncationMarker) { t.Fatalf("queued message preview has %d bytes", len(text)) } diff --git a/internal/sessionserver/server_test.go b/internal/sessionserver/server_test.go index 14cbc4ce1..29a99a876 100644 --- a/internal/sessionserver/server_test.go +++ b/internal/sessionserver/server_test.go @@ -145,18 +145,21 @@ func TestSessionJavaScriptCachesIncrementalHistory(t *testing.T) { } javascript := string(source) for description, expected := range map[string]string{ - "bounded Session view cache": "const maxCachedSessionViews = 5;", - "Session incarnation cache key": "function sessionViewKey(session) {", - "recreated Session invalidation": "state.selected.uid !== current.uid", - "view state preservation": "function saveCurrentSessionView() {", - "incremental history subscription": "historyBounds: true", - "journal identity subscription": "journalId: state.currentView?.journalID || '',", - "stale history reset": "if (event.reset ||", - "replaced journal reset": "state.currentView.journalID !== event.journalId", - "deferred history rendering": "function finishHistoryReplay() {", - "selection bottom pin": "state.pinHistoryToBottom = true;", - "frame-batched bottom anchor": "function scheduleBottomAnchor() {", - "instant bottom positioning": "elements.messages.scrollTop = elements.messages.scrollHeight;", + "bounded Session view cache": "const maxCachedSessionViews = 5;", + "Session incarnation cache key": "function sessionViewKey(session) {", + "recreated Session invalidation": "state.selected.uid !== current.uid", + "view state preservation": "function saveCurrentSessionView() {", + "bounded history item subscription": "historyItems: sessionHistoryItemLimit", + "bounded history byte subscription": "historyBytes: sessionHistoryByteLimit", + "history cursor preservation": "view.historyCursor = state.historyCursor", + "reconnect high-water tracking": "state.lastEventID = Math.max(state.lastEventID, state.historyLastEventID)", + "older history request guard": "if (state.historyPageLoading || !state.historyCursor", + "older history isolation": "function replayOlderHistoryPage(events) {", + "replaced journal reset": "state.currentView.journalID !== event.journalId", + "deferred history rendering": "function finishHistoryReplay(historyState) {", + "selection bottom pin": "state.pinHistoryToBottom = true;", + "frame-batched bottom anchor": "function scheduleBottomAnchor() {", + "instant bottom positioning": "elements.messages.scrollTop = elements.messages.scrollHeight;", } { if !strings.Contains(javascript, expected) { t.Errorf("Session JavaScript is missing %s: %s", description, expected) diff --git a/internal/sessionserver/testdata/session_history_test.js b/internal/sessionserver/testdata/session_history_test.js index b34c6141a..867be79dd 100644 --- a/internal/sessionserver/testdata/session_history_test.js +++ b/internal/sessionserver/testdata/session_history_test.js @@ -13,6 +13,9 @@ class TestNode { this.dataset = {}; this.attributes = new Map(); this.classes = new Set(); + this.listeners = new Map(); + this.style = {}; + this.scrollTop = 0; this.classList = { add: (...names) => names.forEach((name) => this.classes.add(name)), remove: (...names) => names.forEach((name) => this.classes.delete(name)), @@ -23,6 +26,14 @@ class TestNode { return this.children[0] || null; } + get lastChild() { + return this.children[this.children.length - 1] || null; + } + + get scrollHeight() { + return this.children.length * 20; + } + hasChildNodes() { return this.children.length > 0; } @@ -45,6 +56,28 @@ class TestNode { } } + prepend(...nodes) { + const added = []; + for (const node of nodes) { + if (node.tag === '#fragment') { + while (node.firstChild) { + const child = node.firstChild; + node.removeChild(child); + added.push(child); + } + continue; + } + if (node.parent) node.parent.removeChild(node); + added.push(node); + } + for (const node of added) node.parent = this; + this.children = [...added, ...this.children]; + } + + remove() { + if (this.parent) this.parent.removeChild(this); + } + replaceChildren(...nodes) { for (const child of this.children) child.parent = null; this.children = []; @@ -52,7 +85,7 @@ class TestNode { } querySelector(selector) { - if (selector.startsWith('.') && this.classes.has(selector.slice(1))) return this; + if ((selector.startsWith('.') && this.classes.has(selector.slice(1))) || this.tag === selector) return this; for (const child of this.children) { const match = child.querySelector(selector); if (match) return match; @@ -60,10 +93,26 @@ class TestNode { return null; } + querySelectorAll(selector) { + const matches = []; + for (const child of this.children) { + const classMatch = selector === '.file-change[open]' + ? child.classes.has('file-change') && child.open + : selector.startsWith('.') && child.classes.has(selector.slice(1)); + if (classMatch || child.tag === selector) matches.push(child); + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + addEventListener(name, listener) { + this.listeners.set(name, listener); + } + set textContent(value) { this.replaceChildren(new TestNode('#text', String(value))); } @@ -91,6 +140,7 @@ global.document = { let bottomAnchors; let socketConnections; let progressTimers; +let toasts; global.window = { clearInterval: (timer) => progressTimers.delete(timer), @@ -120,6 +170,7 @@ function resetHarness() { selected: null, currentView: null, sessionViews: new Map(), + socket: null, promptDrafts: new Map(), lastEventID: 0, assistantSegmentByTurn: new Map(), @@ -137,6 +188,13 @@ function resetHarness() { runtimeStatus: null, progressTimer: null, replayingHistory: false, + historyCursor: '', + historyLastEventID: 0, + historyPageLoading: false, + historyPageReading: false, + historyPageCursor: '', + historyPageEvents: [], + historyRequestID: '', runtimeRecoveryActive: false, pinHistoryToBottom: false, fileChangesDirty: false, @@ -144,11 +202,14 @@ function resetHarness() { bottomAnchors = 0; socketConnections = 0; progressTimers = new Map(); + toasts = []; } global.maxCachedSessionViews = 5; global.renderFileChanges = () => {}; global.renderDiffBlock = () => {}; +global.renderMessageMarkdown = (element, text) => { element.textContent = text || ''; }; +global.providerInitials = () => 'A'; global.savePromptDraft = () => {}; global.restorePromptDraft = () => {}; global.closeSocket = () => {}; @@ -159,12 +220,13 @@ global.resizeComposer = () => {}; global.scheduleBottomAnchor = () => { bottomAnchors++; }; global.connectSocket = () => { socketConnections++; }; global.updateComposerAction = () => {}; +global.updateFileChangesHeader = () => {}; global.endAssistantSegment = () => {}; global.acceptQueuedMessage = () => {}; global.renderInputRequest = () => {}; global.resolveInputCard = () => {}; global.scrollToBottom = () => {}; -global.showToast = () => {}; +global.showToast = (message) => { toasts.push(message); }; const application = fs.readFileSync(path.join(__dirname, '..', 'web', 'app.js'), 'utf8'); @@ -181,9 +243,11 @@ vm.runInThisContext(applicationSlice('function savePromptDraft', 'function provi vm.runInThisContext(applicationSlice('function parseSessionTimestamp', 'function safeHTTPURL'), {filename: 'app.js'}); vm.runInThisContext(applicationSlice('function selectSession', 'function renderHeader'), {filename: 'app.js'}); vm.runInThisContext(applicationSlice('function ensureConversation', 'function trimURLSuffix'), {filename: 'app.js'}); -vm.runInThisContext(applicationSlice('function finishHistoryReplay', 'function handleEvent'), {filename: 'app.js'}); +vm.runInThisContext(applicationSlice('function completedAssistantText', 'function handleEvent'), {filename: 'app.js'}); vm.runInThisContext(applicationSlice('function handleEvent', 'function renderUser'), {filename: 'app.js'}); +vm.runInThisContext(applicationSlice('function renderUser', 'function renderTool'), {filename: 'app.js'}); vm.runInThisContext(applicationSlice('function renderTool', 'function renderInputRequest'), {filename: 'app.js'}); +vm.runInThisContext(applicationSlice('function renderDiff', 'function setActiveView'), {filename: 'app.js'}); vm.runInThisContext(applicationSlice('function renderError', 'function scrollToBottom'), {filename: 'app.js'}); function testSessionViewSaveAndRestore() { @@ -195,17 +259,20 @@ function testSessionViewSaveAndRestore() { message.textContent = 'first conversation'; elements.messages.append(message); state.lastEventID = 7; + state.historyCursor = 'cursor-1'; state.tools.set('tool-1', {status: 'completed'}); saveCurrentSessionView(); assert.equal(elements.messages.hasChildNodes(), false); - assert.equal(view.messages.textContent, 'first conversation'); + assert.match(view.messages.textContent, /first conversation/); assert.equal(view.lastEventID, 7); + assert.equal(view.historyCursor, 'cursor-1'); activateSessionView(createSessionView()); activateSessionView(view); - assert.equal(elements.messages.textContent, 'first conversation'); + assert.match(elements.messages.textContent, /first conversation/); assert.equal(state.lastEventID, 7); + assert.equal(state.historyCursor, 'cursor-1'); assert.equal(state.tools.get('tool-1').status, 'completed'); } @@ -217,16 +284,19 @@ function testSessionViewReset() { activateSessionView(view); elements.messages.append(document.createTextNode('stale history')); state.lastEventID = 12; + state.historyCursor = 'cursor-1'; state.tools.set('tool-1', {}); resetCurrentSessionView(); assert.equal(elements.messages.hasChildNodes(), false); assert.equal(state.lastEventID, 0); + assert.equal(state.historyCursor, ''); assert.equal(state.tools.size, 0); assert.equal(state.replayingHistory, true); assert.equal(state.pinHistoryToBottom, true); assert.equal(view.historyLoaded, false); assert.equal(view.statusPlaceholder, false); + assert.equal(view.historyCursor, ''); } function testSessionProgressLifecycle() { @@ -419,6 +489,108 @@ function testHistoryReplayCompletion() { assert.equal(bottomAnchors, 1); } +function testProjectedHistoryRestoresStateAndReconnectHighWater() { + resetHarness(); + const view = createSessionView(); + view.historyLoaded = true; + view.historyCursor = 'stale-cursor'; + activateSessionView(view); + + const fileDiff = 'diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n-old\n+new'; + handleEvent({ + type: 'history.start', + journalId: 'journal-1', + lastEventId: 12, + historyLimited: true, + historyCursor: 'cursor-1', + }); + handleEvent({type: 'assistant.delta', id: 9, text: 'working'}); + handleEvent({ + type: 'history.end', + historyState: { + activeTurnId: 'turn-1', + activeTurnStarted: '2026-08-08T12:00:00Z', + waitingForInput: true, + turnInterrupting: true, + queuedTurns: [{turnId: 'turn-2', text: 'queued request'}], + fileDiff, + }, + }); + + assert.equal(state.lastEventID, 12); + assert.equal(view.lastEventID, 12); + assert.equal(state.historyCursor, 'cursor-1'); + assert.equal(view.historyCursor, 'cursor-1'); + assert.equal(state.activeTurn, true); + assert.equal(state.activeTurnID, 'turn-1'); + assert.equal(state.activeTurnStartedAt, Date.parse('2026-08-08T12:00:00Z')); + assert.equal(state.waitingForInput, true); + assert.equal(state.interrupting, true); + assert.equal(state.assistantTextByTurn.get('turn-1'), 'working'); + assert.equal(state.assistantTextByTurn.has('current'), false); + assert.equal(state.queuedMessages.get('turn-2').event.text, 'queued request'); + assert.equal(state.fileChanges.get('old.txt'), fileDiff); + assert.equal(elements.messages.querySelector('.history-page-control').textContent, 'Load earlier messages'); + + handleEvent({type: 'assistant.message', id: 13, turnId: 'turn-1', text: 'working done'}); + assert.equal(elements.messages.textContent.match(/working done/g).length, 1); +} + +function testOlderHistoryPageIsPrependedWithoutChangingLiveState() { + resetHarness(); + const view = createSessionView(); + view.historyLoaded = true; + view.historyCursor = 'cursor-1'; + view.lastEventID = 50; + activateSessionView(view); + state.activeTurn = true; + state.activeTurnID = 'turn-live'; + state.activeTurnStartedAt = Date.parse('2026-08-08T12:00:00Z'); + state.waitingForInput = true; + const sent = []; + state.socket = {readyState: 1, send: (payload) => sent.push(JSON.parse(payload))}; + const recent = document.createElement('article'); + recent.textContent = 'recent response'; + elements.messages.append(recent); + renderHistoryControl(); + + requestOlderHistory(); + requestOlderHistory(); + assert.equal(sent.length, 1); + assert.equal(sent[0].type, 'history'); + assert.equal(sent[0].historyCursor, 'cursor-1'); + assert.ok(sent[0].requestId); + + handleEvent({type: 'history.start', historyPage: true, requestId: sent[0].requestId, historyCursor: 'cursor-2'}); + elements.messages.scrollTop = 15; + const previousTop = elements.messages.scrollTop; + const previousHeight = elements.messages.scrollHeight; + handleEvent({type: 'user.message', id: 1, text: 'earlier request'}); + handleEvent({type: 'assistant.message', id: 2, text: 'earlier response'}); + handleEvent({type: 'turn.completed', id: 3, status: 'interrupted'}); + assert.doesNotMatch(elements.messages.textContent, /earlier response/); + handleEvent({type: 'history.end', historyPage: true, requestId: sent[0].requestId}); + + const text = elements.messages.textContent; + assert.ok(text.indexOf('earlier request') < text.indexOf('recent response')); + assert.ok(text.indexOf('earlier response') < text.indexOf('recent response')); + assert.equal(state.activeTurn, true); + assert.equal(state.activeTurnID, 'turn-live'); + assert.equal(state.activeTurnStartedAt, Date.parse('2026-08-08T12:00:00Z')); + assert.equal(state.waitingForInput, true); + assert.equal(state.interrupting, false); + assert.equal(state.lastEventID, 50); + assert.equal(state.historyCursor, 'cursor-2'); + assert.equal(state.historyPageLoading, false); + assert.deepEqual(toasts, []); + assert.ok(elements.messages.scrollHeight > previousHeight); + assert.equal(elements.messages.scrollTop, previousTop + elements.messages.scrollHeight - previousHeight); + + requestOlderHistory(); + assert.equal(sent.length, 2); + assert.equal(sent[1].historyCursor, 'cursor-2'); +} + function testReselectRefreshesStatusPlaceholder() { resetHarness(); const first = { @@ -551,6 +723,8 @@ testUntimestampedLiveTurnUsesLocalDuration(); testRuntimeRecoveryDividerOmitsDuration(); testSessionResetClearsPromptDraft(); testHistoryReplayCompletion(); +testProjectedHistoryRestoresStateAndReconnectHighWater(); +testOlderHistoryPageIsPrependedWithoutChangingLiveState(); testReselectRefreshesStatusPlaceholder(); testSessionTimestampFormatting(); testSessionTimestampElement(); diff --git a/internal/sessionserver/web/app.js b/internal/sessionserver/web/app.js index a5e847d6f..88284bc33 100644 --- a/internal/sessionserver/web/app.js +++ b/internal/sessionserver/web/app.js @@ -94,6 +94,13 @@ const state = { runtimeStatus: null, progressTimer: null, replayingHistory: false, + historyCursor: '', + historyLastEventID: 0, + historyPageLoading: false, + historyPageReading: false, + historyPageCursor: '', + historyPageEvents: [], + historyRequestID: '', runtimeRecoveryActive: false, pinHistoryToBottom: false, fileChangesDirty: false, @@ -116,6 +123,8 @@ const state = { const customOption = '__custom__'; const maxCachedSessionViews = 5; +const sessionHistoryItemLimit = 20; +const sessionHistoryByteLimit = 128 * 1024; const sectionOrderStoragePrefix = 'kelos-session-section-order:'; async function api(path, options = {}) { @@ -177,6 +186,7 @@ function createSessionView() { interrupting: false, runtimeStatus: null, replayingHistory: false, + historyCursor: '', runtimeRecoveryActive: false, pinHistoryToBottom: false, fileChangesDirty: false, @@ -206,6 +216,7 @@ function saveCurrentSessionView() { view.interrupting = state.interrupting; view.runtimeStatus = state.runtimeStatus; view.replayingHistory = state.replayingHistory; + view.historyCursor = state.historyCursor; view.runtimeRecoveryActive = state.runtimeRecoveryActive; view.pinHistoryToBottom = state.pinHistoryToBottom; view.fileChangesDirty = state.fileChangesDirty; @@ -234,6 +245,13 @@ function activateSessionView(view) { state.interrupting = view.interrupting; state.runtimeStatus = view.runtimeStatus; state.replayingHistory = view.replayingHistory; + state.historyCursor = view.historyCursor; + state.historyLastEventID = 0; + state.historyPageLoading = false; + state.historyPageReading = false; + state.historyPageCursor = ''; + state.historyPageEvents = []; + state.historyRequestID = ''; state.runtimeRecoveryActive = view.runtimeRecoveryActive; state.pinHistoryToBottom = view.pinHistoryToBottom; state.fileChangesDirty = view.fileChangesDirty; @@ -246,6 +264,7 @@ function activateSessionView(view) { if (!hasChanges) renderFileChanges(); refreshSessionProgress(); renderRuntimeStatus(); + renderHistoryControl(); } function cachedSessionView(session) { @@ -281,6 +300,13 @@ function resetCurrentSessionView() { state.interrupting = false; state.runtimeStatus = null; state.replayingHistory = true; + state.historyCursor = ''; + state.historyLastEventID = 0; + state.historyPageLoading = false; + state.historyPageReading = false; + state.historyPageCursor = ''; + state.historyPageEvents = []; + state.historyRequestID = ''; state.runtimeRecoveryActive = false; state.pinHistoryToBottom = true; state.fileChangesDirty = false; @@ -292,6 +318,8 @@ function resetCurrentSessionView() { view.historyLoaded = false; view.statusPlaceholder = false; view.lastEventID = 0; + view.journalID = ''; + view.historyCursor = ''; view.assistantSegmentByTurn = state.assistantSegmentByTurn; view.assistantTextByTurn = state.assistantTextByTurn; view.tools = state.tools; @@ -1554,6 +1582,7 @@ function closeSocket() { state.socket.close(); state.socket = null; } + cancelOlderHistoryPage(); setComposer(false); updateComposerAction(); } @@ -1574,15 +1603,19 @@ function connectSocket() { socket.addEventListener('open', () => { if (generation !== state.socketGeneration) return; state.reconnectDelay = 800; + const historyLoaded = Boolean(state.currentView?.historyLoaded); socket.send(JSON.stringify({ type: 'subscribe', - since: state.lastEventID, - journalId: state.currentView?.journalID || '', + since: historyLoaded ? state.lastEventID : 0, + journalId: historyLoaded ? state.currentView?.journalID || '' : '', historyBounds: true, + historyItems: sessionHistoryItemLimit, + historyBytes: sessionHistoryByteLimit, })); setConnection('connected', 'Connected'); setComposer(true); updateComposerAction(); + renderHistoryControl(); elements.input.focus(); }); socket.addEventListener('message', event => { @@ -1596,6 +1629,7 @@ function connectSocket() { socket.addEventListener('close', () => { if (generation !== state.socketGeneration || !state.selected) return; state.socket = null; + cancelOlderHistoryPage(); setConnection('error', 'Reconnecting'); setComposer(false); updateComposerAction(); @@ -2254,9 +2288,82 @@ function completedAssistantText(eventText, streamedText) { return eventText || streamedText || ''; } -function finishHistoryReplay() { - const pinToBottom = state.pinHistoryToBottom; - state.replayingHistory = false; +function sessionHistoryRequestID() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + return `history-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function renderHistoryControl() { + let control = elements.messages.querySelector('.history-page-control'); + if (!state.historyCursor && !state.historyPageLoading) { + if (control) control.remove(); + return; + } + if (!control) { + control = document.createElement('div'); + control.className = 'history-page-control'; + const button = document.createElement('button'); + button.type = 'button'; + button.addEventListener('click', requestOlderHistory); + control.append(button); + } + const button = control.querySelector('button'); + button.disabled = state.historyPageLoading || !state.socket || state.socket.readyState !== 1; + button.textContent = state.historyPageLoading ? 'Loading earlier messages…' : 'Load earlier messages'; + elements.messages.prepend(control); +} + +function cancelOlderHistoryPage() { + state.historyPageLoading = false; + state.historyPageReading = false; + state.historyPageCursor = ''; + state.historyPageEvents = []; + state.historyRequestID = ''; + renderHistoryControl(); +} + +function requestOlderHistory() { + if (state.historyPageLoading || !state.historyCursor || !state.socket || state.socket.readyState !== 1) return; + const requestID = sessionHistoryRequestID(); + state.historyPageLoading = true; + state.historyRequestID = requestID; + renderHistoryControl(); + try { + state.socket.send(JSON.stringify({type: 'history', requestId: requestID, historyCursor: state.historyCursor})); + } catch (error) { + cancelOlderHistoryPage(); + showToast(`Could not load earlier messages: ${error.message}`); + } +} + +function applyHistoryState(historyState) { + if (!historyState) return; + const activeTurnID = historyState.activeTurnId || ''; + if (activeTurnID) { + const projectedBubble = state.assistantSegmentByTurn.get('current'); + const projectedText = state.assistantTextByTurn.get('current'); + if (projectedBubble) state.assistantSegmentByTurn.set(activeTurnID, projectedBubble); + if (projectedText !== undefined) state.assistantTextByTurn.set(activeTurnID, projectedText); + state.assistantSegmentByTurn.delete('current'); + state.assistantTextByTurn.delete('current'); + } + state.activeTurn = Boolean(activeTurnID); + state.activeTurnID = activeTurnID; + const startedAt = Date.parse(historyState.activeTurnStarted || ''); + state.activeTurnStartedAt = Number.isNaN(startedAt) ? 0 : startedAt; + state.waitingForInput = Boolean(historyState.waitingForInput); + state.interrupting = Boolean(historyState.turnInterrupting); + if (historyState.fileDiff) updateFileChanges(parseFileDiffs(historyState.fileDiff)); + elements.queue.replaceChildren(); + state.queuedMessages = new Map(); + for (const turn of historyState.queuedTurns || []) { + renderQueuedUser({type: 'user.message', turnId: turn.turnId, text: turn.text}); + } + refreshSessionProgress(); + updateComposerAction(); +} + +function flushDeferredHistoryRendering() { if (state.fileChangesDirty) { state.fileChangesDirty = false; renderFileChanges(); @@ -2267,29 +2374,132 @@ function finishHistoryReplay() { block.dirty = false; block.openFirst = false; } +} + +function replayOlderHistoryPage(events) { + const messages = elements.messages; + const previousHeight = Number(messages.scrollHeight) || 0; + const previousTop = Number(messages.scrollTop) || 0; + const page = document.createElement('div'); + const live = { + activeTurn: state.activeTurn, + activeTurnID: state.activeTurnID, + activeTurnStartedAt: state.activeTurnStartedAt, + waitingForInput: state.waitingForInput, + interrupting: state.interrupting, + runtimeRecoveryActive: state.runtimeRecoveryActive, + replayingHistory: state.replayingHistory, + pinHistoryToBottom: state.pinHistoryToBottom, + lastEventID: state.lastEventID, + assistantSegmentByTurn: state.assistantSegmentByTurn, + assistantTextByTurn: state.assistantTextByTurn, + fileChanges: new Map(state.fileChanges), + }; + + elements.messages = page; + state.assistantSegmentByTurn = new Map(); + state.assistantTextByTurn = new Map(); + state.replayingHistory = true; + state.pinHistoryToBottom = false; + for (const event of events) handleEvent(event); + for (const [name, diff] of live.fileChanges) state.fileChanges.set(name, diff); + flushDeferredHistoryRendering(); + const rendered = moveChildren(page); + + elements.messages = messages; + state.activeTurn = live.activeTurn; + state.activeTurnID = live.activeTurnID; + state.activeTurnStartedAt = live.activeTurnStartedAt; + state.waitingForInput = live.waitingForInput; + state.interrupting = live.interrupting; + state.runtimeRecoveryActive = live.runtimeRecoveryActive; + state.replayingHistory = live.replayingHistory; + state.pinHistoryToBottom = live.pinHistoryToBottom; + state.lastEventID = live.lastEventID; + state.assistantSegmentByTurn = live.assistantSegmentByTurn; + state.assistantTextByTurn = live.assistantTextByTurn; + messages.prepend(rendered); + renderHistoryControl(); + const scrollBehavior = messages.style.scrollBehavior; + messages.style.scrollBehavior = 'auto'; + messages.scrollTop = Math.max(0, previousTop + (Number(messages.scrollHeight) || 0) - previousHeight); + messages.style.scrollBehavior = scrollBehavior; + refreshSessionProgress(); + updateComposerAction(); +} + +function finishOlderHistoryPage(event) { + if (event.requestId !== state.historyRequestID) return; + const events = state.historyPageEvents; + state.historyCursor = state.historyPageCursor; + state.historyPageLoading = false; + state.historyPageReading = false; + state.historyPageCursor = ''; + state.historyPageEvents = []; + state.historyRequestID = ''; + if (state.currentView) state.currentView.historyCursor = state.historyCursor; + replayOlderHistoryPage(events); +} + +function finishHistoryReplay(historyState) { + const pinToBottom = state.pinHistoryToBottom; + state.lastEventID = Math.max(state.lastEventID, state.historyLastEventID); + applyHistoryState(historyState); + state.replayingHistory = false; + flushDeferredHistoryRendering(); if (state.currentView) { state.currentView.historyLoaded = true; state.currentView.lastEventID = state.lastEventID; + state.currentView.historyCursor = state.historyCursor; } ensureConversation(); + renderHistoryControl(); if (pinToBottom) scheduleBottomAnchor(); state.pinHistoryToBottom = false; } function handleEvent(event) { + if (state.historyPageReading) { + if (event.type === 'history.end' && event.historyPage) { + finishOlderHistoryPage(event); + return; + } + if (event.type === 'error' && event.requestId === state.historyRequestID) { + cancelOlderHistoryPage(); + } else if (event.type === 'history.start' && !event.historyPage) { + cancelOlderHistoryPage(); + } else { + state.historyPageEvents.push(event); + return; + } + } if (event.id) state.lastEventID = Math.max(state.lastEventID, event.id); const recoveredCompletion = state.runtimeRecoveryActive && event.type === 'turn.completed' && event.status === 'interrupted'; if (state.runtimeRecoveryActive && !isRuntimeRecoveryEvent(event)) state.runtimeRecoveryActive = false; switch (event.type) { - case 'history.start': - if (event.reset || (state.currentView?.journalID && state.currentView.journalID !== event.journalId)) { + case 'history.start': { + if (event.historyPage) { + if (!state.historyPageLoading || event.requestId !== state.historyRequestID) return; + state.historyPageReading = true; + state.historyPageCursor = event.historyCursor || ''; + state.historyPageEvents = []; + renderHistoryControl(); + return; + } + const incompleteHistory = Boolean(state.currentView && !state.currentView.historyLoaded); + const replacedJournal = Boolean(state.currentView?.journalID && state.currentView.journalID !== event.journalId); + const replaceHistoryCursor = event.reset || replacedJournal || incompleteHistory || !state.currentView || state.lastEventID === 0; + if (event.reset || replacedJournal || incompleteHistory) { resetCurrentSessionView(); } if (state.currentView && event.journalId) state.currentView.journalID = event.journalId; + if (replaceHistoryCursor) state.historyCursor = event.historyCursor || ''; + state.historyLastEventID = event.lastEventId || 0; state.replayingHistory = true; break; + } case 'history.end': - finishHistoryReplay(); + if (!event.historyPage) finishHistoryReplay(event.historyState); break; case 'runtime.status': state.runtimeStatus = event.runtime || null; @@ -2357,6 +2567,7 @@ function handleEvent(event) { break; case 'error': endAssistantSegment(event.turnId); + if (event.requestId && event.requestId === state.historyRequestID) cancelOlderHistoryPage(); renderError(event); break; } @@ -2906,7 +3117,7 @@ function renderTurnEnd(event, recoveredCompletion = false) { acceptQueuedMessage(event.turnId); updateComposerAction(); refreshSessionProgress(); - if (event.status === 'interrupted') showToast('Active work interrupted'); + if (event.status === 'interrupted' && !state.replayingHistory) showToast('Active work interrupted'); const divider = document.createElement('div'); divider.className = 'turn-divider'; if (elapsed !== null) divider.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`; diff --git a/internal/sessionserver/web/styles.css b/internal/sessionserver/web/styles.css index 3a3d964c0..2ff7d4432 100644 --- a/internal/sessionserver/web/styles.css +++ b/internal/sessionserver/web/styles.css @@ -130,6 +130,10 @@ button { color: inherit; } .icon-button:disabled { opacity: .38; cursor: default; } .icon-button.danger:not(:disabled):hover { color: var(--danger); border-color: rgba(167,63,63,.3); background: #fff6f6; } .messages { min-height: 0; overflow-y: auto; padding: 35px max(24px, calc((100% - 850px) / 2)); scroll-behavior: smooth; } +.history-page-control { display: flex; justify-content: center; margin: -12px 0 24px; } +.history-page-control button { padding: 7px 12px; border: 1px solid var(--line); border-radius: 999px; background: var(--card); color: var(--muted); font-size: 10.5px; font-weight: 700; cursor: pointer; } +.history-page-control button:hover:not(:disabled) { border-color: #c5ccc6; color: var(--ink); } +.history-page-control button:disabled { cursor: default; opacity: .6; } .welcome { height: 100%; min-height: 410px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } .welcome-orbit { width: 72px; height: 72px; display: grid; place-items: center; border: 1px solid #ccd8cf; border-radius: 50%; background: linear-gradient(145deg,#fff,#e7eee8); box-shadow: var(--shadow); } .welcome-orbit span { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 14px; background: var(--accent); color: white; font: 700 18px/1 ui-monospace, monospace; }