ui: refine worker console streaming

This commit is contained in:
Keisuke Hirata 2026-07-14 04:54:23 +09:00
parent ddbcd595ef
commit 520c10d807
No known key found for this signature in database
10 changed files with 1012 additions and 198 deletions

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 -- serve --workspace . --db .yoi/workspace.db --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 --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/workers.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/chat-submit.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/workers.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -96,6 +96,10 @@
}
@layer base {
html {
font-size: 14px;
}
body {
background: var(--bg);
color: var(--text);
@ -1048,8 +1052,7 @@
}
.console-log li {
display: grid;
gap: var(--space-1);
min-width: 0;
padding: 0.2rem 0;
background: transparent;
}
@ -1059,6 +1062,22 @@
line-height: 1.55;
}
.console-plain-text {
margin: 0.45rem 0;
color: inherit;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.console-plain-text:first-child {
margin-top: 0;
}
.console-plain-text:last-child {
margin-bottom: 0;
}
.rich-markdown > :first-child {
margin-top: 0;
}
@ -1174,7 +1193,7 @@
font-style: italic;
}
.console-log li.thinking .rich-markdown {
.console-log li.thinking .console-plain-text {
color: var(--tui-dark-gray);
font-style: italic;
}
@ -1197,7 +1216,20 @@
}
.tool-label {
flex: 0 0 auto;
color: var(--tui-cyan);
white-space: nowrap;
}
.tool-separator {
flex: 0 0 auto;
white-space: nowrap;
}
.tool-suffix {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.tool-separator,
@ -1219,7 +1251,16 @@
color: var(--tui-dark-gray);
}
.console-line.tool .rich-markdown {
.console-line.tool-bash .console-plain-text {
display: block;
max-width: 100%;
min-width: 0;
font-family: var(--font-mono);
overflow-x: auto;
white-space: pre;
}
.console-line.tool .console-plain-text {
color: var(--tui-gray);
}
@ -1324,13 +1365,16 @@
}
.console-composer textarea {
box-sizing: border-box;
width: 100%;
min-height: 7rem;
resize: vertical;
min-height: 0;
resize: none;
overflow-y: hidden;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.85rem 1rem;
font: inherit;
line-height: 1.45;
color: var(--text-strong);
}

View File

@ -0,0 +1,81 @@
<script lang="ts">
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
import type { ConsoleLine } from '$lib/workspace-console/model';
type Props = {
item: ConsoleLine;
};
let { item }: Props = $props();
function lineClass(line: ConsoleLine): string {
return line.error ? 'error' : line.kind;
}
function toolClass(line: ConsoleLine): string {
const name = line.toolCall?.name?.toLowerCase() ?? '';
const state = line.toolCall?.state ?? (line.streaming ? 'streaming' : 'done');
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
}
function shouldRenderHeading(line: ConsoleLine): boolean {
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool';
}
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
const [firstLine = '', ...rest] = line.body.split('\n');
const [label, suffix = ''] = firstLine.split(' — ', 2);
return {
label,
suffix,
rest: rest.join('\n')
};
}
function shouldRenderMarkdown(line: ConsoleLine): boolean {
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
}
function bodyTextAfterToolSummary(line: ConsoleLine): string {
return toolSummary(line).rest;
}
</script>
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
{#if shouldRenderHeading(item)}
<div class="message-heading">
<span>{item.title}</span>
{#if item.streaming}<small>streaming</small>{/if}
</div>
{:else if item.kind === 'tool'}
<div class="tool-summary">
<span class="tool-label">{toolSummary(item).label}</span>
<span class="tool-separator"></span>
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
{#if item.streaming}<small>streaming</small>{/if}
</div>
{:else if item.streaming}
<div class="message-heading streaming-heading">
<small>streaming</small>
</div>
{/if}
{#if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)}
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
{/if}
{:else if shouldRenderMarkdown(item)}
<RichMarkdown text={item.body || '—'} />
{:else}
<p class="console-plain-text">{item.body || '—'}</p>
{/if}
{#if item.diff}
<pre class="console-diff" aria-label="Edit diff">{#each item.diff as diffLine}
<span class={`diff-line ${diffLine.kind}`}><span class="diff-gutter">{diffLine.oldNumber ?? ''}</span><span class="diff-gutter">{diffLine.newNumber ?? ''}</span><span class="diff-marker">{diffLine.kind === 'add' ? '+' : diffLine.kind === 'remove' ? '-' : ' '}</span><span class="diff-content">{diffLine.content}</span></span>{/each}</pre>
{/if}
{#if item.detail}
<details class="message-detail">
<summary>detail</summary>
<p>{item.detail}</p>
</details>
{/if}
</li>

View File

@ -0,0 +1,65 @@
import { shouldSubmitChatKey } from "./chat-submit.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function assertEquals<T>(actual: T, expected: T): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
}
}
Deno.test("shouldSubmitChatKey defaults to Cmd+Enter submit behavior", () => {
assert(
shouldSubmitChatKey(
{ key: "Enter", metaKey: true },
{ mode: "mod-enter", modKey: "meta", enabled: true },
),
"Cmd+Enter should submit by default",
);
assertEquals(
shouldSubmitChatKey(
{ key: "Enter" },
{ mode: "mod-enter", modKey: "meta", enabled: true },
),
false,
);
assertEquals(
shouldSubmitChatKey(
{ key: "Enter", ctrlKey: true },
{ mode: "mod-enter", modKey: "meta", enabled: true },
),
false,
);
});
Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () => {
const options = { mode: "mod-enter" as const, modKey: "meta" as const, enabled: true };
assertEquals(
shouldSubmitChatKey({ key: "Enter", metaKey: true, isComposing: true }, options),
false,
);
assertEquals(
shouldSubmitChatKey({ key: "Enter", metaKey: true, keyCode: 229 }, options),
false,
);
assertEquals(
shouldSubmitChatKey({ key: "Enter", metaKey: true, repeat: true }, options),
false,
);
});
Deno.test("shouldSubmitChatKey supports enter submit mode", () => {
const options = { mode: "enter" as const, modKey: "meta" as const, enabled: true };
assert(shouldSubmitChatKey({ key: "Enter" }, options), "Enter should submit");
assertEquals(shouldSubmitChatKey({ key: "Enter", shiftKey: true }, options), false);
});

View File

@ -0,0 +1,144 @@
export type ChatSubmitMode = "mod-enter" | "enter";
export type ChatSubmitModKey = "meta" | "ctrl" | "auto";
export type ChatSubmitOptions = {
onSubmit: (value: string, ctx: { target: HTMLTextAreaElement }) => void;
mode?: ChatSubmitMode;
modKey?: ChatSubmitModKey;
allowEmptySubmit?: boolean;
stopPropagation?: boolean;
enabled?: boolean;
};
type NormalizedChatSubmitOptions = Required<ChatSubmitOptions>;
export type ChatSubmitKeyEventLike = {
key: string;
shiftKey?: boolean;
metaKey?: boolean;
ctrlKey?: boolean;
repeat?: boolean;
isComposing?: boolean;
keyCode?: number;
which?: number;
};
function normalizeOptions(options: ChatSubmitOptions): NormalizedChatSubmitOptions {
return {
mode: "mod-enter",
modKey: "meta",
allowEmptySubmit: false,
stopPropagation: false,
enabled: true,
...options,
};
}
function isImeEvent(event: ChatSubmitKeyEventLike): boolean {
return event.isComposing === true ||
event.key === "Process" ||
event.keyCode === 229 ||
event.which === 229;
}
function isApplePlatform(): boolean {
if (typeof navigator === "undefined") {
return true;
}
const platform = navigator.platform || "";
const userAgent = navigator.userAgent || "";
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac|iPhone|iPad|iPod/.test(userAgent);
}
function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" {
if (modKey === "auto") {
return isApplePlatform() ? "meta" : "ctrl";
}
return modKey;
}
function isModPressed(event: ChatSubmitKeyEventLike, modKey: ChatSubmitModKey): boolean {
return resolveModKey(modKey) === "meta" ? event.metaKey === true : event.ctrlKey === true;
}
export function shouldSubmitChatKey(
event: ChatSubmitKeyEventLike,
options: Pick<NormalizedChatSubmitOptions, "mode" | "modKey" | "enabled"> & {
isComposing?: boolean;
},
): boolean {
if (!options.enabled || event.repeat || event.key !== "Enter") {
return false;
}
if (options.isComposing || isImeEvent(event)) {
return false;
}
if (options.mode === "enter") {
return event.shiftKey !== true;
}
return isModPressed(event, options.modKey);
}
export function chatSubmit(node: HTMLTextAreaElement, options: ChatSubmitOptions) {
let current = normalizeOptions(options);
let isComposing = false;
function triggerSubmit() {
if (!current.enabled || node.disabled || node.readOnly) {
return;
}
const value = node.value ?? "";
if (!current.allowEmptySubmit && value.trim() === "") {
return;
}
current.onSubmit(value, { target: node });
}
function onKeyDown(event: KeyboardEvent) {
if (isImeEvent(event)) {
isComposing = true;
return;
}
if (!shouldSubmitChatKey(event, { ...current, isComposing })) {
return;
}
event.preventDefault();
if (current.stopPropagation) {
event.stopPropagation();
}
triggerSubmit();
}
function onKeyUp(event: KeyboardEvent) {
if (!event.isComposing) {
isComposing = false;
}
}
function onCompositionStart() {
isComposing = true;
}
function clearComposition() {
isComposing = false;
}
node.addEventListener("keydown", onKeyDown);
node.addEventListener("keyup", onKeyUp);
node.addEventListener("compositionstart", onCompositionStart);
node.addEventListener("compositionend", clearComposition);
node.addEventListener("compositioncancel", clearComposition);
return {
update(nextOptions: ChatSubmitOptions) {
current = normalizeOptions(nextOptions);
},
destroy() {
node.removeEventListener("keydown", onKeyDown);
node.removeEventListener("keyup", onKeyUp);
node.removeEventListener("compositionstart", onCompositionStart);
node.removeEventListener("compositionend", clearComposition);
node.removeEventListener("compositioncancel", clearComposition);
},
};
}

View File

@ -1,5 +1,10 @@
import type { Event } from "$lib/generated/protocol";
import { projectConsole, segmentsToText, workerConsoleHref } from "./model.ts";
import {
createConsoleProjector,
projectConsole,
segmentsToText,
workerConsoleHref,
} from "./model.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
@ -175,7 +180,7 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
data: {
id: "call-1",
summary: "command completed",
output: "/repo\n",
output: "/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12",
is_error: false,
},
} satisfies Event,
@ -197,12 +202,153 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
toolLines[0].body.includes("/repo"),
"tool result should be folded into the Call block",
);
assert(
toolLines[0].body.includes("line9"),
"Bash result preview should include the ninth output line",
);
assert(
!toolLines[0].body.includes("line10") &&
!toolLines[0].body.includes("line12"),
"Bash result preview should be capped at ten display lines",
);
assert(
toolLines[0].body.includes("… +3 more lines"),
"Bash result preview should show omitted output count",
);
assert(
toolLines[0].detail?.includes("id: call-1"),
"call id should remain in detail",
);
});
Deno.test("projectConsole caps default tool request and result previews", () => {
const projection = projectConsole([
{
eventId: "70",
event: {
event: "tool_call_done",
data: {
id: "custom-1",
name: "CustomTool",
arguments: JSON.stringify({
first: "one",
second: "two",
third: "three",
fourth: "four",
}),
},
} satisfies Event,
},
{
eventId: "71",
event: {
event: "tool_result",
data: {
id: "custom-1",
summary: "custom completed",
output: "out1\nout2\nout3\nout4\nout5",
is_error: false,
},
} satisfies Event,
},
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · CustomTool");
assertEquals(line.body.split("\n").length, 7);
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
assert(line.body.includes('"first": "one"'), "request preview should be shown");
assert(line.body.includes("out1"), "result preview should be shown");
assert(!line.body.includes("third"), "request preview should be capped");
assert(!line.body.includes("out3"), "result preview should be capped");
assert(line.body.includes("… +"), "overflow marker should be shown");
});
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
const projection = projectConsole([
{
eventId: "72",
event: {
event: "tool_call_done",
data: {
id: "grep-1",
name: "Grep",
arguments: JSON.stringify({ pattern: "needle", path: "/repo" }),
},
} satisfies Event,
},
{
eventId: "73",
event: {
event: "tool_result",
data: {
id: "grep-1",
summary: "6 matches",
output: "hit1\nhit2\nhit3\nhit4\nhit5\nhit6",
is_error: false,
},
} satisfies Event,
},
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
assert(line.body.includes("Grep — 6 matches"), "Grep summary should be shown");
assert(line.body.includes("query: needle"), "Grep query should be shown");
assert(line.body.includes("hit1"), "first result should be shown");
assert(line.body.includes("hit5"), "fifth result should be shown");
assert(!line.body.includes("hit6"), "sixth result should be capped");
assert(
line.body.includes("… +1 more results"),
"overflow marker should be shown",
);
});
Deno.test("projectConsole keeps Grep error detail in the body", () => {
const message =
"Tool execution failed: Invalid argument: path is outside allowed scope: /home/hare/.yoi/workdirs/working-directories/0019f5bce74f1000000/root/main/ghq.local/github/openai/codex";
const projection = projectConsole([
{
eventId: "74",
event: {
event: "tool_call_done",
data: {
id: "grep-error-1",
name: "Grep",
arguments: JSON.stringify({ pattern: "needle", path: "/outside" }),
},
} satisfies Event,
},
{
eventId: "75",
event: {
event: "tool_result",
data: {
id: "grep-error-1",
summary: message,
output: message,
is_error: true,
},
} satisfies Event,
},
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
assert(
line.body.includes("Grep — Failed"),
"error suffix should stay short",
);
assert(
line.body.includes(message),
"error detail should remain visible in the body",
);
assert(
!line.body.includes(`Grep — ${message}`),
"error detail should not be repeated in the suffix",
);
});
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
const projection = projectConsole([
{
@ -232,6 +378,85 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
);
});
Deno.test("createConsoleProjector replaces only the updated protocol block", () => {
const projector = createConsoleProjector();
let projection = projector.append([
{
eventId: "identity-1",
event: {
event: "user_message",
data: { segments: [{ kind: "text", content: "hello" }] },
} satisfies Event,
},
{
eventId: "identity-2",
event: {
event: "tool_call_start",
data: { id: "grep-a", name: "Grep" },
} satisfies Event,
},
{
eventId: "identity-3",
event: {
event: "tool_call_start",
data: { id: "grep-b", name: "Grep" },
} satisfies Event,
},
]);
const userLine = projection.lines[0];
const grepA = projection.lines[1];
const grepB = projection.lines[2];
projection = projector.append([
{
eventId: "identity-4",
event: {
event: "tool_call_args_delta",
data: { id: "grep-a", json: '{"pattern":"needle"}' },
} satisfies Event,
},
]);
assert(
projection.lines[0] === userLine,
"unrelated message line should keep object identity",
);
assert(
projection.lines[1] !== grepA,
"updated tool line should get a new object identity",
);
assert(
projection.lines[2] === grepB,
"parallel unrelated tool line should keep object identity",
);
const updatedGrepA = projection.lines[1];
projection = projector.append([
{
eventId: "identity-5",
event: {
event: "tool_result",
data: {
id: "grep-b",
summary: "done",
output: "hit",
is_error: false,
},
} satisfies Event,
},
]);
assert(
projection.lines[1] === updatedGrepA,
"previously updated but now unrelated tool line should keep identity",
);
assert(
projection.lines[2] !== grepB,
"completed parallel tool line should get a new object identity",
);
});
Deno.test("projectConsole aggregates Read calls without showing file content", () => {
const projection = projectConsole([
{

View File

@ -95,15 +95,42 @@ export function workerConsolePath(
export type ConsoleEventInput = { eventId: string; event: ProtocolEvent };
export function projectConsole(
events: ConsoleEventInput[] = [],
): ConsoleProjection {
const projection = events.reduce(applyProtocolEvent, {
export function emptyConsoleProjection(): ConsoleProjection {
return {
lines: [],
status: null,
usage: null,
lastEventId: null,
});
};
}
export function projectConsole(
events: ConsoleEventInput[] = [],
): ConsoleProjection {
const projection = events.reduce(applyProtocolEvent, emptyConsoleProjection());
return projectVisibleConsole(projection);
}
export function createConsoleProjector() {
let projection = emptyConsoleProjection();
return {
reset(): ConsoleProjection {
projection = emptyConsoleProjection();
return projectVisibleConsole(projection);
},
append(events: ConsoleEventInput[]): ConsoleProjection {
for (const event of events) {
projection = applyProtocolEvent(projection, event);
}
return projectVisibleConsole(projection);
},
snapshot(): ConsoleProjection {
return projectVisibleConsole(projection);
},
};
}
function projectVisibleConsole(projection: ConsoleProjection): ConsoleProjection {
return {
...projection,
lines: aggregateReadToolLines(projection.lines),
@ -314,6 +341,18 @@ function line(
};
}
function findLastLineIndex(
lines: ConsoleLine[],
predicate: (line: ConsoleLine) => boolean,
): number {
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (predicate(lines[index])) {
return index;
}
}
return -1;
}
function appendStreaming(
projection: ConsoleProjection,
eventId: string,
@ -321,12 +360,17 @@ function appendStreaming(
title: string,
delta: string,
): void {
const existing = [...projection.lines].reverse().find((item) =>
item.kind === kind && item.streaming
const index = findLastLineIndex(
projection.lines,
(item) => item.kind === kind && item.streaming === true,
);
if (existing) {
existing.body += delta;
existing.eventId = eventId;
if (index >= 0) {
const existing = projection.lines[index];
projection.lines[index] = {
...existing,
body: `${existing.body}${delta}`,
eventId,
};
return;
}
projection.lines.push(line(eventId, kind, title, delta, undefined, true));
@ -339,14 +383,19 @@ function finalizeStreaming(
title: string,
body: string,
): void {
const existing = [...projection.lines].reverse().find((item) =>
item.kind === kind && item.streaming
const index = findLastLineIndex(
projection.lines,
(item) => item.kind === kind && item.streaming === true,
);
if (existing) {
existing.body = body || existing.body;
existing.streaming = false;
existing.title = title;
existing.eventId = eventId;
if (index >= 0) {
const existing = projection.lines[index];
projection.lines[index] = {
...existing,
body: body || existing.body,
streaming: false,
title,
eventId,
};
return;
}
projection.lines.push(line(eventId, kind, title, body));
@ -358,9 +407,9 @@ function upsertToolCall(
id: string,
update: Partial<Omit<ToolCallView, "id">>,
): ConsoleLine {
let existing = findToolCallLine(projection, id);
if (!existing) {
existing = toolLine(eventId, {
const index = findToolCallLineIndex(projection, id);
if (index < 0) {
const created = toolLine(eventId, {
id,
name: update.name ?? "Tool",
argsStream: update.argsStream ?? "",
@ -370,18 +419,23 @@ function upsertToolCall(
output: update.output,
isError: update.isError,
});
projection.lines.push(existing);
} else {
existing.eventId = eventId;
existing.toolCall = {
projection.lines.push(created);
return created;
}
const existing = projection.lines[index];
const updated = refreshedToolLine({
...existing,
eventId,
toolCall: {
...existing.toolCall!,
...update,
id,
name: update.name ?? existing.toolCall!.name,
};
}
refreshToolLine(existing);
return existing;
},
});
projection.lines[index] = updated;
return updated;
}
function appendToolArgs(
@ -390,11 +444,27 @@ function appendToolArgs(
id: string,
delta: string,
): void {
const existing = upsertToolCall(projection, eventId, id, {
state: "streaming_args",
const index = findToolCallLineIndex(projection, id);
if (index < 0) {
projection.lines.push(toolLine(eventId, {
id,
name: "Tool",
argsStream: delta,
state: "streaming_args",
}));
return;
}
const existing = projection.lines[index];
const toolCall = existing.toolCall!;
projection.lines[index] = refreshedToolLine({
...existing,
eventId,
toolCall: {
...toolCall,
argsStream: `${toolCall.argsStream}${delta}`,
state: "streaming_args",
},
});
existing.toolCall!.argsStream += delta;
refreshToolLine(existing);
}
function attachToolResult(
@ -403,42 +473,47 @@ function attachToolResult(
id: string,
result: Pick<ToolCallView, "summary" | "output" | "isError">,
): void {
const existing = findToolCallLine(projection, id);
if (!existing) {
const fallback = toolLine(eventId, {
id,
name: "Tool",
argsStream: "",
state: result.isError ? "error" : "done",
...result,
const index = findToolCallLineIndex(projection, id);
if (index < 0) {
const fallback = refreshedToolLine({
...toolLine(eventId, {
id,
name: "Tool",
argsStream: "",
state: result.isError ? "error" : "done",
...result,
}),
title: result.isError ? "Call · Tool result error" : "Call · Tool result",
});
fallback.title = result.isError
? "Call · Tool result error"
: "Call · Tool result";
refreshToolLine(fallback);
projection.lines.push(fallback);
return;
}
existing.eventId = eventId;
existing.toolCall = {
...existing.toolCall!,
...result,
state: result.isError ? "error" : "done",
};
refreshToolLine(existing);
const existing = projection.lines[index];
projection.lines[index] = refreshedToolLine({
...existing,
eventId,
toolCall: {
...existing.toolCall!,
...result,
state: result.isError ? "error" : "done",
},
});
}
function findToolCallLine(
function findToolCallLineIndex(
projection: ConsoleProjection,
id: string,
): ConsoleLine | undefined {
return [...projection.lines].reverse().find((item) =>
item.toolCall?.id === id
);
): number {
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
if (projection.lines[index].toolCall?.id === id) {
return index;
}
}
return -1;
}
function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
const item: ConsoleLine = {
return refreshedToolLine({
id: `tool-call-${toolCall.id}`,
kind: "tool",
title: `Call · ${toolCall.name}`,
@ -449,24 +524,25 @@ function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
streaming: true,
error: false,
toolCall,
};
refreshToolLine(item);
return item;
});
}
function refreshToolLine(item: ConsoleLine): void {
function refreshedToolLine(item: ConsoleLine): ConsoleLine {
const toolCall = item.toolCall;
if (!toolCall) {
return;
return item;
}
item.title = item.title.startsWith("Call · Tool result")
? item.title
: `Call · ${toolCall.name}`;
item.body = renderToolCall(toolCall);
item.detail = toolCallDetail(toolCall);
item.diff = toolCall.name === "Edit" ? editDiff(toolCall) : undefined;
item.streaming = !["done", "error"].includes(toolCall.state);
item.error = toolCall.state === "error";
return {
...item,
title: item.title.startsWith("Call · Tool result")
? item.title
: `Call · ${toolCall.name}`,
body: renderToolCall(toolCall),
detail: toolCallDetail(toolCall),
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
streaming: !["done", "error"].includes(toolCall.state),
error: toolCall.state === "error",
};
}
function renderToolCall(toolCall: ToolCallView): string {
@ -478,8 +554,9 @@ function renderToolCall(toolCall: ToolCallView): string {
case "Edit":
return renderEditTool(toolCall);
case "Glob":
case "Grep":
return renderSearchTool(toolCall);
case "Grep":
return renderGrepTool(toolCall);
case "Bash":
return renderBashTool(toolCall);
default:
@ -665,28 +742,55 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
`${toolCall.name}${
summary ? firstLine(summary) : stateSuffix(toolCall.state)
}`,
`${toolCall.name}${toolHeaderSuffix(toolCall, summary)}`,
resultText(toolCall),
]);
}
function renderGrepTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
grepQueryText(toolCall),
cappedResultSection(resultText(toolCall), 5),
]);
}
function toolHeaderSuffix(
toolCall: ToolCallView,
summary?: string,
): string {
if (toolCall.state === "error") {
return "Failed";
}
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
}
function grepQueryText(toolCall: ToolCallView): string | undefined {
const args = parsedArgs(toolCall);
const pattern = stringField(args, "pattern");
if (pattern) {
return `query: ${pattern}`;
}
const renderedArgs = argsText(toolCall);
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
}
function renderBashTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const command = stringField(args, "command");
return compactLines([
`Bash — ${stateSuffix(toolCall.state)}`,
command ? `$ ${command}` : argsText(toolCall),
resultText(toolCall),
cappedDisplaySection(resultText(toolCall), 10),
]);
}
function renderDefaultTool(toolCall: ToolCallView): string {
return compactLines([
`${toolCall.name}${stateSuffix(toolCall.state)}`,
argsText(toolCall),
resultText(toolCall),
cappedDisplaySection(argsText(toolCall), 3),
cappedDisplaySection(resultText(toolCall), 3),
]);
}
@ -754,6 +858,42 @@ function cappedSection(
return shown.join("\n");
}
function cappedDisplaySection(
value: string | undefined,
maxLines: number,
): string | undefined {
if (!value) {
return undefined;
}
const lines = value.split(/\r?\n/);
if (lines.length <= maxLines) {
return value;
}
if (maxLines <= 0) {
return undefined;
}
const shown = lines.slice(0, maxLines);
shown[maxLines - 1] = `… +${lines.length - maxLines + 1} more lines`;
return shown.join("\n");
}
function cappedResultSection(
value: string | undefined,
maxResults: number,
): string | undefined {
if (!value || maxResults <= 0) {
return undefined;
}
const lines = value.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length <= maxResults) {
return lines.join("\n");
}
return [
...lines.slice(0, maxResults),
`… +${lines.length - maxResults} more results`,
].join("\n");
}
function usageText(
data: {
input_tokens: number | null;

View File

@ -0,0 +1,87 @@
export type TextareaFitOptions = {
value?: string;
maxRows?: number;
};
function positiveNumber(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: fallback;
}
function pixelValue(value: string): number {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function lineHeightPx(style: CSSStyleDeclaration): number {
const parsed = pixelValue(style.lineHeight);
if (parsed > 0) {
return parsed;
}
const fontSize = pixelValue(style.fontSize);
return fontSize > 0 ? fontSize * 1.2 : 16.8;
}
function verticalBoxPx(style: CSSStyleDeclaration): number {
return pixelValue(style.paddingTop) +
pixelValue(style.paddingBottom) +
pixelValue(style.borderTopWidth) +
pixelValue(style.borderBottomWidth);
}
function fitTextareaHeight(node: HTMLTextAreaElement, maxRows: number) {
const style = globalThis.getComputedStyle(node);
const maxHeight = lineHeightPx(style) * maxRows + verticalBoxPx(style);
node.style.height = "auto";
const nextHeight = Math.min(node.scrollHeight, maxHeight);
node.style.height = `${nextHeight}px`;
node.style.overflowY = node.scrollHeight > maxHeight ? "auto" : "hidden";
}
export function fitTextarea(
node: HTMLTextAreaElement,
options: TextareaFitOptions = {},
) {
let current = {
value: options.value,
maxRows: positiveNumber(options.maxRows, 10),
};
let frame: number | null = null;
function scheduleFit() {
if (frame !== null) {
return;
}
frame = window.requestAnimationFrame(() => {
frame = null;
fitTextareaHeight(node, current.maxRows);
});
}
function onInput() {
scheduleFit();
}
node.addEventListener("input", onInput);
scheduleFit();
return {
update(nextOptions: TextareaFitOptions = {}) {
const next = {
value: nextOptions.value,
maxRows: positiveNumber(nextOptions.maxRows, 10),
};
if (next.value !== current.value || next.maxRows !== current.maxRows) {
current = next;
scheduleFit();
}
},
destroy() {
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
node.removeEventListener("input", onInput);
},
};
}

View File

@ -69,13 +69,49 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
consolePage.includes(
"rememberObservationEvent(frame.envelope.event_id)",
) &&
consolePage.includes("projectConsole(observedEvents.map") &&
consolePage.includes("createConsoleProjector") &&
consolePage.includes("consoleProjector.append(eventBatch)") &&
consolePage.includes("{#each lines as item (item.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("Worker Console renders markdown only for message rows", async () => {
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
);
assert(
consoleLine.includes("function shouldRenderMarkdown") &&
consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes('<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>') &&
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
consoleLine.includes("<RichMarkdown text={item.body || '—'} />"),
"Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally",
);
});
Deno.test("Worker Console composer fits to content without manual resize", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const css = await Deno.readTextFile(new URL("../../app.css", import.meta.url));
assert(
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
css.includes(".console-composer textarea") &&
css.includes("resize: none") &&
css.includes("overflow-y: hidden"),
"Console composer should autosize to content, cap at ten rows, and disable manual resize",
);
});
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
const editor = await Deno.readTextFile(
new URL(

View File

@ -1,9 +1,12 @@
<script lang="ts">
import { tick } from 'svelte';
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
import ConsoleLineItem from '$lib/workspace-console/ConsoleLineItem.svelte';
import { chatSubmit } from '$lib/workspace-console/chat-submit';
import { fitTextarea } from '$lib/workspace-console/textarea-fit';
import {
projectConsole,
type ConsoleLine
createConsoleProjector,
type ConsoleEventInput,
type ConsoleProjection
} from '$lib/workspace-console/model';
import { workspaceApiPath } from '$lib/workspace-api/http';
import type {
@ -44,8 +47,13 @@
let consoleBodyElement: HTMLElement | null = null;
let autoFollowConsole = $state(true);
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
let observedEvents = $state<Array<{ eventId: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
const consoleProjector = createConsoleProjector();
let consoleProjection = $state.raw<ConsoleProjection>(consoleProjector.snapshot());
let seenObservationEventIds = new Set<string>();
let pendingObservationEvents: ConsoleEventInput[] = [];
let pendingObservedStates: Array<string | null> = [];
let pendingStreamDiagnostics: Diagnostic[] = [];
let observationFlushHandle: number | null = null;
let nextReloadToken = 0;
let reloadToken = $state(0);
@ -56,10 +64,7 @@
const consoleTarget = $derived({ runtimeId, workerId });
const projection = $derived(
projectConsole(observedEvents.map((item) => ({ eventId: item.eventId, event: item.event.envelope.payload })))
);
const lines = $derived(projection.lines);
const lines = $derived(consoleProjection.lines);
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
const workerState = $derived(liveWorkerState ?? worker?.state ?? 'loading');
const inputReady = $derived(workerState === 'idle');
@ -124,10 +129,69 @@
}
function resetObservedEvents() {
observedEvents = [];
cancelObservationFlush();
consoleProjection = consoleProjector.reset();
seenObservationEventIds = new Set();
}
function cancelObservationFlush() {
if (observationFlushHandle !== null) {
window.cancelAnimationFrame(observationFlushHandle);
observationFlushHandle = null;
}
pendingObservationEvents = [];
pendingObservedStates = [];
pendingStreamDiagnostics = [];
}
function scheduleObservationFlush() {
if (observationFlushHandle !== null) {
return;
}
observationFlushHandle = window.requestAnimationFrame(() => {
flushObservationBatch();
});
}
function flushObservationBatch() {
observationFlushHandle = null;
const eventBatch = pendingObservationEvents;
const stateBatch = pendingObservedStates;
const diagnosticBatch = pendingStreamDiagnostics;
pendingObservationEvents = [];
pendingObservedStates = [];
pendingStreamDiagnostics = [];
if (eventBatch.length > 0) {
const latestState = stateBatch.findLast((state) => state !== null);
if (latestState) {
liveWorkerState = latestState;
}
consoleProjection = consoleProjector.append(eventBatch);
}
if (diagnosticBatch.length > 0) {
streamDiagnostics = [...streamDiagnostics, ...diagnosticBatch];
}
}
function queueObservationEvent(frame: ClientWorkerEventWsFrame & { kind: 'event' }) {
if (!rememberObservationEvent(frame.envelope.event_id)) {
return;
}
pendingObservationEvents.push({
eventId: frame.envelope.event_id,
event: frame.envelope.payload
});
pendingObservedStates.push(workerStateFromProtocolEvent(frame.envelope.payload));
scheduleObservationFlush();
}
function queueObservationDiagnostic(diagnostic: Diagnostic) {
pendingStreamDiagnostics.push(diagnostic);
scheduleObservationFlush();
}
function rememberObservationEvent(eventId: string): boolean {
if (seenObservationEventIds.has(eventId)) {
return false;
@ -136,9 +200,8 @@
return true;
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
async function submitDraft(value = draft) {
const content = value.trim();
if (!content || sending || !inputReady) {
return;
}
@ -163,6 +226,11 @@
}
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
await submitDraft();
}
function workerStateFromProtocolEvent(event: PodProtocolEvent): string | null {
switch (event.event) {
case 'snapshot':
@ -199,39 +267,20 @@
try {
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
if (frame.kind === 'event') {
if (!rememberObservationEvent(frame.envelope.event_id)) {
return;
}
const observedState = workerStateFromProtocolEvent(frame.envelope.payload);
if (observedState) {
liveWorkerState = observedState;
}
observedEvents = [
...observedEvents,
{
eventId: frame.envelope.event_id,
event: frame
}
].slice(-500);
queueObservationEvent(frame);
} else {
streamDiagnostics = [
...streamDiagnostics,
{
code: frame.diagnostic.code,
severity: 'warning',
message: frame.diagnostic.message
}
];
queueObservationDiagnostic({
code: frame.diagnostic.code,
severity: 'warning',
message: frame.diagnostic.message
});
}
} catch (error) {
streamDiagnostics = [
...streamDiagnostics,
{
code: 'worker_observation_frame_invalid',
severity: 'warning',
message: error instanceof Error ? error.message : String(error)
}
];
queueObservationDiagnostic({
code: 'worker_observation_frame_invalid',
severity: 'warning',
message: error instanceof Error ? error.message : String(error)
});
}
};
ws.onerror = () => {
@ -264,34 +313,6 @@
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
}
function lineClass(line: ConsoleLine): string {
return line.error ? 'error' : line.kind;
}
function toolClass(line: ConsoleLine): string {
const name = line.toolCall?.name?.toLowerCase() ?? '';
const state = line.toolCall?.state ?? (line.streaming ? 'streaming' : 'done');
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
}
function shouldRenderHeading(line: ConsoleLine): boolean {
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool';
}
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
const [firstLine = '', ...rest] = line.body.split('\n');
const [label, suffix = ''] = firstLine.split(' — ', 2);
return {
label,
suffix,
rest: rest.join('\n')
};
}
function bodyTextAfterToolSummary(line: ConsoleLine): string {
return toolSummary(line).rest;
}
function isNearConsoleBottom(element: HTMLElement): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
}
@ -359,11 +380,11 @@
<section class="console-body" bind:this={consoleBodyElement} onscroll={handleConsoleScroll}>
<article class="card console-card worker-console-card">
{#if projection.status || projection.usage}
{#if consoleProjection.status || consoleProjection.usage}
<p class="section-note">
{#if projection.status}status: {projection.status}{/if}
{#if projection.status && projection.usage} · {/if}
{#if projection.usage}usage: {projection.usage}{/if}
{#if consoleProjection.status}status: {consoleProjection.status}{/if}
{#if consoleProjection.status && consoleProjection.usage} · {/if}
{#if consoleProjection.usage}usage: {consoleProjection.usage}{/if}
</p>
{/if}
@ -375,43 +396,8 @@
<p>No console output is available for this Worker yet.</p>
{:else}
<ol class="console-log">
{#each lines as item}
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
{#if shouldRenderHeading(item)}
<div class="message-heading">
<span>{item.title}</span>
{#if item.streaming}<small>streaming</small>{/if}
</div>
{:else if item.kind === 'tool'}
<div class="tool-summary">
<span class="tool-label">{toolSummary(item).label}</span>
<span class="tool-separator"></span>
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
{#if item.streaming}<small>streaming</small>{/if}
</div>
{:else if item.streaming}
<div class="message-heading streaming-heading">
<small>streaming</small>
</div>
{/if}
{#if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)}
<RichMarkdown text={bodyTextAfterToolSummary(item)} />
{/if}
{:else}
<RichMarkdown text={item.body || '—'} />
{/if}
{#if item.diff}
<pre class="console-diff" aria-label="Edit diff">{#each item.diff as diffLine}
<span class={`diff-line ${diffLine.kind}`}><span class="diff-gutter">{diffLine.oldNumber ?? ''}</span><span class="diff-gutter">{diffLine.newNumber ?? ''}</span><span class="diff-marker">{diffLine.kind === 'add' ? '+' : diffLine.kind === 'remove' ? '-' : ' '}</span><span class="diff-content">{diffLine.content}</span></span>{/each}</pre>
{/if}
{#if item.detail}
<details class="message-detail">
<summary>detail</summary>
<p>{item.detail}</p>
</details>
{/if}
</li>
{#each lines as item (item.id)}
<ConsoleLineItem {item} />
{/each}
</ol>
{/if}
@ -484,7 +470,13 @@
<textarea
id="worker-console-message"
aria-label="Console input"
aria-keyshortcuts="Meta+Enter"
bind:value={draft}
use:chatSubmit={{
enabled: inputReady && !sending,
onSubmit: (value) => void submitDraft(value)
}}
use:fitTextarea={{ value: draft, maxRows: 10 }}
disabled={!inputReady || sending}
></textarea>
<div class="composer-actions">