ui: simplify worker console

This commit is contained in:
Keisuke Hirata 2026-06-28 22:41:32 +09:00
parent 05d39e05c7
commit 0602efd3b7
No known key found for this signature in database
5 changed files with 129 additions and 163 deletions

View File

@ -694,67 +694,12 @@
color: var(--danger);
}
.transcript-card,
.console-card,
.composer-card {
display: grid;
gap: 1rem;
}
.transcript-list {
display: grid;
gap: 0.85rem;
list-style: none;
margin: 0;
padding: 0;
}
.transcript-item {
display: grid;
gap: 0.45rem;
padding: 0.85rem 1rem;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--bg-raised);
}
.transcript-item.user {
margin-left: min(8vw, 4rem);
background: var(--bg-raised);
border-color: var(--line-strong);
}
.transcript-item.assistant {
margin-right: min(8vw, 4rem);
background: var(--bg-raised);
border-color: var(--line-strong);
}
.message-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
color: var(--text-muted);
font-size: 0.78rem;
}
.message-meta strong {
color: var(--text-strong);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.message-meta time {
margin-left: auto;
}
.transcript-item p {
margin: 0;
white-space: pre-wrap;
line-height: 1.55;
color: var(--code);
}
.empty-state {
margin: 0;
padding: 1rem;
@ -843,7 +788,20 @@
}
.worker-console-shell {
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 100dvh;
padding-bottom: 0;
}
.worker-console-shell > .console-body {
flex: 1 1 auto;
min-height: 0;
}
.console-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
}
.console-status-pill {
@ -864,45 +822,45 @@
color: var(--warning);
}
.console-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 26rem);
.console-body {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-height: 0;
}
.transcript-toolbar {
.console-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.worker-transcript {
.console-log {
display: grid;
gap: var(--space-3);
max-height: min(68dvh, 50rem);
align-content: start;
gap: var(--space-2);
max-height: none;
min-height: 0;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.worker-transcript li {
.console-log li {
display: grid;
gap: var(--space-2);
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.75rem 0.85rem;
background: var(--bg);
gap: var(--space-1);
padding: 0.2rem 0;
background: transparent;
}
.worker-transcript li.error-line {
border-color: var(--danger);
.console-log li.error-line {
color: var(--danger);
}
.message-heading {
@ -910,8 +868,13 @@
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-strong);
font-weight: 800;
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 750;
}
.message-heading.streaming-heading {
justify-content: flex-start;
}
.message-heading small {
@ -922,7 +885,7 @@
text-transform: uppercase;
}
.worker-transcript pre {
.console-log pre {
max-width: 100%;
margin: 0;
overflow-x: auto;
@ -942,15 +905,31 @@
font-weight: 800;
}
.console-side-card {
align-content: start;
.console-side-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 5;
display: grid;
align-content: start;
gap: var(--space-4);
min-width: 0;
width: min(32rem, 100vw);
padding: var(--space-6);
overflow-y: auto;
border-left: 1px solid var(--line);
background: var(--bg);
}
.console-side-card dl,
.console-side-card ul {
.side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.console-side-panel dl,
.console-side-panel ul {
display: grid;
gap: var(--space-2);
margin: 0;
@ -958,7 +937,7 @@
list-style: none;
}
.console-side-card dt {
.console-side-panel dt {
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 800;
@ -966,20 +945,21 @@
text-transform: uppercase;
}
.console-side-card dd {
.console-side-panel dd {
margin: 0;
color: var(--text-strong);
font-weight: 700;
}
.console-composer {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
gap: var(--space-3);
}
.console-composer label {
color: var(--text-strong);
font-weight: 800;
margin-inline: calc(-1 * var(--space-6));
padding: var(--space-3) var(--space-6) var(--space-4);
background: var(--bg);
}
.console-composer textarea {
@ -999,10 +979,6 @@
}
@media (max-width: 960px) {
.console-grid {
grid-template-columns: 1fr;
}
.console-header {
flex-direction: column;
}

View File

@ -45,7 +45,7 @@ Deno.test("segmentsToText preserves protocol segment semantics", () => {
);
});
Deno.test("projectConsole keeps transcript and protocol-derived event rows distinct", () => {
Deno.test("projectConsole projects initial console output and live protocol rows", () => {
const projection = projectConsole(
[
{
@ -101,15 +101,15 @@ Deno.test("projectConsole keeps transcript and protocol-derived event rows disti
assert(
projection.lines.some((line) =>
line.source === "transcript" && line.kind === "user"
line.source === "initial" && line.kind === "user"
),
"transcript user row expected",
"initial user row expected",
);
assert(
projection.lines.some((line) =>
line.source === "event" && line.kind === "assistant"
line.source === "live" && line.kind === "assistant"
),
"assistant event row expected",
"assistant live row expected",
);
assert(
projection.lines.some((line) => line.kind === "thinking"),
@ -133,7 +133,7 @@ Deno.test("projectConsole keeps transcript and protocol-derived event rows disti
);
});
Deno.test("projectConsole displays snapshot and in-flight state", () => {
Deno.test("projectConsole uses snapshot for state without rendering it as console output", () => {
const projection = projectConsole([], [
{
cursor: "20",
@ -171,8 +171,8 @@ Deno.test("projectConsole displays snapshot and in-flight state", () => {
assert(projection.status === "running", "snapshot should update status");
assert(
projection.lines.some((line) => line.kind === "snapshot"),
"snapshot row expected",
!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,

View File

@ -13,7 +13,6 @@ export type ConsoleLineKind =
| "status"
| "error"
| "usage"
| "snapshot"
| "in_flight"
| "system";
@ -24,7 +23,7 @@ export type ConsoleLine = {
body: string;
detail?: string;
cursor?: string | null;
source: "transcript" | "event";
source: "initial" | "live";
streaming?: boolean;
error?: boolean;
};
@ -53,23 +52,22 @@ export function workerConsolePath(runtimeId: string, workerId: string): string {
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId });
}
export function transcriptLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
return items.map((item) => ({
id: `transcript-${item.event_id}-${item.sequence}`,
kind: transcriptRoleKind(item.role),
title: `${item.role} · transcript #${item.sequence}`,
id: `initial-${item.event_id}-${item.sequence}`,
kind: initialRoleKind(item.role),
title: item.role,
body: item.content,
detail: `event ${item.event_id}`,
source: "transcript",
source: "initial",
}));
}
export function projectConsole(
transcript: WorkerTranscriptItem[],
initialItems: WorkerTranscriptItem[],
events: Array<{ cursor: string; event: ProtocolEvent }> = [],
): ConsoleProjection {
return events.reduce(applyProtocolEvent, {
lines: transcriptLines(transcript),
lines: initialConsoleLines(initialItems),
status: null,
usage: null,
lastCursor: null,
@ -208,15 +206,6 @@ export function applyProtocolEvent(
break;
case "snapshot":
next.status = event.data.status;
next.lines.push(
line(
envelope.cursor,
"snapshot",
`snapshot · ${event.data.status}`,
`${event.data.entries.length} entries · ${event.data.greeting.provider} / ${event.data.greeting.model}`,
`${event.data.greeting.worker_name} · context ${event.data.greeting.context_tokens}/${event.data.greeting.context_window}`,
),
);
for (const block of event.data.in_flight?.blocks ?? []) {
next.lines.push(inFlightLine(envelope.cursor, block));
}
@ -443,7 +432,7 @@ export function segmentsToText(segments: Segment[]): string {
.join("\n");
}
function transcriptRoleKind(role: string): ConsoleLineKind {
function initialRoleKind(role: string): ConsoleLineKind {
if (role === "user" || role === "assistant" || role === "system") {
return role;
}
@ -466,7 +455,7 @@ function line(
body,
detail,
cursor,
source: "event",
source: "live",
streaming,
error,
};

View File

@ -38,6 +38,7 @@
let sendError = $state<string | null>(null);
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
let nextReloadToken = 0;
let reloadToken = $state(0);
@ -138,11 +139,6 @@
return nextReloadToken;
}
async function refreshConsole() {
advanceReloadToken();
await loadConsoleData(consoleTarget);
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
@ -283,23 +279,20 @@
<main class="shell console-shell worker-console-shell">
<section class="console-header card">
<div>
<p class="eyebrow">Worker attach Console</p>
<h2>{worker?.label ?? workerId}</h2>
<p class="section-note">
Target authority is <code>runtime_id</code> + <code>worker_id</code>. Browser traffic uses Workspace Backend Worker APIs only;
Runtime endpoints, credentials, socket paths, and session paths are not exposed.
</p>
</div>
<div class="console-header-actions">
<div class="console-status-pill" class:warn={streamState !== 'open'}>
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
</div>
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
Details
</button>
</div>
</section>
<section class="console-grid">
<article class="card transcript-card worker-transcript-card">
<header class="transcript-toolbar">
<div>
<h3>Transcript and protocol events</h3>
<section class="console-body">
<article class="card console-card worker-console-card">
{#if projection.status || projection.usage}
<p class="section-note">
{#if projection.status}status: {projection.status}{/if}
@ -307,9 +300,6 @@
{#if projection.usage}usage: {projection.usage}{/if}
</p>
{/if}
</div>
<button type="button" class="secondary-button" onclick={refreshConsole}>Refresh</button>
</header>
{#if workerError}
<p class="error">{workerError}</p>
@ -319,21 +309,26 @@
{/if}
{#if lines.length === 0}
<p>No transcript items or observation events are available for this Worker yet.</p>
<p>No console output is available for this Worker yet.</p>
{:else}
<ol class="transcript worker-transcript">
<ol class="console-log">
{#each lines as item}
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
{#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
<div class="message-heading">
<span>{item.title}</span>
<small>{item.source}{item.streaming ? ' · streaming' : ''}</small>
{#if item.streaming}<small>streaming</small>{/if}
</div>
{:else if item.streaming}
<div class="message-heading streaming-heading">
<small>streaming</small>
</div>
{/if}
<pre>{item.body || '—'}</pre>
{#if item.detail || item.cursor}
{#if item.detail}
<details class="message-detail">
<summary>metadata</summary>
{#if item.detail}<p>{item.detail}</p>{/if}
{#if item.cursor}<code>{item.cursor}</code>{/if}
<summary>detail</summary>
<p>{item.detail}</p>
</details>
{/if}
</li>
@ -342,8 +337,14 @@
{/if}
</article>
<aside class="console-side-card card">
</section>
{#if workerDetailsOpen}
<aside class="console-side-panel" aria-label="Worker detail">
<header class="side-panel-header">
<h3>Worker detail</h3>
<button type="button" class="secondary-button" onclick={() => workerDetailsOpen = false}>Close</button>
</header>
{#if worker}
<dl>
<div>
@ -398,14 +399,13 @@
</details>
{/if}
</aside>
</section>
{/if}
<form class="console-composer card" onsubmit={sendMessage}>
<label for="worker-console-message">Send user input</label>
<textarea
id="worker-console-message"
aria-label="Console input"
bind:value={draft}
placeholder={worker?.capabilities.can_accept_input ? 'Message this Worker through the Backend input API…' : 'Input is unsupported for this Worker'}
disabled={!worker?.capabilities.can_accept_input || sending}
></textarea>
<div class="composer-actions">

View File

@ -7,7 +7,8 @@ export default defineConfig({
proxy: {
'/api': {
target: 'http://127.0.0.1:8787',
changeOrigin: true
changeOrigin: true,
ws: true
}
}
}