feat: add worker console timeline

This commit is contained in:
2026-07-15 02:46:45 +09:00
parent e298856c85
commit f9f1f85b07
10 changed files with 3141 additions and 2455 deletions
+1129 -1905
View File
File diff suppressed because it is too large Load Diff
@@ -41,7 +41,11 @@
}
</script>
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
<li
class={`console-line ${lineClass(item)} ${toolClass(item)}`}
class:error-line={item.error}
data-console-line-id={item.id}
>
{#if shouldRenderHeading(item)}
<div class="message-heading">
<span>{item.title}</span>
@@ -79,3 +83,200 @@
</details>
{/if}
</li>
<style>
.console-line {
min-width: 0;
padding: 0.2rem 0;
background: transparent;
}
.console-line.error-line {
color: var(--danger);
}
.console-line.user {
color: var(--tui-green);
}
.console-line.assistant {
color: var(--text-strong);
}
.console-line.thinking .message-heading {
color: var(--tui-magenta);
font-style: italic;
}
.console-line.thinking .console-plain-text {
color: var(--tui-dark-gray);
font-style: italic;
}
.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;
}
.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);
}
.tool-summary {
display: flex;
align-items: baseline;
gap: 0;
color: var(--text-muted);
font-size: 0.88rem;
font-weight: 750;
}
.tool-summary small {
margin-left: var(--space-2);
color: var(--text-muted);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
}
.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,
.tool-suffix {
color: var(--tui-dark-gray);
}
.tool-state-error .tool-suffix {
color: var(--tui-red);
}
.tool-state-running .tool-suffix,
.tool-state-streaming_args .tool-suffix,
.tool-state-pending .tool-suffix {
color: var(--tui-yellow);
}
.tool-state-done .tool-suffix {
color: var(--tui-dark-gray);
}
.message-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 750;
}
.message-heading.streaming-heading {
justify-content: flex-start;
}
.message-heading small {
margin: 0;
color: var(--text-muted);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
}
.console-diff {
background: color-mix(in oklch, var(--bg-raised) 85%, black);
border: 1px solid var(--line);
border-radius: 0.65rem;
color: var(--text);
font-size: 0.78rem;
line-height: 1.45;
margin: 0.6rem 0 0;
overflow-x: auto;
padding: 0.45rem 0;
}
.diff-line {
display: grid;
grid-template-columns: 3.2rem 3.2rem 1.4rem minmax(0, 1fr);
min-width: max-content;
}
.diff-line.add {
background: color-mix(in oklch, var(--tui-green) 18%, transparent);
color: color-mix(in oklch, var(--tui-green) 75%, white);
}
.diff-line.remove {
background: color-mix(in oklch, var(--tui-red) 18%, transparent);
color: color-mix(in oklch, var(--tui-red) 72%, white);
}
.diff-line.context {
color: var(--text-muted);
}
.diff-gutter,
.diff-marker {
color: var(--tui-gray);
padding: 0 0.5rem;
text-align: right;
user-select: none;
}
.diff-content {
padding-right: 0.75rem;
white-space: pre;
}
:global(.console-line pre) {
max-width: 100%;
margin: 0;
overflow-x: auto;
white-space: pre-wrap;
color: var(--code);
}
.message-detail {
color: var(--text-muted);
font-size: 0.84rem;
}
.message-detail summary {
cursor: pointer;
font-weight: 800;
}
</style>
@@ -0,0 +1,170 @@
<script lang="ts">
type TimelineKind = 'turn' | 'assistant';
export type TimelineMark = {
id: string;
lineId: string;
label: string;
detail: string;
timeLabel: string;
position: number;
sourcePosition: number;
kind: TimelineKind;
};
type Props = {
marks: TimelineMark[];
thumbStyle: string;
axisStyle: string;
onRailClick: (event: MouseEvent) => void;
onMarkClick: (mark: TimelineMark) => void;
};
let { marks, thumbStyle, axisStyle, onRailClick, onMarkClick }: Props = $props();
</script>
<aside class="console-timeline" aria-label="Console timeline">
<div class="timeline-axis" style={axisStyle}>
<button
type="button"
class="timeline-rail"
aria-label="Jump within console scrollback"
onclick={onRailClick}
>
<span class="timeline-thumb" style={thumbStyle}></span>
</button>
<div class="timeline-marks" aria-label="Timeline marks">
{#each marks as mark (mark.id)}
<button
type="button"
class={`timeline-mark ${mark.kind}`}
style={`top: ${mark.position}px`}
aria-label={mark.detail ? `${mark.label}: ${mark.detail}` : mark.label}
onclick={() => onMarkClick(mark)}
>
<span class="timeline-dot"></span>
<span class="timeline-card">
<span>{mark.label}</span>
</span>
</button>
{/each}
</div>
</div>
</aside>
<style>
.console-timeline {
--timeline-axis-padding: 3rem;
position: relative;
grid-column: 2;
grid-row: 2;
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
opacity: 1;
}
.timeline-axis {
position: absolute;
right: 0;
left: 0;
}
.timeline-rail,
.timeline-marks {
position: absolute;
top: 0;
bottom: 0;
left: 0;
}
.timeline-rail {
width: 0.35rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--line) 70%, transparent);
cursor: pointer;
padding: 0;
}
.timeline-thumb {
position: absolute;
left: 0;
width: 100%;
min-height: 1.25rem;
border-radius: inherit;
background: var(--tui-cyan);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-cyan) 30%, transparent);
}
.timeline-marks {
width: 12rem;
pointer-events: none;
}
.timeline-mark {
position: absolute;
left: 0;
display: flex;
width: 11.5rem;
align-items: center;
gap: var(--space-2);
border: 0;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 0;
pointer-events: auto;
transform: translateY(-50%);
}
.timeline-dot {
width: 0.6rem;
height: 0.6rem;
flex: 0 0 auto;
border: 1px solid var(--bg);
border-radius: 999px;
background: var(--tui-gray);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 80%, transparent);
}
.timeline-mark.turn .timeline-dot {
background: var(--tui-blue);
}
.timeline-mark.assistant .timeline-dot {
background: var(--tui-green);
}
.timeline-card {
display: grid;
width: 10rem;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
justify-items: start;
text-align: left;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
box-shadow: var(--shadow-soft);
opacity: 1;
padding: 0.25rem 0.45rem;
transform: none;
}
.timeline-card span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-mark.turn .timeline-card {
border-color: color-mix(in srgb, var(--tui-blue) 55%, var(--line));
}
.timeline-mark.assistant .timeline-card {
border-color: color-mix(in srgb, var(--tui-green) 55%, var(--line));
}
</style>
@@ -37,3 +37,63 @@
<p>{text}</p>
{/if}
</div>
<style>
.rich-markdown {
color: inherit;
line-height: 1.55;
}
:global(.rich-markdown > :first-child) {
margin-top: 0;
}
:global(.rich-markdown > :last-child) {
margin-bottom: 0;
}
:global(.rich-markdown p),
:global(.rich-markdown ul),
:global(.rich-markdown blockquote),
:global(.rich-markdown pre) {
margin: 0.45rem 0;
}
:global(.rich-markdown ul) {
padding-left: 1.2rem;
}
:global(.rich-markdown h1),
:global(.rich-markdown h2),
:global(.rich-markdown h3),
:global(.rich-markdown h4) {
margin: 0.7rem 0 0.35rem;
color: var(--text-strong);
font-size: 1rem;
}
:global(.rich-markdown blockquote) {
border-left: 2px solid var(--tui-dark-gray);
color: var(--text-muted);
padding-left: 0.8rem;
}
:global(.rich-markdown :not(pre) > code) {
background: color-mix(in oklch, var(--bg-raised) 80%, var(--tui-blue));
border: 1px solid var(--line);
border-radius: 0.35rem;
color: var(--tui-blue);
padding: 0.05rem 0.28rem;
}
:global(.rich-markdown a) {
color: var(--tui-cyan);
}
:global(.rich-markdown .shiki) {
border: 1px solid var(--line);
border-radius: 0.65rem;
overflow: auto;
padding: 0.75rem;
}
</style>
@@ -94,7 +94,11 @@ export function workerConsolePath(
);
}
export type ConsoleEventInput = { eventId: string; event: ProtocolEvent };
export type ConsoleEventInput = {
eventId: string;
event: ProtocolEvent;
observedAtMs?: number;
};
export function emptyConsoleProjection(): ConsoleProjection {
return {
@@ -94,6 +94,33 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
);
});
Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
);
const consoleTimeline = await Deno.readTextFile(
new URL("./ConsoleTimeline.svelte", import.meta.url),
);
assert(
consoleTimeline.includes('class="console-timeline"') &&
consolePage.includes("timelineMarks") &&
consolePage.includes("jumpToTimelineMark") &&
consoleLine.includes("data-console-line-id={item.id}") &&
consolePage.includes("class:timeline-open={timelineOpen}") &&
consolePage.includes("class=\"timeline-fold\"") &&
consolePage.includes("{#if timelineOpen}") &&
consoleTimeline.includes(".timeline-thumb") &&
consoleTimeline.includes(".timeline-card"),
"Worker Console should expose a foldable timeline with scroll and line jump markers",
);
});
Deno.test("Worker Console composer fits to content without manual resize", async () => {
const consolePage = await Deno.readTextFile(
new URL(
@@ -101,13 +128,11 @@ Deno.test("Worker Console composer fits to content without manual resize", async
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"),
consolePage.includes(".console-composer textarea") &&
consolePage.includes("resize: none") &&
consolePage.includes("overflow-y: hidden"),
"Console composer should autosize to content, cap at ten rows, and disable manual resize",
);
});
@@ -221,9 +246,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
);
assert(
consolePage.includes("workspaceApiPath(workspaceId, path)") &&
consolePage.includes(
"workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)",
),
consolePage.includes("workerApiPath(") &&
consolePage.includes("`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(") &&
consolePage.includes("target.workerId"),
"Worker detail should use the scoped backend Worker detail API",
);
assert(
@@ -242,9 +267,8 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
"reload token advancement should not synchronously read and write the rune state",
);
assert(
consolePage.includes(
"advanceReloadToken();\n void loadConsoleData(target);",
) &&
consolePage.includes("advanceReloadToken();") &&
consolePage.includes("void loadConsoleData(target);") &&
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
"target-change effect should load data without depending on manual refresh state reads",
);