web: show Worker tasks in Console

This commit is contained in:
2026-08-14 12:46:59 +09:00
parent e582babae3
commit 93eea24420
8 changed files with 780 additions and 26 deletions
@@ -0,0 +1,183 @@
<script lang="ts">
import { taskCounts, type ConsoleTask } from "./tasks.ts";
type Props = {
tasks: ConsoleTask[];
mode: "mini" | "pane";
};
let { tasks, mode }: Props = $props();
const counts = $derived(taskCounts(tasks));
const activeTasks = $derived(
tasks
.filter((task) => task.status === "pending" || task.status === "inprogress")
.slice(0, 3),
);
function mark(status: ConsoleTask["status"]): string {
switch (status) {
case "pending":
return "[ ]";
case "inprogress":
return "[~]";
case "completed":
return "[x]";
case "deleted":
return "[-]";
}
}
</script>
{#if mode === "mini" && tasks.length > 0}
<section class="task-mini" aria-label="Worker task summary">
{#each activeTasks as task (task.taskid)}
<div class="task-mini-row">
<span class:inprogress={task.status === "inprogress"} class="task-mark">
{mark(task.status)}
</span>
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
</div>
{/each}
<div class="task-summary">
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
</div>
</section>
{:else if mode === "pane"}
<aside class="task-pane" aria-label="Worker tasks">
<h3>Tasks ({counts.total})</h3>
{#if tasks.length === 0}
<p class="task-empty">(no tasks)</p>
{:else}
<ol class="task-list">
{#each tasks as task (task.taskid)}
{@const subjectLines = task.subject.split("\n")}
<li>
<div class="task-heading">
<span class="task-id">#{task.taskid}</span>
<span
class:inprogress={task.status === "inprogress"}
class:completed={task.status === "completed"}
class:deleted={task.status === "deleted"}
class="task-mark"
>{mark(task.status)}</span>
<span>{subjectLines[0] ?? ""}</span>
</div>
{#each subjectLines.slice(1) as subjectLine}
<div class="task-continuation">{subjectLine}</div>
{/each}
{#if task.description}
{#each task.description.split("\n") as descriptionLine}
<div class="task-continuation task-description">{descriptionLine}</div>
{/each}
{/if}
</li>
{/each}
</ol>
{/if}
</aside>
{/if}
<style>
.task-mini,
.task-pane {
font-family: var(--font-mono);
}
.task-mini {
display: grid;
gap: 0.1rem;
min-width: 0;
margin-bottom: -0.75rem;
padding-inline: 0.75rem;
font-size: 0.8rem;
line-height: 1.35;
}
.task-mini-row,
.task-heading {
display: flex;
min-width: 0;
gap: 0.5rem;
}
.task-mark,
.task-id {
flex: 0 0 auto;
color: var(--text-muted);
white-space: nowrap;
}
.task-mark.inprogress {
color: var(--warning);
font-weight: 700;
}
.task-mark.completed {
color: var(--success);
}
.task-mark.deleted {
color: var(--danger);
}
.task-subject,
.task-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-summary,
.task-id,
.task-empty,
.task-description {
color: var(--text-muted);
}
.task-pane {
min-width: 0;
min-height: 0;
overflow: auto;
padding-inline: 1rem;
border-left: 1px solid var(--line);
}
.task-pane h3 {
margin: 0 0 1rem;
color: var(--accent);
font-size: 0.9rem;
}
.task-empty {
margin: 0;
}
.task-list {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.task-heading {
align-items: baseline;
}
.task-continuation {
padding-left: 4ch;
white-space: pre-wrap;
}
.task-description {
font-size: 0.8rem;
line-height: 1.45;
}
@media (max-width: 900px) {
.task-pane {
display: none;
}
}
</style>
@@ -1126,3 +1126,68 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
"Grep should relativize line-start cwd paths and keep outside paths absolute",
);
});
Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => {
const projection = projectConsole([
{
eventId: "task-create",
event: {
event: "tool_call_done",
data: {
id: "task-create-1",
name: "TaskCreate",
arguments: JSON.stringify({
subject: "Port Tasks",
description: "Render active Worker tasks",
}),
},
} satisfies Event,
},
{
eventId: "task-update",
event: {
event: "tool_call_done",
data: {
id: "task-update-1",
name: "TaskUpdate",
arguments: JSON.stringify({ taskid: 1, status: "inprogress" }),
},
} satisfies Event,
},
]);
assertEquals(projection.tasks, [{
taskid: 1,
status: "inprogress",
subject: "Port Tasks",
description: "Render active Worker tasks",
}]);
});
Deno.test("snapshot restores TaskStore state from system history", () => {
const taskSnapshot =
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``;
const event = snapshotEvent("/repo");
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
event.data.entries = [{
kind: "segment_start",
ts: 1,
session_id: "00000000-0000-0000-0000-000000000001",
system_prompt: null,
config: {},
history: [{
kind: "message",
role: "system",
content: [{ kind: "text", text: taskSnapshot }],
}],
}];
const projection = projectConsole([{ eventId: "task-snapshot", event }]);
assertEquals(projection.tasks, [{
taskid: 3,
status: "pending",
subject: "Restored",
description: "From compaction",
}]);
assertEquals(projection.taskNextId, 4);
});
@@ -6,6 +6,11 @@ import type {
Segment,
} from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyTaskSnapshotText,
applyTaskToolCall,
type ConsoleTask,
} from "./tasks.ts";
export type ConsoleLineKind =
| "user"
@@ -60,6 +65,8 @@ export type ConsoleLine = {
export type ConsoleProjection = {
lines: ConsoleLine[];
tasks: ConsoleTask[];
taskNextId: number;
status: string | null;
usage: string | null;
cwd: string | null;
@@ -138,6 +145,8 @@ export type ConsoleEventInput = {
export function emptyConsoleProjection(): ConsoleProjection {
return {
lines: [],
tasks: [],
taskNextId: 1,
status: null,
usage: null,
cwd: null,
@@ -189,6 +198,8 @@ export function applyProtocolEvent(
): ConsoleProjection {
const next: ConsoleProjection = {
lines: [...projection.lines],
tasks: [...projection.tasks],
taskNextId: projection.taskNextId,
status: projection.status,
usage: projection.usage,
cwd: projection.cwd,
@@ -209,6 +220,7 @@ export function applyProtocolEvent(
break;
case "system_item":
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
applyTaskSystemItem(next, event.data.item);
break;
case "text_delta":
appendStreaming(
@@ -267,6 +279,7 @@ export function applyProtocolEvent(
argsStream: event.data.arguments,
state: "running",
});
applyTaskTool(next, event.data.name, event.data.arguments);
break;
case "tool_result":
attachToolResult(next, envelope.eventId, event.data.id, {
@@ -291,26 +304,36 @@ export function applyProtocolEvent(
),
);
break;
case "snapshot":
case "snapshot": {
next.status = event.data.status;
next.cwd = event.data.greeting.cwd;
next.lines = snapshotLinesFromEntries(
const snapshot = snapshotProjectionFromEntries(
envelope.eventId,
event.data.entries,
next.cwd,
);
next.lines = snapshot.lines;
next.tasks = snapshot.tasks;
next.taskNextId = snapshot.taskNextId;
for (const block of event.data.in_flight?.blocks ?? []) {
next.lines.push(inFlightLine(envelope.eventId, block, next.cwd));
}
break;
}
case "status":
next.status = event.data.status;
break;
case "segment_rotated":
next.lines = snapshotLinesFromEntries(envelope.eventId, [
event.data.entry,
], next.cwd);
case "segment_rotated": {
const segment = snapshotProjectionFromEntries(
envelope.eventId,
[event.data.entry],
next.cwd,
);
next.lines = segment.lines;
next.tasks = segment.tasks;
next.taskNextId = segment.taskNextId;
break;
}
case "invoke_start":
case "turn_start":
case "turn_end":
@@ -1111,13 +1134,47 @@ function usageText(
} · cache ${data.cache_read_input_tokens ?? "unknown"}`;
}
function snapshotLinesFromEntries(
function applyTaskTool(
projection: ConsoleProjection,
name: string,
argumentsJson: string,
): void {
const state = applyTaskToolCall(
{ tasks: projection.tasks, nextTaskId: projection.taskNextId },
name,
argumentsJson,
);
projection.tasks = state.tasks;
projection.taskNextId = state.nextTaskId;
}
function applyTaskSnapshot(projection: ConsoleProjection, text: string): void {
const state = applyTaskSnapshotText(
{ tasks: projection.tasks, nextTaskId: projection.taskNextId },
text,
);
projection.tasks = state.tasks;
projection.taskNextId = state.nextTaskId;
}
function applyTaskSystemItem(
projection: ConsoleProjection,
item: unknown,
): void {
if (!isRecord(item)) return;
const body = item["body"];
if (typeof body === "string") applyTaskSnapshot(projection, body);
}
function snapshotProjectionFromEntries(
eventId: string,
entries: unknown[],
cwd: string | null,
): ConsoleLine[] {
): ConsoleProjection {
const projection: ConsoleProjection = {
lines: [],
tasks: [],
taskNextId: 1,
status: null,
usage: null,
cwd,
@@ -1126,7 +1183,7 @@ function snapshotLinesFromEntries(
entries.forEach((entry, index) =>
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
);
return projection.lines;
return projection;
}
function applyLogEntry(
@@ -1153,6 +1210,7 @@ function applyLogEntry(
break;
case "system_item":
projection.lines.push(systemItemLine(eventId, entry["item"]));
applyTaskSystemItem(projection, entry["item"]);
break;
case "assistant_item":
case "tool_result":
@@ -1224,6 +1282,9 @@ function applyLoggedItem(
case "assistant":
projection.lines.push(line(eventId, "assistant", "assistant", body));
break;
case "system":
applyTaskSnapshot(projection, body);
break;
default:
break;
}
@@ -1238,19 +1299,23 @@ function applyLoggedItem(
}
break;
}
case "tool_call":
case "tool_call": {
const name = stringField(item, "name") ?? "Tool";
const argumentsJson = stringField(item, "arguments") ?? "";
upsertToolCall(
projection,
eventId,
stringField(item, "call_id") ?? eventId,
{
name: stringField(item, "name") ?? "Tool",
arguments: stringField(item, "arguments") ?? "",
argsStream: stringField(item, "arguments") ?? "",
name,
arguments: argumentsJson,
argsStream: argumentsJson,
state: "running",
},
);
applyTaskTool(projection, name, argumentsJson);
break;
}
case "tool_result":
attachToolResult(
projection,
@@ -0,0 +1,134 @@
declare const Deno: {
test(name: string, fn: () => void): void;
};
import {
applyTaskSnapshotText,
applyTaskToolCall,
emptyConsoleTaskState,
taskCounts,
} from "./tasks.ts";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("TaskCreate and TaskUpdate mirror the active TUI task store", () => {
let state = emptyConsoleTaskState();
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject: "first", description: "First detail" }),
);
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject: "second", description: "Second detail" }),
);
state = applyTaskToolCall(
state,
"TaskUpdate",
JSON.stringify({ taskid: 2, status: "inprogress", subject: "working" }),
);
assert(state.tasks.length === 2, "both active tasks should remain visible");
assert(
state.tasks[0].taskid === 1,
"TaskCreate should allocate sequential ids",
);
assert(
state.tasks[1].status === "inprogress",
"TaskUpdate should change status",
);
assert(
state.tasks[1].subject === "working",
"TaskUpdate should change subject",
);
assert(
state.tasks[1].description === "Second detail",
"omitted fields should keep their prior values",
);
const counts = taskCounts(state.tasks);
assert(counts.pending === 1, "one task should be pending");
assert(counts.inprogress === 1, "one task should be in progress");
});
Deno.test("completed and deleted tasks remain in the TUI-style store", () => {
let state = emptyConsoleTaskState();
for (const subject of ["complete", "delete"]) {
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject, description: "detail" }),
);
}
state = applyTaskToolCall(
state,
"TaskUpdate",
JSON.stringify({ taskid: 1, status: "completed" }),
);
state = applyTaskToolCall(
state,
"TaskUpdate",
JSON.stringify({ taskid: 2, status: "deleted" }),
);
assert(
state.tasks.length === 2,
"the full TaskStore should retain inactive tasks",
);
const counts = taskCounts(state.tasks);
assert(counts.completed === 1, "completed tasks should remain counted");
assert(counts.deleted === 1, "deleted tasks should remain counted");
assert(counts.active === 0, "inactive tasks should not be shown as active");
});
Deno.test("session TaskStore snapshot replaces stale state and advances ids", () => {
let state = applyTaskToolCall(
emptyConsoleTaskState(),
"TaskCreate",
JSON.stringify({ subject: "stale", description: "" }),
);
const snapshot = `[Session TaskStore snapshot]
TaskStore: 1 active task(s) (pending: 0, inprogress: 1)
\`\`\`json
{
"tasks": [
{"taskid": 4, "status": "completed", "subject": "old", "description": ""},
{"taskid": 7, "status": "inprogress", "subject": "restored", "description": "detail"}
]
}
\`\`\`
`;
state = applyTaskSnapshotText(state, snapshot);
assert(
state.tasks.length === 2,
"snapshot should restore the full TaskStore",
);
assert(state.tasks[0].taskid === 4, "completed tasks should remain restored");
assert(state.tasks[1].taskid === 7, "active tasks should remain restored");
assert(state.nextTaskId === 8, "next id should follow the highest task id");
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject: "next", description: "" }),
);
assert(state.tasks[2].taskid === 8, "new task should use the advanced id");
});
Deno.test("malformed task events are resilient no-ops", () => {
const state = emptyConsoleTaskState();
assert(
applyTaskToolCall(state, "TaskCreate", '{"subject":1}') === state,
"invalid TaskCreate should be ignored",
);
assert(
applyTaskToolCall(state, "TaskUpdate", "not json") === state,
"invalid TaskUpdate should be ignored",
);
assert(
applyTaskSnapshotText(state, "ordinary system message") === state,
"unrelated system messages should be ignored",
);
});
@@ -0,0 +1,228 @@
export type ConsoleTaskStatus =
| "pending"
| "inprogress"
| "completed"
| "deleted";
type SnapshotTask = ConsoleTask;
export type ConsoleTask = {
taskid: number;
status: ConsoleTaskStatus;
subject: string;
description: string;
};
export type ConsoleTaskState = {
tasks: ConsoleTask[];
nextTaskId: number;
};
type TaskUpdate = {
taskid: number;
status?: ConsoleTaskStatus;
subject?: string;
description?: string;
};
export function emptyConsoleTaskState(): ConsoleTaskState {
return { tasks: [], nextTaskId: 1 };
}
export function applyTaskToolCall(
state: ConsoleTaskState,
name: string,
argumentsJson: string,
): ConsoleTaskState {
const argumentsValue = parseRecord(argumentsJson);
if (!argumentsValue) return state;
if (name === "TaskCreate") {
const subject = stringField(argumentsValue, "subject");
const description = stringField(argumentsValue, "description");
if (subject === undefined || description === undefined) return state;
return {
tasks: [
...state.tasks,
{
taskid: state.nextTaskId,
status: "pending",
subject,
description,
},
],
nextTaskId: state.nextTaskId + 1,
};
}
if (name !== "TaskUpdate") return state;
const update = taskUpdate(argumentsValue);
if (!update) return state;
const index = state.tasks.findIndex((task) => task.taskid === update.taskid);
if (index < 0) return state;
const current = state.tasks[index];
const status = update.status ?? current.status;
const tasks = [...state.tasks];
tasks[index] = {
taskid: current.taskid,
status,
subject: update.subject ?? current.subject,
description: update.description ?? current.description,
};
return { tasks, nextTaskId: state.nextTaskId };
}
export function applyTaskSnapshotText(
state: ConsoleTaskState,
text: string,
): ConsoleTaskState {
const tasks = parseTaskSnapshotText(text);
if (!tasks) return state;
return {
tasks,
nextTaskId: Math.max(1, ...tasks.map((task) => task.taskid + 1)),
};
}
export function parseTaskSnapshotText(text: string): ConsoleTask[] | null {
if (!text.startsWith("[Session TaskStore snapshot]")) return null;
const startMarker = "```json\n";
const start = text.indexOf(startMarker);
if (start < 0) return null;
const jsonStart = start + startMarker.length;
const end = text.indexOf("\n```", jsonStart);
if (end < 0) return null;
let value: unknown;
try {
value = JSON.parse(text.slice(jsonStart, end));
} catch {
return null;
}
if (!isRecord(value) || !Array.isArray(value.tasks)) return null;
const tasks: ConsoleTask[] = [];
for (const candidate of value.tasks) {
const task = taskEntry(candidate);
if (!task) return null;
tasks.push(task);
}
return tasks;
}
export function taskCounts(tasks: ConsoleTask[]): {
pending: number;
inprogress: number;
completed: number;
deleted: number;
active: number;
total: number;
} {
let pending = 0;
let inprogress = 0;
let completed = 0;
let deleted = 0;
for (const task of tasks) {
switch (task.status) {
case "pending":
pending += 1;
break;
case "inprogress":
inprogress += 1;
break;
case "completed":
completed += 1;
break;
case "deleted":
deleted += 1;
break;
}
}
return {
pending,
inprogress,
completed,
deleted,
active: pending + inprogress,
total: tasks.length,
};
}
function taskEntry(value: unknown): SnapshotTask | null {
if (!isRecord(value)) return null;
const taskid = integerField(value, "taskid");
const status = statusField(value, "status");
const subject = stringField(value, "subject");
const description = stringField(value, "description");
if (
taskid === undefined ||
status === undefined ||
subject === undefined ||
description === undefined
) return null;
return { taskid, status, subject, description };
}
function taskUpdate(value: Record<string, unknown>): TaskUpdate | null {
const taskid = integerField(value, "taskid");
if (taskid === undefined) return null;
const statusValue = value.status;
const status = statusValue === undefined
? undefined
: statusField(value, "status");
if (statusValue !== undefined && status === undefined) return null;
const subjectValue = value.subject;
const subject = subjectValue === undefined
? undefined
: stringField(value, "subject");
if (subjectValue !== undefined && subject === undefined) return null;
const descriptionValue = value.description;
const description = descriptionValue === undefined
? undefined
: stringField(value, "description");
if (descriptionValue !== undefined && description === undefined) return null;
return { taskid, status, subject, description };
}
function parseRecord(value: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(value);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringField(
value: Record<string, unknown>,
key: string,
): string | undefined {
return typeof value[key] === "string" ? value[key] : undefined;
}
function integerField(
value: Record<string, unknown>,
key: string,
): number | undefined {
const field = value[key];
return typeof field === "number" && Number.isSafeInteger(field) && field >= 0
? field
: undefined;
}
function statusField(
value: Record<string, unknown>,
key: string,
): ConsoleTaskStatus | undefined {
const field = value[key];
return field === "pending" ||
field === "inprogress" ||
field === "completed" ||
field === "deleted"
? field
: undefined;
}
@@ -775,3 +775,41 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
);
});
Deno.test("Web Console renders the client-projected Worker task store", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const tasksComponent = await Deno.readTextFile(
new URL("./ConsoleTasks.svelte", import.meta.url),
);
const tasksModel = await Deno.readTextFile(
new URL("./tasks.ts", import.meta.url),
);
assert(
consolePage.includes("ConsoleTasks") &&
consolePage.includes("consoleProjection.tasks") &&
consolePage.includes("taskPaneOpen"),
"Console should expose the projected task store through its existing client model",
);
assert(
tasksComponent.includes("[ ]") &&
tasksComponent.includes("[~]") &&
tasksComponent.includes("[x]") &&
tasksComponent.includes("[-]") &&
tasksComponent.includes("task(s) — pending:") &&
tasksComponent.includes("task.description"),
"Tasks UI should mirror the TUI status marks, summary, and descriptions",
);
assert(
tasksModel.includes('name === "TaskCreate"') &&
tasksModel.includes('name !== "TaskUpdate"') &&
tasksModel.includes("[Session TaskStore snapshot]") &&
!consolePage.includes("fetchTasks"),
"Task projection should replay the protocol client-side without adding a task API",
);
});
@@ -1,6 +1,7 @@
<script lang="ts">
import { tick, untrack } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import { chatSubmit } from "$lib/workspace/console/chat-submit";
import {
@@ -114,6 +115,7 @@
} | null = null;
let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false);
let timelineOpen = $state(false);
let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null;
@@ -148,6 +150,7 @@
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
const lines = $derived(consoleProjection.lines);
const tasks = $derived(consoleProjection.tasks);
const timelineLayout = $derived(
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
);
@@ -1086,6 +1089,7 @@
const targetWorker = data.worker;
const targetWorkerError = data.workerError;
resetObservedEvents();
taskPaneOpen = false;
worker = targetWorker;
workerError = targetWorkerError;
liveWorkerState = targetWorker?.state ?? null;
@@ -1155,11 +1159,25 @@
>
Rewind
</button>
<button
type="button"
class="secondary-button"
aria-expanded={taskPaneOpen}
onclick={() => {
taskPaneOpen = !taskPaneOpen;
if (taskPaneOpen) workerDetailsOpen = false;
}}
>
Tasks{tasks.length > 0 ? ` ${tasks.length}` : ""}
</button>
<button
type="button"
class="secondary-button"
aria-expanded={workerDetailsOpen}
onclick={() => (workerDetailsOpen = !workerDetailsOpen)}
onclick={() => {
workerDetailsOpen = !workerDetailsOpen;
if (workerDetailsOpen) taskPaneOpen = false;
}}
>
Details
</button>
@@ -1192,7 +1210,8 @@
</section>
{/if}
<section class:timeline-open={timelineOpen} class="console-body">
<div class:with-task-pane={taskPaneOpen} class="console-history">
<section class:timeline-open={timelineOpen} class="console-body">
<div class="console-timeline-spacer" aria-hidden="true"></div>
<div class="timeline-fold-cell">
<button
@@ -1227,15 +1246,20 @@
</article>
</div>
<ConsoleTimeline
marks={timelineMarks}
thumbStyle={timelineThumb}
axisStyle={timelineAxisStyle}
expanded={timelineOpen}
onRailPointerDown={handleTimelineRailPointerDown}
onMarkClick={jumpToTimelineMark}
/>
</section>
<ConsoleTimeline
marks={timelineMarks}
thumbStyle={timelineThumb}
axisStyle={timelineAxisStyle}
expanded={timelineOpen}
onRailPointerDown={handleTimelineRailPointerDown}
onMarkClick={jumpToTimelineMark}
/>
</section>
{#if taskPaneOpen}
<ConsoleTasks {tasks} mode="pane" />
{/if}
</div>
{#if workerDetailsOpen}
<aside class="console-side-panel" aria-label="Worker detail">
@@ -1320,6 +1344,8 @@
</aside>
{/if}
<ConsoleTasks {tasks} mode="mini" />
<form class="console-composer card" onsubmit={sendMessage}>
<div class="composer-input-shell">
<textarea
@@ -1406,7 +1432,7 @@
overflow: hidden;
}
.worker-console-shell > .console-body {
.worker-console-shell > .console-history {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
@@ -1478,6 +1504,17 @@
color: var(--text-muted);
}
.console-history {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(0, 1fr);
}
.console-history.with-task-pane {
grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr);
}
.console-body {
--console-timeline-width: 12rem;
--console-timeline-fold-width: 2.25rem;
@@ -1734,6 +1771,10 @@
}
@media (max-width: 960px) {
.console-history.with-task-pane {
grid-template-columns: minmax(0, 1fr);
}
.console-header {
flex-direction: column;
}