runtime: remove worker transcript projection
This commit is contained in:
@@ -967,13 +967,18 @@
|
||||
}
|
||||
|
||||
.worker-console-shell {
|
||||
min-height: 100dvh;
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: calc(100dvh - (var(--space-6) * 2));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.worker-console-shell > .console-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.console-header-actions {
|
||||
@@ -1018,12 +1023,10 @@
|
||||
.console-log {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-2);
|
||||
max-height: none;
|
||||
gap: var(--space-3);
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -1134,6 +1137,7 @@
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin-inline: calc(-1 * var(--space-6));
|
||||
|
||||
@@ -53,71 +53,64 @@ Deno.test("segmentsToText preserves protocol segment semantics", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole projects initial console output and live visible protocol rows", () => {
|
||||
const projection = projectConsole(
|
||||
[
|
||||
{
|
||||
sequence: 1,
|
||||
role: "user",
|
||||
content: "transcript input",
|
||||
event_id: 10,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
cursor: "11",
|
||||
event: {
|
||||
event: "text_delta",
|
||||
data: { text: "stream" },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "12",
|
||||
event: {
|
||||
event: "thinking_done",
|
||||
data: { text: "reasoning" },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "13",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "tool-1",
|
||||
summary: "read file",
|
||||
output: "content",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "14",
|
||||
event: {
|
||||
event: "usage",
|
||||
data: { input_tokens: 12, output_tokens: 5 },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "15",
|
||||
event: {
|
||||
event: "error",
|
||||
data: { code: "invalid_request", message: "bad frame" },
|
||||
} satisfies Event,
|
||||
},
|
||||
],
|
||||
);
|
||||
Deno.test("projectConsole projects visible protocol rows", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
cursor: "10",
|
||||
event: {
|
||||
event: "user_message",
|
||||
data: { segments: [{ kind: "text", content: "input" }] },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "11",
|
||||
event: {
|
||||
event: "text_delta",
|
||||
data: { text: "stream" },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "12",
|
||||
event: {
|
||||
event: "thinking_done",
|
||||
data: { text: "reasoning" },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "13",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "tool-1",
|
||||
summary: "read file",
|
||||
output: "content",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "14",
|
||||
event: {
|
||||
event: "usage",
|
||||
data: { input_tokens: 12, output_tokens: 5 },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "15",
|
||||
event: {
|
||||
event: "error",
|
||||
data: { code: "invalid_request", message: "bad frame" },
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assert(
|
||||
projection.lines.some((line) =>
|
||||
line.source === "initial" && line.kind === "user"
|
||||
),
|
||||
"initial user row expected",
|
||||
projection.lines.some((line) => line.source === "event" && line.kind === "user"),
|
||||
"user protocol row expected",
|
||||
);
|
||||
assert(
|
||||
projection.lines.some((line) =>
|
||||
line.source === "live" && line.kind === "assistant"
|
||||
),
|
||||
"assistant live row expected",
|
||||
projection.lines.some((line) => line.source === "event" && line.kind === "assistant"),
|
||||
"assistant protocol row expected",
|
||||
);
|
||||
assert(
|
||||
projection.lines.some((line) => line.kind === "thinking"),
|
||||
@@ -141,85 +134,25 @@ Deno.test("projectConsole projects initial console output and live visible proto
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole suppresses replayed live conversation rows already present in initial transcript", () => {
|
||||
const projection = projectConsole(
|
||||
[
|
||||
{
|
||||
sequence: 1,
|
||||
role: "user",
|
||||
content: "hello",
|
||||
event_id: 10,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
role: "assistant",
|
||||
content: "world",
|
||||
event_id: 11,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
cursor: "12",
|
||||
event: {
|
||||
event: "user_message",
|
||||
data: { segments: [{ kind: "text", content: "hello" }] },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "13",
|
||||
event: { event: "text_delta", data: { text: "wo" } } satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "14",
|
||||
event: { event: "text_delta", data: { text: "rld" } } satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "15",
|
||||
event: { event: "text_done", data: { text: "world" } } satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "16",
|
||||
event: { event: "status", data: { status: "idle" } } satisfies Event,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
projection.lines.map((line) => `${line.source}:${line.kind}:${line.body}`),
|
||||
["initial:user:hello", "initial:assistant:world"],
|
||||
);
|
||||
assertEquals(projection.status, "idle");
|
||||
});
|
||||
|
||||
Deno.test("projectConsole preserves live assistant stream not yet present in initial transcript", () => {
|
||||
const projection = projectConsole(
|
||||
[
|
||||
{
|
||||
sequence: 1,
|
||||
role: "user",
|
||||
content: "hello",
|
||||
event_id: 10,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
cursor: "13",
|
||||
event: { event: "text_delta", data: { text: "new" } } satisfies Event,
|
||||
},
|
||||
],
|
||||
);
|
||||
Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
cursor: "13",
|
||||
event: { event: "text_delta", data: { text: "new" } } satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assert(
|
||||
projection.lines.some((line) =>
|
||||
line.source === "live" && line.kind === "assistant" &&
|
||||
line.source === "event" && line.kind === "assistant" &&
|
||||
line.body === "new" && line.streaming
|
||||
),
|
||||
"live in-progress assistant stream should remain visible",
|
||||
"in-progress assistant stream should remain visible",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole keeps protocol lifecycle events out of the console surface", () => {
|
||||
const projection = projectConsole([], [
|
||||
const projection = projectConsole([
|
||||
{
|
||||
cursor: "30",
|
||||
event: { event: "status", data: { status: "running" } } satisfies Event,
|
||||
@@ -250,7 +183,7 @@ Deno.test("projectConsole keeps protocol lifecycle events out of the console sur
|
||||
});
|
||||
|
||||
Deno.test("projectConsole uses snapshot for state without rendering it as console output", () => {
|
||||
const projection = projectConsole([], [
|
||||
const projection = projectConsole([
|
||||
{
|
||||
cursor: "20",
|
||||
event: {
|
||||
@@ -270,14 +203,7 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol
|
||||
status: "running",
|
||||
in_flight: {
|
||||
blocks: [
|
||||
{ kind: "text", text: "unfinished answer", finished: false },
|
||||
{
|
||||
kind: "tool_call",
|
||||
id: "call-1",
|
||||
name: "Read",
|
||||
args: "{}",
|
||||
state: "streaming_args",
|
||||
},
|
||||
{ kind: "text", text: "partial" },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -285,13 +211,9 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol
|
||||
},
|
||||
]);
|
||||
|
||||
assert(projection.status === "running", "snapshot should update status");
|
||||
assert(
|
||||
!projection.lines.some((line) => line.title.includes("snapshot")),
|
||||
"snapshot should not render as a console row",
|
||||
);
|
||||
assert(
|
||||
projection.lines.filter((line) => line.kind === "in_flight").length === 2,
|
||||
"in-flight rows expected",
|
||||
assertEquals(projection.status, "running");
|
||||
assertEquals(
|
||||
projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`),
|
||||
["in_flight:partial:true"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
InFlightBlock,
|
||||
Segment,
|
||||
} from "$lib/generated/protocol";
|
||||
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
|
||||
import { workspaceRoute } from "$lib/workspace-api/http";
|
||||
|
||||
export type ConsoleLineKind =
|
||||
@@ -24,7 +23,7 @@ export type ConsoleLine = {
|
||||
body: string;
|
||||
detail?: string;
|
||||
cursor?: string | null;
|
||||
source: "initial" | "live";
|
||||
source: "event";
|
||||
streaming?: boolean;
|
||||
error?: boolean;
|
||||
};
|
||||
@@ -60,103 +59,17 @@ export function workerConsolePath(
|
||||
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId);
|
||||
}
|
||||
|
||||
export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
|
||||
return items.map((item) => ({
|
||||
id: `initial-${item.event_id}-${item.sequence}`,
|
||||
kind: initialRoleKind(item.role),
|
||||
title: item.role,
|
||||
body: item.content,
|
||||
source: "initial",
|
||||
}));
|
||||
}
|
||||
export type ConsoleEventInput = { cursor: string; event: ProtocolEvent };
|
||||
|
||||
export function projectConsole(
|
||||
initialItems: WorkerTranscriptItem[],
|
||||
events: Array<{ cursor: string; event: ProtocolEvent }> = [],
|
||||
): ConsoleProjection {
|
||||
const visibleEvents = dedupeInitialTranscriptReplay(initialItems, events);
|
||||
return visibleEvents.reduce(applyProtocolEvent, {
|
||||
lines: initialConsoleLines(initialItems),
|
||||
export function projectConsole(events: ConsoleEventInput[] = []): ConsoleProjection {
|
||||
return events.reduce(applyProtocolEvent, {
|
||||
lines: [],
|
||||
status: null,
|
||||
usage: null,
|
||||
lastCursor: null,
|
||||
});
|
||||
}
|
||||
|
||||
type EventEnvelope = { cursor: string; event: ProtocolEvent };
|
||||
|
||||
function dedupeInitialTranscriptReplay(
|
||||
initialItems: WorkerTranscriptItem[],
|
||||
events: EventEnvelope[],
|
||||
): EventEnvelope[] {
|
||||
const remainingInitial = new Map<string, number>();
|
||||
for (const item of initialItems) {
|
||||
if (item.role !== "user" && item.role !== "assistant") continue;
|
||||
const key = transcriptKey(item.role, item.content);
|
||||
remainingInitial.set(key, (remainingInitial.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const output: EventEnvelope[] = [];
|
||||
let pendingAssistant: EventEnvelope[] = [];
|
||||
let pendingAssistantText = "";
|
||||
|
||||
const flushPendingAssistant = () => {
|
||||
if (pendingAssistant.length === 0) return;
|
||||
output.push(...pendingAssistant);
|
||||
pendingAssistant = [];
|
||||
pendingAssistantText = "";
|
||||
};
|
||||
|
||||
for (const envelope of events) {
|
||||
const event = envelope.event;
|
||||
if (event.event === "user_message") {
|
||||
flushPendingAssistant();
|
||||
const body = segmentsToText(event.data.segments);
|
||||
if (consumeTranscriptKey(remainingInitial, "user", body)) continue;
|
||||
output.push(envelope);
|
||||
continue;
|
||||
}
|
||||
if (event.event === "text_delta") {
|
||||
pendingAssistant.push(envelope);
|
||||
pendingAssistantText += event.data.text;
|
||||
continue;
|
||||
}
|
||||
if (event.event === "text_done") {
|
||||
const body = event.data.text || pendingAssistantText;
|
||||
if (!consumeTranscriptKey(remainingInitial, "assistant", body)) {
|
||||
output.push(...pendingAssistant, envelope);
|
||||
}
|
||||
pendingAssistant = [];
|
||||
pendingAssistantText = "";
|
||||
continue;
|
||||
}
|
||||
flushPendingAssistant();
|
||||
output.push(envelope);
|
||||
}
|
||||
flushPendingAssistant();
|
||||
return output;
|
||||
}
|
||||
|
||||
function consumeTranscriptKey(
|
||||
remainingInitial: Map<string, number>,
|
||||
role: "user" | "assistant",
|
||||
body: string,
|
||||
): boolean {
|
||||
const key = transcriptKey(role, body);
|
||||
const remaining = remainingInitial.get(key) ?? 0;
|
||||
if (remaining <= 0) return false;
|
||||
if (remaining === 1) {
|
||||
remainingInitial.delete(key);
|
||||
} else {
|
||||
remainingInitial.set(key, remaining - 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function transcriptKey(role: "user" | "assistant", body: string): string {
|
||||
return `${role}\0${body}`;
|
||||
}
|
||||
|
||||
export function applyProtocolEvent(
|
||||
projection: ConsoleProjection,
|
||||
envelope: { cursor: string; event: ProtocolEvent },
|
||||
@@ -353,13 +266,6 @@ export function segmentsToText(segments: Segment[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function initialRoleKind(role: string): ConsoleLineKind {
|
||||
if (role === "user" || role === "assistant" || role === "system") {
|
||||
return role;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function line(
|
||||
cursor: string,
|
||||
kind: ConsoleLineKind,
|
||||
@@ -376,7 +282,7 @@ function line(
|
||||
body,
|
||||
detail,
|
||||
cursor,
|
||||
source: "live",
|
||||
source: "event",
|
||||
streaming,
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,35 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
consolePage.includes("seenObservationEventIds") &&
|
||||
consolePage.includes("rememberObservationEvent(frame.envelope.event_id)") &&
|
||||
consolePage.includes("projectConsole(observedEvents.map") &&
|
||||
!consolePage.includes("/transcript") &&
|
||||
!consolePage.includes("WorkerTranscriptProjection"),
|
||||
"Console should render protocol observation replay/live events directly and dedupe repeated frames by event id",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
||||
const editor = await Deno.readTextFile(
|
||||
new URL("../workspace-settings/DecodalSourceEditor.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
editor.includes("let view: EditorView | null = null") &&
|
||||
!editor.includes("$state<EditorView") &&
|
||||
editor.includes("untrack(() => value)") &&
|
||||
editor.includes("untrack(() => onChange)"),
|
||||
"CodeMirror EditorView must not be reactive state; otherwise mount cleanup can loop forever",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
|
||||
const sidebar = await Deno.readTextFile(
|
||||
new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||
@@ -138,9 +167,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||
"Worker detail should use the scoped backend Worker detail API",
|
||||
);
|
||||
assert(
|
||||
consolePage.includes("/transcript?limit=200") &&
|
||||
!consolePage.includes("/transcript") &&
|
||||
consolePage.includes("/events/ws") && consolePage.includes("/input"),
|
||||
"Console should use bounded transcript, observation WS, and input APIs",
|
||||
"Console should use protocol observation WS and input APIs without a transcript API",
|
||||
);
|
||||
assert(
|
||||
!consolePage.includes("/api/companion"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||
import { decodal } from 'decodal-codemirror';
|
||||
@@ -16,7 +17,7 @@
|
||||
} = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let view = $state<EditorView | null>(null);
|
||||
let view: EditorView | null = null;
|
||||
|
||||
const theme = EditorView.theme({
|
||||
'&': {
|
||||
@@ -35,20 +36,23 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!host || view) return;
|
||||
const initialValue = untrack(() => value);
|
||||
const initialReadonly = untrack(() => readonly);
|
||||
const handleChange = untrack(() => onChange);
|
||||
const editor = new EditorView({
|
||||
parent: host,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
doc: initialValue,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
decodal(),
|
||||
keymap.of([]),
|
||||
EditorState.readOnly.of(readonly),
|
||||
EditorView.editable.of(!readonly),
|
||||
EditorState.readOnly.of(initialReadonly),
|
||||
EditorView.editable.of(!initialReadonly),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChange(update.state.doc.toString());
|
||||
if (update.docChanged) handleChange(update.state.doc.toString());
|
||||
}),
|
||||
theme,
|
||||
],
|
||||
|
||||
@@ -181,30 +181,10 @@ export type WorkerInputResult = {
|
||||
state: WorkerOperationState;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
transcript_sequence?: number | null;
|
||||
event_id?: number | null;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkerTranscriptItem = {
|
||||
sequence: number;
|
||||
role: "user" | "assistant" | "system" | string;
|
||||
content: string;
|
||||
event_id: number;
|
||||
};
|
||||
|
||||
export type WorkerTranscriptProjection = {
|
||||
state: WorkerOperationState;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
start: number;
|
||||
limit: number;
|
||||
total_items: number;
|
||||
next_start?: number | null;
|
||||
items: WorkerTranscriptItem[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ClientWorkerEventWsEnvelope = {
|
||||
cursor: string;
|
||||
event_id: string;
|
||||
|
||||
+60
-30
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import {
|
||||
projectConsole,
|
||||
type ConsoleLine
|
||||
@@ -8,8 +9,7 @@
|
||||
ClientWorkerEventWsFrame,
|
||||
Diagnostic,
|
||||
Worker,
|
||||
WorkerInputResult,
|
||||
WorkerTranscriptProjection
|
||||
WorkerInputResult
|
||||
} from '$lib/workspace-sidebar/types';
|
||||
|
||||
type Props = {
|
||||
@@ -32,15 +32,17 @@
|
||||
|
||||
let worker = $state<Worker | null>(null);
|
||||
let workerError = $state<string | null>(null);
|
||||
let transcript = $state<WorkerTranscriptProjection | null>(null);
|
||||
let transcriptError = $state<string | null>(null);
|
||||
let draft = $state('');
|
||||
let sending = $state(false);
|
||||
let sendError = $state<string | null>(null);
|
||||
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
|
||||
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||
let workerDetailsOpen = $state(false);
|
||||
let consoleBodyElement: HTMLElement | null = null;
|
||||
let autoFollowConsole = $state(true);
|
||||
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
|
||||
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
|
||||
let seenObservationEventIds = new Set<string>();
|
||||
let nextReloadToken = 0;
|
||||
let reloadToken = $state(0);
|
||||
|
||||
@@ -52,15 +54,10 @@
|
||||
const consoleTarget = $derived({ runtimeId, workerId });
|
||||
|
||||
const projection = $derived(
|
||||
projectConsole(
|
||||
transcript?.items ?? [],
|
||||
observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))
|
||||
)
|
||||
projectConsole(observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload })))
|
||||
);
|
||||
const lines = $derived(projection.lines);
|
||||
const diagnostics = $derived(
|
||||
mergeDiagnostics(worker?.diagnostics ?? [], transcript?.diagnostics ?? [], streamDiagnostics)
|
||||
);
|
||||
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
|
||||
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
@@ -108,20 +105,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTranscript(target: ConsoleTarget) {
|
||||
transcriptError = null;
|
||||
try {
|
||||
transcript = await getJson<WorkerTranscriptProjection>(
|
||||
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`)
|
||||
);
|
||||
} catch (error) {
|
||||
transcriptError = error instanceof Error ? error.message : String(error);
|
||||
transcript = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConsoleData(target: ConsoleTarget) {
|
||||
await Promise.all([loadWorker(target), loadTranscript(target)]);
|
||||
await loadWorker(target);
|
||||
}
|
||||
|
||||
function advanceReloadToken(): number {
|
||||
@@ -130,6 +115,19 @@
|
||||
return nextReloadToken;
|
||||
}
|
||||
|
||||
function resetObservedEvents() {
|
||||
observedEvents = [];
|
||||
seenObservationEventIds = new Set();
|
||||
}
|
||||
|
||||
function rememberObservationEvent(eventId: string): boolean {
|
||||
if (seenObservationEventIds.has(eventId)) {
|
||||
return false;
|
||||
}
|
||||
seenObservationEventIds.add(eventId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendMessage(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const content = draft.trim();
|
||||
@@ -149,7 +147,6 @@
|
||||
} else {
|
||||
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
||||
}
|
||||
await loadTranscript(consoleTarget);
|
||||
} catch (error) {
|
||||
sendError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
@@ -181,6 +178,9 @@
|
||||
try {
|
||||
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
||||
if (frame.kind === 'event') {
|
||||
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
||||
return;
|
||||
}
|
||||
observedEvents = [
|
||||
...observedEvents,
|
||||
{
|
||||
@@ -243,9 +243,42 @@
|
||||
return line.error ? 'error' : line.kind;
|
||||
}
|
||||
|
||||
function isNearConsoleBottom(element: HTMLElement): boolean {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
function handleConsoleScroll() {
|
||||
if (!consoleBodyElement) {
|
||||
return;
|
||||
}
|
||||
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
|
||||
}
|
||||
|
||||
async function scrollConsoleToBottom() {
|
||||
await tick();
|
||||
if (!consoleBodyElement) {
|
||||
return;
|
||||
}
|
||||
consoleBodyElement.scrollTop = consoleBodyElement.scrollHeight;
|
||||
autoFollowConsole = true;
|
||||
}
|
||||
|
||||
const scrollFollowKey = $derived(
|
||||
lines
|
||||
.map((line) => `${line.source}:${line.kind}:${line.body.length}:${line.streaming ? 'streaming' : 'done'}`)
|
||||
.join('|')
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
scrollFollowKey;
|
||||
if (autoFollowConsole) {
|
||||
void scrollConsoleToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const target = consoleTarget;
|
||||
observedEvents = [];
|
||||
resetObservedEvents();
|
||||
streamDiagnostics = [];
|
||||
advanceReloadToken();
|
||||
void loadConsoleData(target);
|
||||
@@ -274,7 +307,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="console-body">
|
||||
<section class="console-body" bind:this={consoleBodyElement} onscroll={handleConsoleScroll}>
|
||||
<article class="card console-card worker-console-card">
|
||||
{#if projection.status || projection.usage}
|
||||
<p class="section-note">
|
||||
@@ -287,9 +320,6 @@
|
||||
{#if workerError}
|
||||
<p class="error">{workerError}</p>
|
||||
{/if}
|
||||
{#if transcriptError}
|
||||
<p class="error">{transcriptError}</p>
|
||||
{/if}
|
||||
|
||||
{#if lines.length === 0}
|
||||
<p>No console output is available for this Worker yet.</p>
|
||||
|
||||
Reference in New Issue
Block a user