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); color: var(--danger);
} }
.transcript-card, .console-card,
.composer-card { .composer-card {
display: grid; display: grid;
gap: 1rem; 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 { .empty-state {
margin: 0; margin: 0;
padding: 1rem; padding: 1rem;
@ -843,7 +788,20 @@
} }
.worker-console-shell { .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 { .console-status-pill {
@ -864,45 +822,45 @@
color: var(--warning); color: var(--warning);
} }
.console-grid { .console-body {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 26rem); flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
min-height: 0; min-height: 0;
} }
.transcript-toolbar { .console-toolbar {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
} }
.worker-transcript { .console-log {
display: grid; display: grid;
gap: var(--space-3); align-content: start;
max-height: min(68dvh, 50rem); gap: var(--space-2);
max-height: none;
min-height: 0;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-y: auto; overflow-y: auto;
list-style: none; list-style: none;
} }
.worker-transcript li { .console-log li {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-1);
border: 1px solid var(--line); padding: 0.2rem 0;
border-radius: 14px; background: transparent;
padding: 0.75rem 0.85rem;
background: var(--bg);
} }
.worker-transcript li.error-line { .console-log li.error-line {
border-color: var(--danger); color: var(--danger);
} }
.message-heading { .message-heading {
@ -910,8 +868,13 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-2); gap: var(--space-2);
color: var(--text-strong); color: var(--text-muted);
font-weight: 800; font-size: 0.78rem;
font-weight: 750;
}
.message-heading.streaming-heading {
justify-content: flex-start;
} }
.message-heading small { .message-heading small {
@ -922,7 +885,7 @@
text-transform: uppercase; text-transform: uppercase;
} }
.worker-transcript pre { .console-log pre {
max-width: 100%; max-width: 100%;
margin: 0; margin: 0;
overflow-x: auto; overflow-x: auto;
@ -942,15 +905,31 @@
font-weight: 800; font-weight: 800;
} }
.console-side-card { .console-side-panel {
align-content: start; position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 5;
display: grid; display: grid;
align-content: start;
gap: var(--space-4); 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, .side-panel-header {
.console-side-card ul { display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.console-side-panel dl,
.console-side-panel ul {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
margin: 0; margin: 0;
@ -958,7 +937,7 @@
list-style: none; list-style: none;
} }
.console-side-card dt { .console-side-panel dt {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 800; font-weight: 800;
@ -966,20 +945,21 @@
text-transform: uppercase; text-transform: uppercase;
} }
.console-side-card dd { .console-side-panel dd {
margin: 0; margin: 0;
color: var(--text-strong); color: var(--text-strong);
font-weight: 700; font-weight: 700;
} }
.console-composer { .console-composer {
position: sticky;
bottom: 0;
z-index: 2;
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
} margin-inline: calc(-1 * var(--space-6));
padding: var(--space-3) var(--space-6) var(--space-4);
.console-composer label { background: var(--bg);
color: var(--text-strong);
font-weight: 800;
} }
.console-composer textarea { .console-composer textarea {
@ -999,10 +979,6 @@
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.console-grid {
grid-template-columns: 1fr;
}
.console-header { .console-header {
flex-direction: column; 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( const projection = projectConsole(
[ [
{ {
@ -101,15 +101,15 @@ Deno.test("projectConsole keeps transcript and protocol-derived event rows disti
assert( assert(
projection.lines.some((line) => 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( assert(
projection.lines.some((line) => 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( assert(
projection.lines.some((line) => line.kind === "thinking"), 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([], [ const projection = projectConsole([], [
{ {
cursor: "20", 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.status === "running", "snapshot should update status");
assert( assert(
projection.lines.some((line) => line.kind === "snapshot"), !projection.lines.some((line) => line.title.includes("snapshot")),
"snapshot row expected", "snapshot should not render as a console row",
); );
assert( assert(
projection.lines.filter((line) => line.kind === "in_flight").length === 2, projection.lines.filter((line) => line.kind === "in_flight").length === 2,

View File

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

View File

@ -38,6 +38,7 @@
let sendError = $state<string | null>(null); let sendError = $state<string | null>(null);
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting'); let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
let streamDiagnostics = $state<Diagnostic[]>([]); let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]); let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
let nextReloadToken = 0; let nextReloadToken = 0;
let reloadToken = $state(0); let reloadToken = $state(0);
@ -138,11 +139,6 @@
return nextReloadToken; return nextReloadToken;
} }
async function refreshConsole() {
advanceReloadToken();
await loadConsoleData(consoleTarget);
}
async function sendMessage(event: SubmitEvent) { async function sendMessage(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
const content = draft.trim(); const content = draft.trim();
@ -283,33 +279,27 @@
<main class="shell console-shell worker-console-shell"> <main class="shell console-shell worker-console-shell">
<section class="console-header card"> <section class="console-header card">
<div> <div>
<p class="eyebrow">Worker attach Console</p>
<h2>{worker?.label ?? workerId}</h2> <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>
<div class="console-status-pill" class:warn={streamState !== 'open'}> <div class="console-header-actions">
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState} <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> </div>
</section> </section>
<section class="console-grid"> <section class="console-body">
<article class="card transcript-card worker-transcript-card"> <article class="card console-card worker-console-card">
<header class="transcript-toolbar"> {#if projection.status || projection.usage}
<div> <p class="section-note">
<h3>Transcript and protocol events</h3> {#if projection.status}status: {projection.status}{/if}
{#if projection.status || projection.usage} {#if projection.status && projection.usage} · {/if}
<p class="section-note"> {#if projection.usage}usage: {projection.usage}{/if}
{#if projection.status}status: {projection.status}{/if} </p>
{#if projection.status && projection.usage} · {/if} {/if}
{#if projection.usage}usage: {projection.usage}{/if}
</p>
{/if}
</div>
<button type="button" class="secondary-button" onclick={refreshConsole}>Refresh</button>
</header>
{#if workerError} {#if workerError}
<p class="error">{workerError}</p> <p class="error">{workerError}</p>
@ -319,21 +309,26 @@
{/if} {/if}
{#if lines.length === 0} {#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} {:else}
<ol class="transcript worker-transcript"> <ol class="console-log">
{#each lines as item} {#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}> <li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
<div class="message-heading"> {#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
<span>{item.title}</span> <div class="message-heading">
<small>{item.source}{item.streaming ? ' · streaming' : ''}</small> <span>{item.title}</span>
</div> {#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> <pre>{item.body || '—'}</pre>
{#if item.detail || item.cursor} {#if item.detail}
<details class="message-detail"> <details class="message-detail">
<summary>metadata</summary> <summary>detail</summary>
{#if item.detail}<p>{item.detail}</p>{/if} <p>{item.detail}</p>
{#if item.cursor}<code>{item.cursor}</code>{/if}
</details> </details>
{/if} {/if}
</li> </li>
@ -342,8 +337,14 @@
{/if} {/if}
</article> </article>
<aside class="console-side-card card"> </section>
<h3>Worker detail</h3>
{#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} {#if worker}
<dl> <dl>
<div> <div>
@ -398,14 +399,13 @@
</details> </details>
{/if} {/if}
</aside> </aside>
</section> {/if}
<form class="console-composer card" onsubmit={sendMessage}> <form class="console-composer card" onsubmit={sendMessage}>
<label for="worker-console-message">Send user input</label>
<textarea <textarea
id="worker-console-message" id="worker-console-message"
aria-label="Console input"
bind:value={draft} 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} disabled={!worker?.capabilities.can_accept_input || sending}
></textarea> ></textarea>
<div class="composer-actions"> <div class="composer-actions">

View File

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