feat: page console history across compaction

This commit is contained in:
2026-09-30 08:13:07 +09:00
parent cafbb73a22
commit 34ed988b75
38 changed files with 3107 additions and 144 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -149,6 +149,14 @@ entry_id: string,
*/
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
export type SessionConversationTurn = { turn_id: string, entries: Array<SessionSnapshotEntry>, };
export type SessionHistoryPage = { session_id: string,
/**
* Stable identity of the adopted active lineage used to fence stale pages.
*/
lineage_id: string, turns: Array<SessionConversationTurn>, next_cursor?: string | null, has_more: boolean, };
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, head_id: string | null, submissions: Array<PendingSubmissionSummary>, };
@@ -296,7 +304,11 @@ export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "req
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": {
/**
* Stable durable history identity. Older mixed-version producers omit it.
*/
entry_id?: string | null, segments: Array<Segment>, } } | { "event": "session_entry_committed", "data": { entry: SessionSnapshotEntry, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
/**
* The attempt that just failed. 1 origin.
*/
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -0,0 +1,166 @@
<script lang="ts">
import type { TurnNavigationItem } from "./turn-navigation";
type Props = {
items: TurnNavigationItem[];
hasMore: boolean;
loading: boolean;
error: string | null;
onLoadMore: () => void;
onTurnClick: (item: TurnNavigationItem) => void;
onTopEdgeChange: (atTop: boolean) => void;
historyAvailable?: boolean;
element?: HTMLElement | null;
};
let {
items,
hasMore,
loading,
error,
onLoadMore,
onTurnClick,
onTopEdgeChange,
historyAvailable = true,
element = $bindable(null),
}: Props = $props();
function handleScroll(event: Event) {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
onTopEdgeChange(target.scrollTop <= 1);
}
</script>
<aside class="turn-navigation" aria-label="Conversation turns">
<div class="turn-list" bind:this={element} onscroll={handleScroll}>
<div class="history-boundary" class:complete={!hasMore && !error && !loading}>
{#if !historyAvailable}
<span>Earlier history is unavailable for this view</span>
{:else if loading}
<button type="button" disabled>Loading earlier conversation…</button>
{:else if error}
<button type="button" onclick={onLoadMore}>Retry earlier conversation</button>
<span title={error}>History unavailable</span>
{:else if hasMore}
<button type="button" onclick={onLoadMore}>Earlier conversation available</button>
{:else}
<span>Start of conversation</span>
{/if}
</div>
{#each items as item (item.turnId)}
<button
type="button"
class="turn"
data-turn-id={item.turnId}
aria-label={`Jump to conversation: ${item.user}`}
onclick={() => onTurnClick(item)}
>
<span class="bar" aria-hidden="true"></span>
<span class="preview">
<span class="user-preview">{item.user}</span>
{#each item.assistant as line}
<span class="assistant-preview">{line}</span>
{/each}
</span>
</button>
{/each}
</div>
</aside>
<style>
.turn-navigation {
min-width: 0;
min-height: 0;
height: 100%;
border-left: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-raised) 82%, transparent);
}
.turn-list {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-2) var(--space-2) var(--space-4);
}
.history-boundary {
display: grid;
gap: 0.2rem;
justify-items: center;
min-height: 2.25rem;
color: var(--text-muted);
font-size: var(--font-size-compact);
text-align: center;
}
.history-boundary button {
border: 0;
background: transparent;
color: var(--tui-cyan);
cursor: pointer;
font: inherit;
padding: 0.25rem;
}
.history-boundary button:disabled {
cursor: wait;
opacity: 0.65;
}
.history-boundary.complete::before {
content: "";
width: 2rem;
border-top: 1px solid var(--line);
}
.turn {
display: grid;
width: 100%;
grid-template-columns: 1.6rem minmax(0, 1fr);
align-items: center;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0.42rem 0;
text-align: left;
}
.bar {
width: 1.1rem;
border-top: 3px solid var(--tui-blue);
border-radius: 999px;
justify-self: center;
}
.preview {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.user-preview,
.assistant-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-preview {
color: var(--text);
font-size: var(--font-size-compact);
}
.assistant-preview {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.turn:focus-visible,
.turn:hover {
background: color-mix(in srgb, var(--tui-blue) 9%, transparent);
outline: none;
}
</style>
@@ -0,0 +1,186 @@
import type {
SessionConversationTurn,
SessionHistoryPage,
SessionSnapshotEntry,
} from "$lib/generated/protocol";
import {
applyConsoleHistoryPage,
beginConsoleHistoryRequest,
type ConsoleHistoryState,
conversationTurnPreviews,
emptyConsoleHistoryState,
historyEntries,
setConsoleHistoryTopEdge,
shouldLoadHistoryAtTop,
} from "./history";
declare const Deno: { test(name: string, fn: () => void): void };
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
}
function message(
id: string,
role: "user" | "assistant",
text: string,
): SessionSnapshotEntry {
return {
entry_id: id,
timestamp: 1,
provenance: role === "user" ? "human_input" : "model_output",
kind: "message",
role,
content: [{ kind: "text", text }],
};
}
function turn(index: number): SessionConversationTurn {
return {
turn_id: `u-${index}`,
entries: [
message(`u-${index}`, "user", `question ${index}\nignored`),
message(`a-progress-${index}`, "assistant", `working ${index}`),
message(
`a-final-${index}`,
"assistant",
`done ${index}\ndetail a\ndetail b\ndetail c`,
),
],
};
}
function page(
turns: SessionConversationTurn[],
cursor: string | null,
): SessionHistoryPage {
return {
session_id: "session-a",
lineage_id: "lineage-a",
turns,
next_cursor: cursor,
has_more: cursor !== null,
};
}
Deno.test("history prepends 12 turns as 5 then 5 then 2 without duplicates", () => {
let state = emptyConsoleHistoryState();
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
null,
);
state = beginConsoleHistoryRequest(state, "cursor-8")!;
state = applyConsoleHistoryPage(
state,
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
"cursor-8",
);
state = beginConsoleHistoryRequest(state, "cursor-3")!;
state = applyConsoleHistoryPage(
state,
page([1, 2].map(turn), null),
"cursor-3",
);
assert(state.turns.length === 12, "all real turns must be retained");
assert(state.turns[0].turn_id === "u-1", "pages must remain chronological");
assert(
!state.hasMore && state.cursor === null,
"final short page must end history",
);
state = { ...state, requestedCursor: "__initial__", status: "loading" };
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
null,
);
assert(
state.turns.length === 12,
"refresh must dedupe stable turn identities",
);
assert(
historyEntries(state).length === 36,
"entries must dedupe by stable identity",
);
});
Deno.test("refresh replaces overlapping latest turns while retaining loaded earlier turns", () => {
let state = emptyConsoleHistoryState();
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
null,
);
state = beginConsoleHistoryRequest(state, "cursor-8")!;
state = applyConsoleHistoryPage(
state,
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
"cursor-8",
);
const updated = turn(12);
updated.entries.push(message("a-later-12", "assistant", "actually finished"));
state = beginConsoleHistoryRequest(state, null)!;
state = applyConsoleHistoryPage(
state,
page([8, 9, 10, 11].map(turn).concat(updated), "stale-cursor-is-ignored"),
null,
);
assert(
state.turns.length === 10,
"refresh must not discard loaded earlier turns",
);
assert(
state.turns.at(-1)?.entries.at(-1)?.entry_id === "a-later-12",
"refresh must replace an overlapping turn with its latest committed entries",
);
assert(
state.cursor === "cursor-3",
"refresh must retain the oldest paging boundary",
);
});
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
const preview = conversationTurnPreviews([turn(4)])[0];
assert(preview.user === "question 4", "user preview must use its first line");
assert(
preview.assistant.join("|") === "done 4|detail a|detail b",
"preview must choose the final assistant and cap it at three lines",
);
});
Deno.test("top sentinel requests only once until it leaves the edge", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
status: "ready",
cursor: "older",
hasMore: true,
};
assert(
shouldLoadHistoryAtTop(state, true),
"armed top edge should request a page",
);
state = beginConsoleHistoryRequest(state, "older")!;
assert(
!shouldLoadHistoryAtTop(state, true),
"in-flight page must suppress duplicates",
);
state = {
...state,
status: "ready",
requestedCursor: null,
};
assert(
!shouldLoadHistoryAtTop(state, true),
"stationary sentinel must not drain history",
);
state = setConsoleHistoryTopEdge(state, false);
assert(
shouldLoadHistoryAtTop(state, true),
"leaving the top must rearm one request",
);
});
@@ -0,0 +1,228 @@
import type {
SessionConversationTurn,
SessionHistoryPage,
SessionSnapshotEntry,
} from "$lib/generated/protocol";
export const CONSOLE_HISTORY_PAGE_TURNS = 5;
export type ConsoleHistoryStatus = "idle" | "loading" | "ready" | "error";
export type ConsoleHistoryState = {
sessionId: string | null;
lineageId: string | null;
turns: SessionConversationTurn[];
cursor: string | null;
hasMore: boolean;
status: ConsoleHistoryStatus;
error: string | null;
/** Cursor currently in flight; prevents duplicate sentinel requests. */
requestedCursor: string | null;
/** Rearmed only after leaving the top edge or an explicit user action. */
topEdgeArmed: boolean;
};
export type ConsoleTurnPreview = {
turnId: string;
lineId: string;
user: string;
assistant: string[];
};
export function emptyConsoleHistoryState(): ConsoleHistoryState {
return {
sessionId: null,
lineageId: null,
turns: [],
cursor: null,
hasMore: false,
status: "idle",
error: null,
requestedCursor: null,
topEdgeArmed: true,
};
}
export function beginConsoleHistoryRequest(
state: ConsoleHistoryState,
cursor: string | null,
): ConsoleHistoryState | null {
if (state.status === "loading" || state.requestedCursor !== null) return null;
if (cursor !== null && (!state.hasMore || cursor !== state.cursor)) {
return null;
}
return {
...state,
status: "loading",
error: null,
requestedCursor: cursor ?? "__initial__",
topEdgeArmed: cursor === null ? state.topEdgeArmed : false,
};
}
export function applyConsoleHistoryPage(
state: ConsoleHistoryState,
page: SessionHistoryPage,
requestedCursor: string | null,
): ConsoleHistoryState {
const requestKey = requestedCursor ?? "__initial__";
if (state.requestedCursor !== requestKey) return state;
const initial = requestedCursor === null;
if (
!initial &&
(state.sessionId !== page.session_id || state.lineageId !== page.lineage_id)
) {
return failConsoleHistoryRequest(
state,
"The conversation changed while earlier history was loading. Retry from the current view.",
);
}
const incoming = page.turns.filter((turn) => turn.entries.length > 0);
const refreshingSameSession = initial &&
state.sessionId === page.session_id &&
state.turns.length > 0;
const turns = initial
? refreshingSameSession
? mergeRefreshedTurns(state.turns, incoming)
: dedupeTurns(incoming)
: dedupeTurns([...incoming, ...state.turns]);
return {
...state,
sessionId: page.session_id,
lineageId: page.lineage_id,
turns,
cursor: refreshingSameSession ? state.cursor : (page.next_cursor ?? null),
hasMore: refreshingSameSession ? state.hasMore : page.has_more,
status: "ready",
error: null,
requestedCursor: null,
};
}
export function failConsoleHistoryRequest(
state: ConsoleHistoryState,
message: string,
): ConsoleHistoryState {
return {
...state,
status: "error",
error: message.slice(0, 512),
requestedCursor: null,
};
}
export function setConsoleHistoryTopEdge(
state: ConsoleHistoryState,
atTop: boolean,
): ConsoleHistoryState {
if (!atTop && !state.topEdgeArmed) return { ...state, topEdgeArmed: true };
return state;
}
export function shouldLoadHistoryAtTop(
state: ConsoleHistoryState,
atTop: boolean,
): boolean {
return atTop && state.topEdgeArmed && state.hasMore &&
state.status !== "loading" && state.requestedCursor === null;
}
export function historyEntries(
state: ConsoleHistoryState,
): SessionSnapshotEntry[] {
return state.turns.flatMap((turn) => turn.entries);
}
export function conversationTurnPreviews(
turns: readonly SessionConversationTurn[],
): ConsoleTurnPreview[] {
return turns.map((turn) => {
const userEntry = turn.entries.find(isUserEntry);
const assistants = turn.entries
.filter(isAssistantEntry)
.map(entryText)
.filter((text) => text.trim().length > 0);
const assistant = (assistants.at(-1) ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 3);
return {
turnId: turn.turn_id,
lineId: userEntry?.entry_id ?? turn.turn_id,
user: firstNonEmptyLine(userEntry ? entryText(userEntry) : ""),
assistant,
};
});
}
function mergeRefreshedTurns(
retained: readonly SessionConversationTurn[],
refreshed: readonly SessionConversationTurn[],
): SessionConversationTurn[] {
const refreshedIds = new Set(refreshed.map((turn) => turn.turn_id));
return dedupeTurns([
...retained.filter((turn) => !refreshedIds.has(turn.turn_id)),
...refreshed,
]);
}
function dedupeTurns(
turns: readonly SessionConversationTurn[],
): SessionConversationTurn[] {
const seenTurns = new Set<string>();
const seenEntries = new Set<string>();
const result: SessionConversationTurn[] = [];
for (const turn of turns) {
if (seenTurns.has(turn.turn_id)) continue;
const entries = turn.entries.filter((entry) => {
if (seenEntries.has(entry.entry_id)) return false;
seenEntries.add(entry.entry_id);
return true;
});
if (entries.length === 0) continue;
seenTurns.add(turn.turn_id);
result.push({ ...turn, entries });
}
return result;
}
function isUserEntry(entry: SessionSnapshotEntry): boolean {
return entry.kind === "user_input" ||
(entry.kind === "message" && entry.role === "user");
}
function isAssistantEntry(entry: SessionSnapshotEntry): boolean {
return entry.kind === "message" && entry.role === "assistant";
}
function entryText(entry: SessionSnapshotEntry): string {
switch (entry.kind) {
case "user_input":
return entry.segments.map((segment) => {
if (segment.kind === "text" || segment.kind === "paste") {
return segment.content;
}
if (segment.kind === "file_ref") return `@file ${segment.path}`;
if (segment.kind === "uploaded_file") {
return `[Attachment: ${segment.file.file_name}]`;
}
if (segment.kind === "paste_artifact") {
return `[Large paste ${segment.artifact.artifact_id}]`;
}
return "";
}).filter(Boolean).join("\n");
case "message":
return entry.content.map((part) =>
part.kind === "text" ? part.text : part.refusal
).join("\n");
default:
return "";
}
}
function firstNonEmptyLine(value: string): string {
return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "—";
}
@@ -2970,3 +2970,113 @@ Deno.test("new invoke resets stats before the next RunEnd", () => {
assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
});
Deno.test("committed assistant identity reconciles the live block without text matching", () => {
const lines = projectConsole([
{
eventId: "user-live",
event: {
event: "user_message",
data: {
entry_id: "user-entry",
segments: [{ kind: "text", content: "question" }],
},
},
},
{
eventId: "assistant-live",
event: { event: "text_done", data: { text: "final answer" } },
},
{
eventId: "assistant-commit",
event: {
event: "session_entry_committed",
data: {
entry: {
entry_id: "assistant-entry",
timestamp: 7,
provenance: "model_output",
kind: "message",
role: "assistant",
content: [{ kind: "text", text: "final answer" }],
},
},
},
},
]).lines;
assertEquals(lines.filter((line) => line.kind === "assistant").length, 1);
assertEquals(
lines.find((line) => line.kind === "assistant")?.entryId,
"assistant-entry",
);
assertEquals(
lines.find((line) => line.kind === "user")?.entryId,
"user-entry",
);
});
Deno.test("committed tool identities reconcile one live call block", () => {
const lines = projectConsole([
{
eventId: "tool-live",
event: {
event: "tool_call_done",
data: { id: "call-1", name: "Read", arguments: '{"file_path":"a"}' },
},
},
{
eventId: "tool-call-commit",
event: {
event: "session_entry_committed",
data: {
entry: {
entry_id: "tool-call-entry",
timestamp: 8,
provenance: "model_output",
kind: "tool_call",
call_id: "call-1",
name: "Read",
arguments: '{"file_path":"a"}',
},
},
},
},
{
eventId: "tool-result-live",
event: {
event: "tool_result",
data: {
id: "call-1",
summary: "read",
output: "contents",
is_error: false,
},
},
},
{
eventId: "tool-result-commit",
event: {
event: "session_entry_committed",
data: {
entry: {
entry_id: "tool-result-entry",
timestamp: 9,
provenance: "tool_output",
kind: "tool_result",
call_id: "call-1",
summary: "read",
content: "contents",
is_error: false,
},
},
},
},
]).lines;
assertEquals(
lines.filter((line) => line.toolCall?.id === "call-1").length,
1,
);
assertEquals(lines[0].entryId, "tool-result-entry");
assertEquals(lines[0].toolCall?.state, "done");
});
@@ -10,6 +10,7 @@ import type {
InternalWorkerRef,
InternalWorkerSnapshot,
Segment,
SessionSnapshotEntry,
WorkerState,
WorkerStateSnapshot,
WorkerStatus,
@@ -90,6 +91,8 @@ export type ConsoleLine = {
compaction?: ConsoleCompaction;
diff?: ConsoleDiffLine[];
eventId?: string | null;
/** Stable durable identity when this line originates from a committed entry. */
entryId?: string;
source: "event";
streaming?: boolean;
error?: boolean;
@@ -279,6 +282,30 @@ export function createConsoleProjector() {
};
}
export function mergeCommittedHistoryLines(
history: ConsoleLine[],
current: ConsoleLine[],
): ConsoleLine[] {
const committed = new Set(
history.map((line) => line.entryId).filter((id): id is string =>
Boolean(id)
),
);
return [
...history,
...current.filter((line) => !line.entryId || !committed.has(line.entryId)),
];
}
export function projectSessionHistoryEntries(
entries: SessionSnapshotEntry[],
cwd: string | null,
): ConsoleLine[] {
return projectVisibleConsole(
snapshotProjectionFromSession("history", { entries }, cwd),
).lines;
}
function projectVisibleConsole(
projection: ConsoleProjection,
): ConsoleProjection {
@@ -846,14 +873,19 @@ export function applyProtocolEvent(
switch (event.event) {
case "user_message":
next.lines.push(
line(
next.lines.push({
...line(
envelope.eventId,
"user",
"User",
segmentsToText(event.data.segments),
),
);
entryId: event.data.entry_id ?? undefined,
});
break;
case "session_entry_committed":
reconcileCommittedSessionEntry(next, event.data.entry);
applySessionEntry(next, envelope.eventId, event.data.entry);
break;
case "system_item":
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
@@ -1540,6 +1572,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
detail: calls.map(readDetail).join("\n\n"),
eventId: group.at(-1)?.eventId,
entryId: group.at(-1)?.entryId,
source: "event",
streaming: inProgress,
error: hasError,
@@ -2004,6 +2037,22 @@ function snapshotProjectionFromSession(
return projection;
}
function reconcileCommittedSessionEntry(
projection: ConsoleProjection,
entry: SessionSnapshotEntry,
): void {
if (entry.kind !== "message" || entry.role !== "assistant") return;
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
const line = projection.lines[index];
if (line.entryId) continue;
if (line.kind === "assistant" || line.kind === "in_flight") {
projection.lines.splice(index, 1);
return;
}
if (line.kind === "user") return;
}
}
function applySessionEntry(
projection: ConsoleProjection,
fallbackEventId: string,
@@ -2011,6 +2060,7 @@ function applySessionEntry(
): void {
if (!isRecord(value)) return;
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
const lineCountBefore = projection.lines.length;
switch (stringField(value, "kind")) {
case "user_input":
projection.lines.push(
@@ -2068,6 +2118,18 @@ function applySessionEntry(
default:
break;
}
const entryId = stringField(value, "entry_id");
if (entryId) {
for (
let index = Math.max(0, lineCountBefore - 1);
index < projection.lines.length;
index += 1
) {
if (projection.lines[index].eventId === eventId) {
projection.lines[index].entryId = entryId;
}
}
}
}
function applyLoggedUserInput(
@@ -0,0 +1,10 @@
import type { SessionConversationTurn } from "$lib/generated/protocol";
import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history";
export type TurnNavigationItem = ConsoleTurnPreview;
export function buildTurnNavigationItems(
turns: readonly SessionConversationTurn[],
): TurnNavigationItem[] {
return conversationTurnPreviews(turns);
}
@@ -720,7 +720,7 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
);
});
Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => {
Deno.test("Worker Console exposes paged conversation navigation beside the scroll body", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
@@ -730,28 +730,24 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
);
const consoleTimeline = await Deno.readTextFile(
new URL("./ConsoleTimeline.svelte", import.meta.url),
const turnNavigation = await Deno.readTextFile(
new URL("./ConsoleTurnNavigation.svelte", import.meta.url),
);
assert(
consoleTimeline.includes('class="console-timeline"') &&
consolePage.includes("timelineMarks") &&
consolePage.includes("jumpToTimelineMark") &&
turnNavigation.includes('class="turn-navigation"') &&
turnNavigation.includes('class="turn-list"') &&
turnNavigation.includes("data-turn-id={item.turnId}") &&
turnNavigation.includes('class="user-preview"') &&
turnNavigation.includes('class="assistant-preview"') &&
turnNavigation.includes("onTopEdgeChange(target.scrollTop <= 1)") &&
consolePage.includes("<ConsoleTurnNavigation") &&
consolePage.includes("items={turnNavigationItems}") &&
consolePage.includes("onTopEdgeChange={handleHistoryTopEdge}") &&
consolePage.includes("onTurnClick={jumpToConversationTurn}") &&
consolePage.includes('class="conversation-history-boundary"') &&
consoleLine.includes("data-console-line-id={item.id}") &&
consolePage.includes("class:timeline-open={timelineOpen}") &&
consolePage.includes('class="timeline-fold"') &&
consolePage.includes("expanded={timelineOpen}") &&
!consolePage.includes("{#if timelineOpen}") &&
consolePage.includes("handleTimelineRailPointerDown") &&
consolePage.includes("projectTimelineAxisPosition") &&
consolePage.includes("scrollbar-width: none") &&
consoleTimeline.includes("onpointerdown={onRailPointerDown}") &&
consoleTimeline.includes("expanded ? 'expanded' : 'folded'") &&
consoleTimeline.includes(".timeline-mark.expanded .timeline-card") &&
!consoleTimeline.includes("{#if expanded}") &&
consoleTimeline.includes(".timeline-thumb") &&
consoleTimeline.includes(".timeline-card"),
"Worker Console should expose a foldable timeline with scroll and line jump markers",
!consolePage.includes("<ConsoleTimeline"),
"Worker Console should expose bounded turn navigation with shared paging and transcript jump targets",
);
});
@@ -4,7 +4,7 @@
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import ConsoleTurnNavigation from "$lib/workspace/console/ConsoleTurnNavigation.svelte";
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
import {
@@ -28,7 +28,9 @@
consoleWorkerViews,
createConsoleProjector,
isConsoleProjectionEvent,
mergeCommittedHistoryLines,
projectConsoleLines,
projectSessionHistoryEntries,
resolveConsoleViewScrollTop,
resolveConsoleWorkerView,
selectConsoleTimelineLines,
@@ -44,6 +46,7 @@
PendingSubmissionsSnapshot,
RewindTarget,
Segment,
SessionHistoryPage,
} from "$lib/generated/protocol";
import {
MAX_FILES_PER_SUBMISSION,
@@ -69,6 +72,17 @@
type WorkerSessionRequestIdentity,
type WorkerSessionTarget,
} from "$lib/workspace/session-observation";
import {
applyConsoleHistoryPage,
beginConsoleHistoryRequest,
emptyConsoleHistoryState,
failConsoleHistoryRequest,
historyEntries,
setConsoleHistoryTopEdge,
shouldLoadHistoryAtTop,
type ConsoleHistoryState,
} from "$lib/workspace/console/history";
import { buildTurnNavigationItems, type TurnNavigationItem } from "$lib/workspace/console/turn-navigation";
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
type Props = {
@@ -200,6 +214,7 @@
let timelineOpen = $state(false);
let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null;
let turnNavigationElement = $state<HTMLElement | null>(null);
let composerInputElement = $state<
(SvelteComponent & ComposerInputHandle) | null
>(null);
@@ -222,6 +237,8 @@
let consoleProjection = $state.raw<ConsoleProjection>(
consoleProjector.snapshot(),
);
let historyByView = $state.raw<Record<string, ConsoleHistoryState>>({});
const pendingHistoryRefreshes = new Set<string>();
let pendingObservationEvents: ConsoleEventInput[] = [];
let pendingInitialSnapshotApplication: {
eventId: string;
@@ -251,9 +268,30 @@
),
);
const selectedConsoleProjection = $derived(selectedWorkerView.console);
const lines = $derived(
const selectedHistoryKey = $derived(
`${workspaceId}:${runtimeId ?? ""}:${workerId ?? ""}:${selectedWorkerViewSessionId ?? "main"}`,
);
const selectedHistory = $derived(
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
);
const currentLines = $derived(
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
);
const committedHistoryLines = $derived(
projectConsoleLines(
projectSessionHistoryEntries(
historyEntries(selectedHistory),
selectedConsoleProjection.cwd,
),
consoleViewMode,
),
);
const lines = $derived(
mergeCommittedHistoryLines(committedHistoryLines, currentLines),
);
const turnNavigationItems = $derived(
buildTurnNavigationItems(selectedHistory.turns),
);
const tasks = $derived(selectedConsoleProjection.tasks);
const timelineLayout = $derived(
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
@@ -309,6 +347,203 @@
return response.json() as Promise<T>;
}
type WorkerSessionHistoryResponse =
| { availability: "page"; page: SessionHistoryPage }
| {
availability: "unavailable";
reason: string;
message: string;
};
function mainHistoryKey(target: ConsoleTarget): string {
return `${target.workspaceId}:${target.runtimeId}:${target.workerId}:main`;
}
function historyStateFor(key: string): ConsoleHistoryState {
return historyByView[key] ?? emptyConsoleHistoryState();
}
function setHistoryState(key: string, state: ConsoleHistoryState) {
historyByView = { ...historyByView, [key]: state };
}
async function requestHistoryPage(
target: ConsoleTarget,
key: string,
cursor: string | null,
token: number,
) {
const started = beginConsoleHistoryRequest(historyStateFor(key), cursor);
if (!started) return;
setHistoryState(key, started);
const anchors = cursor === null ? null : captureHistoryAnchors();
const query = new URLSearchParams({ limit: "5" });
if (cursor) query.set("cursor", cursor);
const path = workerApiPath(
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session/history?${query}`,
);
try {
const response = await fetch(path, {
credentials: "same-origin",
headers: { accept: "application/json" },
});
if (response.status === 404 || response.status === 405) {
throw new Error("Earlier conversation is not supported by this Runtime version.");
}
if (!response.ok) {
throw new Error(`History request failed (${response.status}).`);
}
const payload = (await response.json()) as WorkerSessionHistoryResponse;
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
if (payload.availability === "unavailable") {
setHistoryState(
key,
failConsoleHistoryRequest(historyStateFor(key), payload.message),
);
return;
}
setHistoryState(
key,
applyConsoleHistoryPage(historyStateFor(key), payload.page, cursor),
);
await tick();
restoreHistoryAnchors(anchors);
} catch (error) {
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
setHistoryState(
key,
failConsoleHistoryRequest(
historyStateFor(key),
error instanceof Error ? error.message : String(error),
),
);
} finally {
const refreshAgain = pendingHistoryRefreshes.delete(key);
if (
refreshAgain &&
token === reloadToken &&
mainHistoryKey(target) === key
) {
void requestHistoryPage(target, key, null, token);
}
}
}
type HistoryAnchor = { id: string; top: number };
type HistoryAnchors = {
transcript: HistoryAnchor | null;
navigation: HistoryAnchor | null;
};
function captureElementAnchor(
container: HTMLElement | null,
selector: string,
dataKey: "consoleLineId" | "turnId",
): HistoryAnchor | null {
const first = container?.querySelector<HTMLElement>(selector);
const id = first?.dataset[dataKey] ?? "";
return first && id ? { id, top: first.getBoundingClientRect().top } : null;
}
function captureHistoryAnchors(): HistoryAnchors {
return {
transcript: captureElementAnchor(
consoleBodyElement,
"[data-console-line-id]",
"consoleLineId",
),
navigation: captureElementAnchor(
turnNavigationElement,
"[data-turn-id]",
"turnId",
),
};
}
function restoreElementAnchor(
container: HTMLElement | null,
selector: string,
anchor: HistoryAnchor | null,
) {
if (!anchor || !container) return;
const target = container.querySelector<HTMLElement>(selector);
if (!target) return;
container.scrollTop += target.getBoundingClientRect().top - anchor.top;
}
function restoreHistoryAnchors(anchors: HistoryAnchors | null) {
if (!anchors) return;
restoreElementAnchor(
consoleBodyElement,
`[data-console-line-id="${cssEscape(anchors.transcript?.id ?? "")}"]`,
anchors.transcript,
);
restoreElementAnchor(
turnNavigationElement,
`[data-turn-id="${cssEscape(anchors.navigation?.id ?? "")}"]`,
anchors.navigation,
);
updateConsoleScrollMetrics();
}
function loadEarlierHistory() {
const target = consoleTarget;
if (!target || selectedWorkerViewSessionId !== null) return;
const key = mainHistoryKey(target);
const state = historyStateFor(key);
if (!state.hasMore || !state.cursor) return;
void requestHistoryPage(target, key, state.cursor, reloadToken);
}
function retryHistoryLoad() {
const target = consoleTarget;
if (!target || selectedWorkerViewSessionId !== null) return;
const key = mainHistoryKey(target);
const state = historyStateFor(key);
void requestHistoryPage(
target,
key,
state.hasMore ? state.cursor : null,
reloadToken,
);
}
function refreshCurrentHistory() {
const target = consoleTarget;
if (!target) return;
const key = mainHistoryKey(target);
if (historyStateFor(key).status === "loading") {
pendingHistoryRefreshes.add(key);
return;
}
void requestHistoryPage(target, key, null, reloadToken);
}
function handleHistoryTopEdge(atTop: boolean) {
if (selectedWorkerViewSessionId !== null) return;
const key = selectedHistoryKey;
const state = historyStateFor(key);
if (shouldLoadHistoryAtTop(state, atTop)) {
loadEarlierHistory();
} else if (!atTop) {
const rearmed = setConsoleHistoryTopEdge(state, false);
if (rearmed !== state) setHistoryState(key, rearmed);
}
}
function jumpToConversationTurn(item: TurnNavigationItem) {
const line = lines.find((candidate) => candidate.entryId === item.lineId);
const target = line
? consoleBodyElement?.querySelector(
`[data-console-line-id="${cssEscape(line.id)}"]`,
)
: null;
if (target instanceof HTMLElement) {
target.scrollIntoView({ block: "start", behavior: "smooth" });
}
}
async function loadWorker(target: ConsoleTarget, token: number) {
workerError = null;
try {
@@ -489,6 +724,13 @@
} else if (payload.event === "pending_submissions_changed") {
pendingSubmissions = payload.data.pending;
}
if (
payload.event === "segment_rotated" ||
payload.event === "user_message" ||
payload.event === "session_entry_committed"
) {
refreshCurrentHistory();
}
if (payload.event === "error") {
queueObservationDiagnostic({
code: payload.data.code,
@@ -1093,6 +1335,9 @@
];
return;
}
untrack(() => {
void requestHistoryPage(target, mainHistoryKey(target), null, token);
});
if (
action.kind === "subscribe_live" &&
targetWorker &&
@@ -1769,6 +2014,7 @@
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
updateConsoleScrollMetrics();
rememberConsoleWorkerViewScroll();
handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1);
}
async function scrollConsoleToBottom() {
@@ -1988,19 +2234,7 @@
{/if}
<div class:with-task-pane={taskPaneOpen} class="console-history">
<section class:timeline-open={timelineOpen} class="console-body">
<div class="console-timeline-spacer" aria-hidden="true"></div>
<div class="timeline-fold-cell">
<button
type="button"
class="timeline-fold"
aria-expanded={timelineOpen}
aria-label={timelineOpen ? "Hide timeline" : "Show timeline"}
onclick={() => (timelineOpen = !timelineOpen)}
>
{timelineOpen ? "Timeline ◂" : "Timeline ▸"}
</button>
</div>
<section class="console-body">
<div
class="console-scroll"
bind:this={consoleBodyElement}
@@ -2014,6 +2248,24 @@
<p class="error">{workerError}</p>
{/if}
{#if selectedWorkerViewSessionId === null && (selectedHistory.hasMore || selectedHistory.error || selectedHistory.status === "loading")}
<div class="conversation-history-boundary">
<button
type="button"
disabled={selectedHistory.status === "loading"}
onclick={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
>
{selectedHistory.status === "loading"
? "Loading earlier conversation…"
: selectedHistory.error
? "Earlier conversation unavailable · retry"
: "Earlier conversation available · load 5 turns"}
</button>
</div>
{:else if selectedWorkerViewSessionId === null && selectedHistory.status === "ready"}
<div class="conversation-history-boundary complete">Start of conversation</div>
{/if}
<ConsoleDisplayStateView
state={consoleDisplayState}
hasContent={lines.length > 0}
@@ -2031,13 +2283,18 @@
</article>
</div>
<ConsoleTimeline
marks={timelineMarks}
thumbStyle={timelineThumb}
axisStyle={timelineAxisStyle}
expanded={timelineOpen}
onRailPointerDown={handleTimelineRailPointerDown}
onMarkClick={jumpToTimelineMark}
<ConsoleTurnNavigation
items={turnNavigationItems}
hasMore={selectedWorkerViewSessionId === null && selectedHistory.hasMore}
loading={selectedHistory.status === "loading"}
error={selectedWorkerViewSessionId === null
? selectedHistory.error
: "Earlier history is unavailable for direct SubWorker views."}
onLoadMore={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
onTopEdgeChange={handleHistoryTopEdge}
onTurnClick={jumpToConversationTurn}
historyAvailable={selectedWorkerViewSessionId === null}
bind:element={turnNavigationElement}
/>
</section>
@@ -2446,57 +2703,15 @@
}
.console-body {
--console-timeline-width: 12rem;
--console-timeline-fold-width: 2.25rem;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--console-timeline-fold-width);
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(10rem, 12rem);
gap: 0 var(--space-3);
min-height: 0;
overflow: hidden;
transition: grid-template-columns 160ms ease;
}
.console-body.timeline-open {
grid-template-columns: minmax(0, 1fr) var(--console-timeline-width);
}
.console-timeline-spacer {
grid-column: 1;
grid-row: 1;
}
.timeline-fold-cell {
grid-column: 2;
grid-row: 1;
display: flex;
justify-content: flex-start;
padding-bottom: var(--space-2);
}
.timeline-fold {
width: 100%;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
font-size: var(--font-size-compact);
font-weight: 800;
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
}
.timeline-fold:focus-visible {
border-color: var(--tui-cyan);
color: var(--text-strong);
}
.console-scroll {
grid-column: 1;
grid-row: 2;
height: 100%;
min-width: 0;
min-height: 0;
@@ -2510,6 +2725,36 @@
display: none;
}
.conversation-history-boundary {
display: flex;
justify-content: center;
color: var(--text-muted);
font-size: var(--font-size-compact);
padding-bottom: var(--space-3);
}
.conversation-history-boundary button {
border: 0;
background: transparent;
color: var(--tui-cyan);
cursor: pointer;
font: inherit;
}
.conversation-history-boundary button:disabled {
cursor: wait;
opacity: 0.65;
}
.conversation-history-boundary.complete::before,
.conversation-history-boundary.complete::after {
content: "";
align-self: center;
width: 2rem;
margin: 0 var(--space-2);
border-top: 1px solid var(--line);
}
.pending-submissions {
margin: 0 var(--space-3);
color: var(--muted);
@@ -106,6 +106,52 @@ function sessionWithUserMessage(content: string): SessionSnapshot {
};
}
function emptyHistoryPage() {
return {
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
turns: [],
next_cursor: null,
has_more: false,
},
};
}
function historyPage(indices: number[], cursor: string | null) {
return {
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
turns: indices.map((index) => ({
turn_id: `user-${index}`,
entries: [
{
entry_id: `user-${index}`,
timestamp: index,
provenance: "human_input",
kind: "message",
role: "user",
content: [{ kind: "text", text: `question ${index}` }],
},
{
entry_id: `assistant-${index}`,
timestamp: index,
provenance: "model_output",
kind: "message",
role: "assistant",
content: [{ kind: "text", text: `done ${index}` }],
},
],
})),
next_cursor: cursor,
has_more: cursor !== null,
},
};
}
function snapshotEvent(session = emptySession()): ProtocolEvent {
return {
event: "snapshot",
@@ -256,12 +302,21 @@ test("applies retained empty and nonempty snapshots before showing their read-on
});
test("shows bounded initial failures and retries explicitly", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(Response.json({
availability: "retained_snapshot",
snapshot: emptySession(),
}));
let sessionRequests = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).includes("/session/history")) {
return Promise.resolve(Response.json(emptyHistoryPage()));
}
sessionRequests += 1;
return Promise.resolve(
sessionRequests === 1
? new Response(null, { status: 503 })
: Response.json({
availability: "retained_snapshot",
snapshot: emptySession(),
}),
);
});
vi.stubGlobal("fetch", fetchMock);
render(ConsolePage, { data: pageData() });
@@ -271,7 +326,7 @@ test("shows bounded initial failures and retries explicitly", async () => {
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("No conversation to display")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(sessionRequests).toBe(2);
});
test("distinguishes typed unavailability and an initial live connection closure", async () => {
@@ -327,6 +382,9 @@ test("route changes clear prior content and fence stale Session responses", asyn
const current = deferred<Response>();
let firstWorkerLoads = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).includes("/session/history")) {
return Promise.resolve(Response.json(emptyHistoryPage()));
}
if (String(input).includes("worker-b")) {
return current.promise.then((value) => value.clone());
}
@@ -370,3 +428,185 @@ test("route changes clear prior content and fence stale Session responses", asyn
expect(screen.queryByText("previous worker content")).toBeNull();
expect(screen.getByText("current worker content")).not.toBeNull();
});
test("loads retained conversation turns five at a time into the log and turn bar", async () => {
const historyPages = [
{
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
turns: [8, 9, 10, 11, 12].map((index) => ({
turn_id: `user-${index}`,
entries: [
{
entry_id: `user-${index}`,
timestamp: index,
provenance: "human_input",
kind: "message",
role: "user",
content: [{ kind: "text", text: `question ${index}` }],
},
{
entry_id: `assistant-${index}`,
timestamp: index,
provenance: "model_output",
kind: "message",
role: "assistant",
content: [{ kind: "text", text: `done ${index}` }],
},
],
})),
next_cursor: "older-8",
has_more: true,
},
},
{
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
turns: [3, 4, 5, 6, 7].map((index) => ({
turn_id: `user-${index}`,
entries: [{
entry_id: `user-${index}`,
timestamp: index,
provenance: "human_input",
kind: "message",
role: "user",
content: [{ kind: "text", text: `question ${index}` }],
}],
})),
next_cursor: "older-3",
has_more: true,
},
},
historyPage([1, 2], null),
];
let historyRequests = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (String(input).includes("/session/history")) {
return Promise.resolve(Response.json(historyPages[historyRequests++]));
}
return Promise.resolve(Response.json({ availability: "live_protocol" }));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await waitFor(() =>
expect(screen.getAllByText("question 8")).toHaveLength(2)
);
expect(screen.getAllByText("done 12")).toHaveLength(2);
const transcriptScroller = screen.getByLabelText("main transcript")
.parentElement as HTMLElement;
transcriptScroller.scrollTop = 0;
await fireEvent.scroll(transcriptScroller);
await waitFor(() =>
expect(screen.getAllByText("question 3")).toHaveLength(2)
);
expect(historyRequests).toBe(2);
const navigation = screen.getByLabelText("Conversation turns");
const navigationList = navigation.firstElementChild as HTMLElement;
navigationList.scrollTop = 0;
await fireEvent.scroll(navigationList);
await fireEvent.scroll(navigationList);
transcriptScroller.scrollTop = 0;
await fireEvent.scroll(transcriptScroller);
await settleMicrotasks();
expect(historyRequests).toBe(2);
await fireEvent.click(
screen.getByRole("button", {
name: "Earlier conversation available · load 5 turns",
}),
);
await waitFor(() =>
expect(screen.getAllByText("question 1")).toHaveLength(2)
);
expect(screen.getAllByText("Start of conversation")).toHaveLength(2);
expect(historyRequests).toBe(3);
const scrollIntoView = vi.fn();
vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(
scrollIntoView,
);
await fireEvent.click(
screen.getByRole("button", {
name: "Jump to conversation: question 8",
}),
);
expect(scrollIntoView).toHaveBeenCalledOnce();
});
test("keeps live content when history fails and retries the bounded page explicitly", async () => {
let historyRequests = 0;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (String(input).includes("/session/history")) {
historyRequests += 1;
return Promise.resolve(
historyRequests === 1
? new Response(null, { status: 503 })
: Response.json(historyPage([1, 2, 3, 4], null)),
);
}
return Promise.resolve(Response.json({ availability: "live_protocol" }));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("current response remains")),
);
expect(await screen.findByText("current response remains")).not.toBeNull();
await fireEvent.click(
await screen.findByRole("button", {
name: "Earlier conversation unavailable · retry",
}),
);
await waitFor(() =>
expect(screen.getAllByText("question 1")).toHaveLength(2)
);
expect(historyRequests).toBe(2);
expect(screen.getByText("current response remains")).not.toBeNull();
expect(screen.getAllByText("Start of conversation")).toHaveLength(2);
});
test("fences delayed history pages when the selected Worker route changes", async () => {
const stale = deferred<Response>();
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const path = String(input);
if (path.includes("/session/history")) {
return path.includes("worker-b")
? Promise.resolve(Response.json(historyPage([21], null)))
: stale.promise;
}
return Promise.resolve(Response.json({
availability: "retained_snapshot",
snapshot: emptySession(),
}));
}),
);
const view = render(ConsolePage, { data: pageData() });
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() =>
expect(screen.getAllByText("question 21")).toHaveLength(2)
);
stale.resolve(Response.json(historyPage([99], null)));
await settleMicrotasks();
expect(screen.queryByText("question 99")).toBeNull();
expect(screen.getAllByText("question 21")).toHaveLength(2);
});