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
11 changes: 9 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,15 @@ accepts prompt drafts while a selected Session is still
Pending and enables sending after the runtime connects. It shows connection
status separately from working, waiting-for-input,
and interrupting progress, including elapsed time for active work, and adds the
duration to its separator after a completed turn. Duration labels are omitted
from retained history that does not contain event timestamps and from turns
duration to its separator after a completed turn. The console shows the turn's
finish time before its duration in the browser's local time zone. Retained history
shows finish times without durations. Turns from earlier days also include the
date, with the year shown when it differs from the current year. Hover over,
focus, or tap the finish time for the full date, seconds, and time zone. Press
Escape to dismiss the details.
Failed and interrupted turns are labeled accordingly. Finish times are omitted
when the event has no timestamp or the turn was interrupted by runtime recovery.
Duration labels are also omitted from turns
interrupted by runtime recovery. Both clients use the same event stream and
provider conversation. Both clients can stream agent and tool activity, answer
user-input requests, and interrupt active work without ending the provider
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/session_terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,9 @@ func runSessionPlainTerminalWithWidth(ctx context.Context, input io.Reader, outp
if event.Status == "interrupted" {
write("%s\n", formatter.warning("Turn interrupted."))
}
if pageEvent {
continue
}
if elapsed, ok := sessionTerminalTurnElapsed(activeTurnID, activeTurnStarted, event, time.Now(), replayingHistory, recoveredCompletion); ok {
write("%s\n\n", formatter.turnSeparator(elapsed, terminalWidth()))
activeTurnID = ""
Expand Down
11 changes: 10 additions & 1 deletion internal/cli/session_terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,23 @@ func TestSessionPlainTerminalDoesNotRepeatStreamedToolOutput(t *testing.T) {
}

func TestSessionPlainTerminalIsolatesHistoryPagesFromLiveStream(t *testing.T) {
startedAt := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC)
earlierCompletedAt := startedAt.Add(-2 * time.Minute)
completedAt := startedAt.Add(5*time.Minute + 19*time.Second)
var events bytes.Buffer
encoder := json.NewEncoder(&events)
for _, event := range []sessionruntime.Event{
{Type: sessionruntime.EventHistoryStart, HistoryLimited: true, HistoryCursor: "cursor-1"},
{Type: sessionruntime.EventAssistantDelta, Text: "projected response"},
{Type: sessionruntime.EventHistoryEnd, HistoryState: &sessionruntime.HistoryState{ActiveTurnID: "turn-1"}},
{Type: sessionruntime.EventHistoryEnd, HistoryState: &sessionruntime.HistoryState{ActiveTurnID: "turn-1", ActiveTurnStarted: &startedAt}},
{Type: sessionruntime.EventAssistantMessage, Text: "projected response"},
{Type: sessionruntime.EventAssistantDelta, Text: "live response"},
{Type: sessionruntime.EventHistoryStart, RequestID: "history-1", HistoryPage: true},
{Type: sessionruntime.EventAssistantMessage, Text: "earlier response"},
{Type: sessionruntime.EventTurnCompleted, Status: "completed", Timestamp: &earlierCompletedAt},
{Type: sessionruntime.EventHistoryEnd, RequestID: "history-1", HistoryPage: true},
{Type: sessionruntime.EventAssistantMessage, Text: "live response"},
{Type: sessionruntime.EventTurnCompleted, TurnID: "turn-1", Status: "completed", Timestamp: &completedAt},
} {
if err := encoder.Encode(event); err != nil {
t.Fatal(err)
Expand All @@ -186,6 +191,10 @@ func TestSessionPlainTerminalIsolatesHistoryPagesFromLiveStream(t *testing.T) {
if !strings.Contains(got, "Earlier Session history is available. Use /history to load the previous page.") {
t.Fatalf("terminal output = %q, want limited history notice", got)
}
separator := formatSessionTurnSeparator(completedAt.Sub(startedAt), sessionTUIDefaultWidth)
if strings.Count(got, "Worked for") != 1 || !strings.Contains(got, separator) {
t.Fatalf("terminal output = %q, want only the live turn separator %q", got, separator)
}
}

func TestSessionPlainTerminalIgnoresDuplicateHistoryRequests(t *testing.T) {
Expand Down
49 changes: 48 additions & 1 deletion internal/consoleserver/frontend/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5048,6 +5048,9 @@ function replayOlderHistoryPage(events) {
elements.messages = page;
state.assistantSegmentByTurn = new Map();
state.assistantTextByTurn = new Map();
state.activeTurn = false;
state.activeTurnID = '';
state.activeTurnStartedAt = 0;
state.replayingHistory = true;
state.pinHistoryToBottom = false;
for (const event of events) handleEvent(event);
Expand Down Expand Up @@ -6313,6 +6316,16 @@ function renderRecovery(event) {
scrollToBottom();
}

function formatTurnFinishTime(date: Date, now = new Date(Date.now())) {
const time = new Intl.DateTimeFormat(undefined, {timeStyle: 'short'}).format(date);
if (date.toDateString() === now.toDateString()) return `at ${time}`;
const day = new Intl.DateTimeFormat(undefined, {
month: 'short', day: 'numeric',
...(date.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}),
}).format(date);
return `${day} at ${time}`;
}

function renderTurnEnd(event, recoveredCompletion = false) {
const completedAt = Date.parse(event.timestamp || '');
const matchingTurn = !event.turnId || !state.activeTurnID || event.turnId === state.activeTurnID;
Expand All @@ -6332,7 +6345,41 @@ function renderTurnEnd(event, recoveredCompletion = false) {
const divider = document.createElement('div');
divider.className = 'turn-divider';
if (event.notificationId) divider.dataset.notificationId = event.notificationId;
if (elapsed !== null) divider.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`;
const label = document.createElement('span');
label.className = 'turn-divider-label';
if (!Number.isNaN(completedAt) && !recoveredCompletion) {
const date = new Date(completedAt);
const status = event.status === 'failed' ? 'Failed' : event.status === 'interrupted' ? 'Interrupted' : 'Finished';
const exact = `${status} ${new Intl.DateTimeFormat(undefined, {dateStyle: 'full', timeStyle: 'long'}).format(date)}`;
const details = document.createElement('details');
details.className = 'turn-timestamp';
const summary = document.createElement('summary');
summary.setAttribute('aria-label', exact);
const timestamp = document.createElement('time');
timestamp.dateTime = date.toISOString();
timestamp.textContent = `${status} ${formatTurnFinishTime(date)}`;
summary.append(timestamp);
const full = document.createElement('span');
full.className = 'turn-timestamp-detail';
full.textContent = exact;
details.append(summary, full);
details.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
details.open = false;
details.dataset.dismissed = 'true';
});
details.addEventListener('pointerleave', () => { delete details.dataset.dismissed; });
details.addEventListener('focusout', () => { delete details.dataset.dismissed; });
label.append(details);
}
if (elapsed !== null) {
if (label.hasChildNodes()) label.append(document.createTextNode(' · '));
const duration = document.createElement('span');
duration.className = 'turn-duration';
duration.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`;
label.append(duration);
}
if (label.hasChildNodes()) divider.append(label);
elements.messages.append(divider);
scrollToBottom();
scheduleCompletionRead();
Expand Down
110 changes: 99 additions & 11 deletions internal/consoleserver/testdata/session_history_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1542,7 +1542,83 @@ function testTurnDividerShowsDuration() {
handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'completed', timestamp: '2026-08-08T12:05:19Z'});

const divider = elements.messages.querySelector('.turn-divider');
assert.equal(divider.textContent, 'Worked for 5m 19s');
const finished = formatTurnFinishTime(new Date('2026-08-08T12:05:19Z'));
assert.equal(divider.querySelector('summary').textContent, `Finished ${finished}`);
assert.equal(divider.querySelector('.turn-duration').textContent, 'Worked for 5m 19s');
const label = divider.querySelector('.turn-divider-label');
assert.equal(label.firstChild.tag, 'details');
assert.equal(label.children[1].textContent, ' · ');
assert.equal(label.lastChild.className, 'turn-duration');
}

function testTurnFinishTimeFormatting() {
const now = new Date(2026, 7, 8, 12, 6);
const today = new Date(2026, 7, 8, 12, 5, 19);
const yesterday = new Date(2026, 7, 7, 23, 59);
const lastYear = new Date(2025, 7, 8, 12, 5);
const shortTime = date => new Intl.DateTimeFormat(undefined, {timeStyle: 'short'}).format(date);
const shortDate = date => new Intl.DateTimeFormat(undefined, {month: 'short', day: 'numeric'}).format(date);
const fullDate = date => new Intl.DateTimeFormat(undefined, {month: 'short', day: 'numeric', year: 'numeric'}).format(date);
assert.equal(formatTurnFinishTime(today, now), `at ${shortTime(today)}`);
assert.equal(formatTurnFinishTime(yesterday, now), `${shortDate(yesterday)} at ${shortTime(yesterday)}`);
assert.equal(formatTurnFinishTime(lastYear, now), `${fullDate(lastYear)} at ${shortTime(lastYear)}`);
assert.equal(formatTurnFinishTime(yesterday, new Date(2026, 7, 8, 0, 0)), `${shortDate(yesterday)} at ${shortTime(yesterday)}`);
}

function testTurnDividerShowsFinishTimestamp() {
const timestamp = '2026-08-08T12:05:19Z';
const date = new Date(timestamp);
const visible = formatTurnFinishTime(date);
const exact = new Intl.DateTimeFormat(undefined, {dateStyle: 'full', timeStyle: 'long'}).format(date);
for (const replay of [false, true]) {
for (const [status, label] of [['completed', 'Finished'], ['failed', 'Failed'], ['interrupted', 'Interrupted']]) {
resetHarness();
if (replay) handleEvent({type: 'history.start'});
handleEvent({type: 'turn.completed', turnId: 'turn-1', status, timestamp, notificationId: 'completion-1'});

const divider = elements.messages.querySelector('.turn-divider');
const time = divider.querySelector('time');
assert.ok(time, `missing ${status} timestamp during ${replay ? 'replay' : 'live rendering'}`);
assert.equal(time.dateTime, date.toISOString());
const summary = divider.querySelector('summary');
assert.equal(summary.textContent, `${label} ${visible}`);
assert.equal(summary.attributes.get('aria-label'), `${label} ${exact}`);
assert.equal(divider.querySelector('.turn-timestamp-detail').textContent, `${label} ${exact}`);
assert.equal(divider.querySelector('.turn-duration'), null);
assert.equal(divider.dataset.notificationId, 'completion-1');
}
}
}

function testTurnTimestampDismissal() {
resetHarness();
handleEvent({type: 'turn.completed', status: 'completed', timestamp: '2026-08-08T12:05:19Z'});
const details = elements.messages.querySelector('.turn-timestamp');
details.open = true;
details.listeners.get('keydown')({key: 'Enter'});
assert.equal(details.open, true);
details.listeners.get('keydown')({key: 'Escape'});
assert.equal(details.open, false);
assert.equal(details.dataset.dismissed, 'true');
details.listeners.get('pointerleave')();
assert.equal(details.dataset.dismissed, undefined);
details.listeners.get('keydown')({key: 'Escape'});
details.listeners.get('focusout')();
assert.equal(details.dataset.dismissed, undefined);
}

function testTurnDividerOmitsUnknownFinishTimestamp() {
for (const replay of [false, true]) {
for (const timestamp of [undefined, 'invalid']) {
resetHarness();
if (replay) handleEvent({type: 'history.start'});
handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'completed', timestamp});

const divider = elements.messages.querySelector('.turn-divider');
assert.equal(divider.querySelector('time'), null);
assert.equal(divider.textContent, '');
}
}
}

function testUntimestampedHistoryDividerOmitsDuration() {
Expand Down Expand Up @@ -1573,15 +1649,18 @@ function testUntimestampedLiveTurnUsesLocalDuration() {
}

function testRuntimeRecoveryDividerOmitsDuration() {
resetHarness();
handleEvent({type: 'history.start'});
handleEvent({type: 'turn.started', turnId: 'turn-1', timestamp: '2026-08-08T12:00:00Z'});
handleEvent({type: 'runtime.recovered', text: 'Session runtime restarted'});
handleEvent({type: 'input.resolved', turnId: 'turn-1', inputId: 'input-1', status: 'cancelled'});
handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'interrupted', timestamp: '2026-08-08T13:00:00Z'});

const divider = elements.messages.querySelector('.turn-divider');
assert.equal(divider.textContent, '');
for (const replay of [false, true]) {
resetHarness();
if (replay) handleEvent({type: 'history.start'});
handleEvent({type: 'turn.started', turnId: 'turn-1', timestamp: '2026-08-08T12:00:00Z'});
handleEvent({type: 'runtime.recovered', text: 'Session runtime restarted'});
handleEvent({type: 'input.resolved', turnId: 'turn-1', inputId: 'input-1', status: 'cancelled'});
handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'interrupted', timestamp: '2026-08-08T13:00:00Z'});

const divider = elements.messages.querySelector('.turn-divider');
assert.equal(divider.textContent, '');
assert.equal(divider.querySelector('time'), null);
}
}

function testSessionResetClearsPromptDraft() {
Expand Down Expand Up @@ -1707,13 +1786,16 @@ function testOlderHistoryPageIsPrependedWithoutChangingLiveState() {
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'});
handleEvent({type: 'turn.completed', id: 3, status: 'interrupted', timestamp: '2026-08-08T11:58:00Z'});
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'));
const divider = elements.messages.querySelector('.turn-divider');
assert.equal(divider.querySelector('time').dateTime, '2026-08-08T11:58:00.000Z');
assert.equal(divider.querySelector('.turn-duration'), null);
assert.equal(state.activeTurn, true);
assert.equal(state.activeTurnID, 'turn-live');
assert.equal(state.activeTurnStartedAt, Date.parse('2026-08-08T12:00:00Z'));
Expand All @@ -1729,6 +1811,8 @@ function testOlderHistoryPageIsPrependedWithoutChangingLiveState() {
requestOlderHistory();
assert.equal(sent.length, 2);
assert.equal(sent[1].historyCursor, 'cursor-2');
handleEvent({type: 'turn.completed', turnId: 'turn-live', status: 'completed', timestamp: '2026-08-08T12:05:19Z'});
assert.equal(elements.messages.querySelector('.turn-duration').textContent, 'Worked for 5m 19s');
}

function testReselectRefreshesStatusPlaceholder() {
Expand Down Expand Up @@ -2430,6 +2514,10 @@ testSessionProgressElapsedFormatting();
testRuntimeStatusLifecycle();
testRuntimeStatusTreatsOmittedContextTokensAsZero();
testTurnDividerShowsDuration();
testTurnFinishTimeFormatting();
testTurnDividerShowsFinishTimestamp();
testTurnTimestampDismissal();
testTurnDividerOmitsUnknownFinishTimestamp();
testUntimestampedHistoryDividerOmitsDuration();
testUntimestampedLiveTurnUsesLocalDuration();
testRuntimeRecoveryDividerOmitsDuration();
Expand Down
53 changes: 51 additions & 2 deletions internal/consoleserver/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4757,6 +4757,9 @@ spec:
elements.messages = page;
state.assistantSegmentByTurn = new Map();
state.assistantTextByTurn = new Map();
state.activeTurn = false;
state.activeTurnID = '';
state.activeTurnStartedAt = 0;
state.replayingHistory = true;
state.pinHistoryToBottom = false;
for (const event of events)
Expand Down Expand Up @@ -6028,6 +6031,16 @@ spec:
elements.messages.append(card);
scrollToBottom();
}
function formatTurnFinishTime(date, now = new Date(Date.now())) {
const time = new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }).format(date);
if (date.toDateString() === now.toDateString())
return `at ${time}`;
const day = new Intl.DateTimeFormat(undefined, {
month: 'short', day: 'numeric',
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
}).format(date);
return `${day} at ${time}`;
}
function renderTurnEnd(event, recoveredCompletion = false) {
const completedAt = Date.parse(event.timestamp || '');
const matchingTurn = !event.turnId || !state.activeTurnID || event.turnId === state.activeTurnID;
Expand All @@ -6049,8 +6062,44 @@ spec:
divider.className = 'turn-divider';
if (event.notificationId)
divider.dataset.notificationId = event.notificationId;
if (elapsed !== null)
divider.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`;
const label = document.createElement('span');
label.className = 'turn-divider-label';
if (!Number.isNaN(completedAt) && !recoveredCompletion) {
const date = new Date(completedAt);
const status = event.status === 'failed' ? 'Failed' : event.status === 'interrupted' ? 'Interrupted' : 'Finished';
const exact = `${status} ${new Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(date)}`;
const details = document.createElement('details');
details.className = 'turn-timestamp';
const summary = document.createElement('summary');
summary.setAttribute('aria-label', exact);
const timestamp = document.createElement('time');
timestamp.dateTime = date.toISOString();
timestamp.textContent = `${status} ${formatTurnFinishTime(date)}`;
summary.append(timestamp);
const full = document.createElement('span');
full.className = 'turn-timestamp-detail';
full.textContent = exact;
details.append(summary, full);
details.addEventListener('keydown', event => {
if (event.key !== 'Escape')
return;
details.open = false;
details.dataset.dismissed = 'true';
});
details.addEventListener('pointerleave', () => { delete details.dataset.dismissed; });
details.addEventListener('focusout', () => { delete details.dataset.dismissed; });
label.append(details);
}
if (elapsed !== null) {
if (label.hasChildNodes())
label.append(document.createTextNode(' · '));
const duration = document.createElement('span');
duration.className = 'turn-duration';
duration.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`;
label.append(duration);
}
if (label.hasChildNodes())
divider.append(label);
elements.messages.append(divider);
scrollToBottom();
scheduleCompletionRead();
Expand Down
Loading
Loading