feat: refine workspace console ui

This commit is contained in:
Keisuke Hirata 2026-07-15 22:43:56 +09:00
parent f9f1f85b07
commit 66abd53e51
No known key found for this signature in database
63 changed files with 406 additions and 212 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/chat-submit.test.ts src/lib/workspace-console/composer-command.test.ts src/lib/workspace-console/composer-completion.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/composer-command.test.ts src/lib/workspace/console/composer-completion.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

@ -29,7 +29,7 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
});
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
const layout = await Deno.readTextFile(new URL("../../routes/+layout.ts", import.meta.url));
const layout = await Deno.readTextFile(new URL("./../../../routes/+layout.ts", import.meta.url));
assert(
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
"unscoped layout may use only the workspace-id bootstrap endpoint",
@ -44,7 +44,7 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
);
const unscopedSettings = await Deno.readTextFile(
new URL("../../routes/settings/+page.svelte", import.meta.url),
new URL("./../../../routes/settings/+page.svelte", import.meta.url),
);
assert(
unscopedSettings.includes("Redirecting to scoped settings") &&

View File

@ -1,6 +1,6 @@
<script lang="ts">
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
import type { ConsoleLine } from '$lib/workspace-console/model';
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte';
import type { ConsoleLine } from '$lib/workspace/console/model';
type Props = {
item: ConsoleLine;
@ -73,8 +73,11 @@
<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>
<div class="console-diff" role="group" 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}
</div>
{/if}
{#if item.detail}
<details class="message-detail">
@ -222,6 +225,7 @@
border: 1px solid var(--line);
border-radius: 0.65rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.45;
margin: 0.6rem 0 0;

View File

@ -16,11 +16,19 @@
marks: TimelineMark[];
thumbStyle: string;
axisStyle: string;
onRailClick: (event: MouseEvent) => void;
expanded: boolean;
onRailPointerDown: (event: PointerEvent) => void;
onMarkClick: (mark: TimelineMark) => void;
};
let { marks, thumbStyle, axisStyle, onRailClick, onMarkClick }: Props = $props();
let {
marks,
thumbStyle,
axisStyle,
expanded,
onRailPointerDown,
onMarkClick,
}: Props = $props();
</script>
<aside class="console-timeline" aria-label="Console timeline">
@ -28,8 +36,8 @@
<button
type="button"
class="timeline-rail"
aria-label="Jump within console scrollback"
onclick={onRailClick}
aria-label="Scroll console"
onpointerdown={onRailPointerDown}
>
<span class="timeline-thumb" style={thumbStyle}></span>
</button>
@ -37,7 +45,7 @@
{#each marks as mark (mark.id)}
<button
type="button"
class={`timeline-mark ${mark.kind}`}
class={`timeline-mark ${mark.kind} ${expanded ? 'expanded' : 'folded'}`}
style={`top: ${mark.position}px`}
aria-label={mark.detail ? `${mark.label}: ${mark.detail}` : mark.label}
onclick={() => onMarkClick(mark)}
@ -81,12 +89,14 @@
}
.timeline-rail {
width: 0.35rem;
width: 0.65rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--line) 70%, transparent);
cursor: pointer;
cursor: ns-resize;
padding: 0;
touch-action: none;
user-select: none;
}
.timeline-thumb {
@ -97,6 +107,7 @@
border-radius: inherit;
background: var(--tui-cyan);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-cyan) 30%, transparent);
pointer-events: none;
}
.timeline-marks {
@ -139,7 +150,7 @@
}
.timeline-card {
display: grid;
display: none;
width: 10rem;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
@ -154,6 +165,10 @@
transform: none;
}
.timeline-mark.expanded .timeline-card {
display: grid;
}
.timeline-card span {
overflow: hidden;
text-overflow: ellipsis;

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { markdownToHtml } from "$lib/workspace-console/markdown";
import { markdownToHtml } from "$lib/workspace/console/markdown";
type Props = {
text: string;

View File

@ -5,7 +5,7 @@ import type {
InFlightToolCallState,
Segment,
} from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace-api/http";
import { workspaceRoute } from "$lib/workspace/api/http";
export type ConsoleLineKind =
| "user"

View File

@ -11,19 +11,19 @@ function assert(condition: unknown, message: string): asserts condition {
Deno.test("workspace Worker list lives on the dedicated Workers page", async () => {
const workspacePage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
new URL("./../../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
);
const workersPage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/workers/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/+page.svelte",
import.meta.url,
),
);
const workersNav = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
new URL("../sidebar/WorkersNavSection.svelte", import.meta.url),
);
const sidebar = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url),
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
assert(
@ -59,7 +59,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
@ -94,10 +94,24 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
);
});
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
);
assert(
consoleLine.includes('<div class="console-diff" role="group" aria-label="Edit diff">') &&
consoleLine.includes("{#each item.diff as diffLine}") &&
consoleLine.includes("class={`diff-line ${diffLine.kind}`}") &&
!consoleLine.includes('<pre class="console-diff"'),
"Edit diff rows should not be wrapped in a pre element that preserves template whitespace as blank lines",
);
});
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",
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
@ -114,7 +128,15 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
consoleLine.includes("data-console-line-id={item.id}") &&
consolePage.includes("class:timeline-open={timelineOpen}") &&
consolePage.includes("class=\"timeline-fold\"") &&
consolePage.includes("{#if timelineOpen}") &&
consolePage.includes("expanded={timelineOpen}") &&
!consolePage.includes("{#if timelineOpen}") &&
consolePage.includes("handleTimelineRailPointerDown") &&
consolePage.includes("projectTimelineAxisPosition") &&
consolePage.includes("scrollbar-width: none") &&
consoleTimeline.includes("onpointerdown={onRailPointerDown}") &&
consoleTimeline.includes("expanded ? 'expanded' : 'folded'") &&
consoleTimeline.includes(".timeline-mark.expanded .timeline-card") &&
!consoleTimeline.includes("{#if expanded}") &&
consoleTimeline.includes(".timeline-thumb") &&
consoleTimeline.includes(".timeline-card"),
"Worker Console should expose a foldable timeline with scroll and line jump markers",
@ -124,23 +146,33 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
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",
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
assert(
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
consolePage.includes("class=\"composer-input-shell\" onclick={handleComposerShellClick}") &&
consolePage.includes("bind:this={composerTextareaElement}") &&
consolePage.includes("composerTextareaElement?.focus()") &&
consolePage.includes('event.key === "PageUp" || event.key === "PageDown"') &&
consolePage.includes("scrollConsoleByPage") &&
consolePage.includes("class=\"composer-input-footer\"") &&
consolePage.includes("class=\"composer-footer-slot\"") &&
consolePage.includes("class=\"composer-send-button\"") &&
consolePage.includes("class=\"composer-send-icon\"") &&
consolePage.includes('d="M8 6L12 2L16 6"') &&
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",
"Console composer should autosize to content, cap at ten rows, wrap input and icon send button, and disable manual resize",
);
});
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
const editor = await Deno.readTextFile(
new URL(
"../workspace-settings/DecodalSourceEditor.svelte",
"../settings/DecodalSourceEditor.svelte",
import.meta.url,
),
);
@ -156,26 +188,26 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
const sidebar = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url),
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
const runtimesNav = await Deno.readTextFile(
new URL("../workspace-sidebar/RuntimesNavSection.svelte", import.meta.url),
new URL("../sidebar/RuntimesNavSection.svelte", import.meta.url),
);
const runtimesPage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/+page.svelte",
"./../../../routes/w/[workspaceId]/runtimes/+page.svelte",
import.meta.url,
),
);
const workdirsPage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
import.meta.url,
),
);
const workdirsLoad = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
import.meta.url,
),
);
@ -202,11 +234,11 @@ Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned
Deno.test("workspace Worker sidebar links New to the dedicated create page", async () => {
const workersNav = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
new URL("../sidebar/WorkersNavSection.svelte", import.meta.url),
);
const newWorkerPage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/workers/new/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/new/+page.svelte",
import.meta.url,
),
);
@ -228,13 +260,13 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const routeLoad = await Deno.readTextFile(
new URL(
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
import.meta.url,
),
);

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type { RepositoryTicketsResponse, TicketSummary } from '$lib/workspace-sidebar/types';
import type { RepositoryTicketsResponse, TicketSummary } from '$lib/workspace/sidebar/types';
const INITIAL_VISIBLE_ROWS = 30;
const VISIBLE_ROW_INCREMENT = 30;

View File

@ -1,7 +1,7 @@
import {
workspaceApiJson,
workspaceApiJsonWithBody,
} from "../workspace-api/http";
} from "../api/http";
import type {
ProfileSettingsMutationResponse,
ProfileSettingsResponse,

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace-api/http';
import { workspaceRoute } from '$lib/workspace/api/http';
type Props = {
currentPath?: string;

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { workspaceApiPath } from '$lib/workspace-api/http';
import { workerConsoleHref } from '$lib/workspace-console/model';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { workerConsoleHref } from '$lib/workspace/console/model';
import { canShowWorkerInSidebar } from './workers';
import type { ListResponse, Worker } from './types';

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace-api/http';
import { workspaceRoute } from '$lib/workspace/api/http';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import RuntimesNavSection from './RuntimesNavSection.svelte';

View File

@ -1,4 +1,4 @@
import { workspaceRoute } from "$lib/workspace-api/http";
import { workspaceRoute } from "$lib/workspace/api/http";
import type { Diagnostic, RepositoryListResponse } from "./types";
export type RepositoryNavItem = {

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import WorkspaceAlerts from '$lib/workspace-alerts/WorkspaceAlerts.svelte';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '../app.css';
import type { LayoutProps } from './$types';

View File

@ -1,6 +1,6 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace-sidebar/types";
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types";
export const ssr = false;

View File

@ -1,5 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { Host, ListResponse } from "$lib/workspace-sidebar/types";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { Host, ListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,5 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { ObjectiveListResponse } from "$lib/workspace-sidebar/types";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ObjectiveListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,8 +1,8 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
ObjectiveDetail,
ObjectiveListResponse,
} from "$lib/workspace-sidebar/types";
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { formatDate } from '$lib/workspace-api/http';
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
import { formatDate } from '$lib/workspace/api/http';
import RepositoryTicketKanban from '$lib/workspace/pages/RepositoryTicketKanban.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,9 +1,9 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
RepositoryDetailResponse,
RepositoryLogResponse,
RepositoryTicketsResponse,
} from "$lib/workspace-sidebar/types";
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type { Runtime } from '$lib/workspace-sidebar/types';
import type { Runtime } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,5 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { ListResponse, Runtime } from "$lib/workspace-sidebar/types";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ListResponse, Runtime } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {

View File

@ -1,12 +1,12 @@
<script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
import { workspaceApiPath } from '$lib/workspace-api/http';
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import { workspaceApiPath } from '$lib/workspace/api/http';
import type {
CleanupWorkdirCandidate,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
WorkingDirectorySummary,
} from '$lib/workspace-sidebar/types';
} from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,10 +1,10 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
BrowserWorkingDirectoryListResponse,
ListResponse,
Runtime,
RuntimeCleanupPlanResponse,
} from "$lib/workspace-sidebar/types";
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {

View File

@ -1,31 +1,31 @@
<script lang="ts">
import { tick } from "svelte";
import ConsoleLineItem from "$lib/workspace-console/ConsoleLineItem.svelte";
import ConsoleTimeline from "$lib/workspace-console/ConsoleTimeline.svelte";
import { chatSubmit } from "$lib/workspace-console/chat-submit";
import { buildComposerRequest } from "$lib/workspace-console/composer-command";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import { chatSubmit } from "$lib/workspace/console/chat-submit";
import { buildComposerRequest } from "$lib/workspace/console/composer-command";
import {
applyCompletion,
completionTokenAt,
localCommandCompletions,
type ComposerCompletionEntry,
type ComposerCompletionToken,
} from "$lib/workspace-console/composer-completion";
import { fitTextarea } from "$lib/workspace-console/textarea-fit";
} from "$lib/workspace/console/composer-completion";
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
import {
createConsoleProjector,
type ConsoleEventInput,
type ConsoleLine,
type ConsoleProjection,
} from "$lib/workspace-console/model";
import { workspaceApiPath } from "$lib/workspace-api/http";
} from "$lib/workspace/console/model";
import { workspaceApiPath } from "$lib/workspace/api/http";
import type {
ClientWorkerEventWsFrame,
Diagnostic,
Worker,
WorkerInputResult,
PodProtocolEvent,
} from "$lib/workspace-sidebar/types";
} from "$lib/workspace/sidebar/types";
type Props = {
data: {
@ -106,6 +106,8 @@
let workerDetailsOpen = $state(false);
let timelineOpen = $state(false);
let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null;
let timelineRailDragCleanup: (() => void) | null = null;
let autoFollowConsole = $state(true);
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
const eventObservedAtById = new Map<string, number>();
@ -365,6 +367,11 @@
}
function handleComposerKeydown(event: KeyboardEvent) {
if (event.key === "PageUp" || event.key === "PageDown") {
event.preventDefault();
scrollConsoleByPage(event.key === "PageDown" ? 1 : -1);
return;
}
if (event.key !== "Tab") {
return;
}
@ -372,6 +379,28 @@
void applyComposerCompletion(event);
}
function scrollConsoleByPage(direction: 1 | -1) {
if (!consoleBodyElement) {
return;
}
consoleBodyElement.scrollBy({
top: direction * Math.max(consoleBodyElement.clientHeight * 0.86, 1),
behavior: "auto",
});
window.requestAnimationFrame(updateConsoleScrollMetrics);
}
function handleComposerShellClick(event: MouseEvent) {
const target = event.target;
if (
target instanceof Element &&
target.closest("button, textarea, a")
) {
return;
}
composerTextareaElement?.focus();
}
async function submitDraft(value = draft) {
const command = buildComposerRequest(value);
if (!command.ok) {
@ -534,7 +563,7 @@
return {
scale,
axisSize,
marks: positioned,
marks: projectTimelineMarks(positioned, axisSize, trackHeight),
};
}
@ -576,6 +605,28 @@
return positioned;
}
function projectTimelineMarks(
marks: TimelineMark[],
axisSize: number,
trackHeight: number,
): TimelineMark[] {
return marks.map((mark) => ({
...mark,
position: projectTimelineAxisPosition(mark.position, axisSize, trackHeight),
}));
}
function projectTimelineAxisPosition(
axisPosition: number,
axisSize: number,
trackHeight: number,
): number {
if (axisSize <= 0) {
return 0;
}
return (axisPosition / axisSize) * trackHeight;
}
function timelineAxisSize(
marks: TimelineMark[],
trackHeight: number,
@ -748,6 +799,7 @@
metrics: ScrollMetrics,
layout: TimelineLayout,
): string {
const trackHeight = timelineTrackHeight(metrics);
const contentHeight = Math.max(metrics.height, 1);
const viewportHeight = Math.max(metrics.client, 1);
const scrollable = Math.max(contentHeight - viewportHeight, 1);
@ -757,9 +809,19 @@
const sourceBottom = Math.min(100, sourceTop + viewportRatio * 100);
const targetTop = mapTimelinePosition(layout.scale, sourceTop);
const targetBottom = mapTimelinePosition(layout.scale, sourceBottom);
const height = Math.max(targetBottom - targetTop, 18);
const top = Math.min(targetTop, Math.max(0, layout.axisSize - height));
return `top: ${Math.max(0, top)}px; height: ${Math.min(height, layout.axisSize)}px;`;
const projectedTop = projectTimelineAxisPosition(
targetTop,
layout.axisSize,
trackHeight,
);
const projectedBottom = projectTimelineAxisPosition(
targetBottom,
layout.axisSize,
trackHeight,
);
const height = Math.max(projectedBottom - projectedTop, 18);
const top = Math.min(projectedTop, Math.max(0, trackHeight - height));
return `top: ${Math.max(0, top)}px; height: ${Math.min(height, trackHeight)}px;`;
}
function timelineTrackHeight(metrics: ScrollMetrics): number {
@ -767,16 +829,12 @@
}
function timelineAxisStyleFor(
layout: TimelineLayout,
_layout: TimelineLayout,
metrics: ScrollMetrics,
): string {
const trackHeight = timelineTrackHeight(metrics);
const scrollable = Math.max(metrics.height - metrics.client, 1);
const scrollRatio = Math.max(0, Math.min(1, metrics.top / scrollable));
const offset = Math.max(0, layout.axisSize - trackHeight) * scrollRatio;
return [
`height: ${layout.axisSize}px`,
`top: ${TIMELINE_AXIS_PADDING_PX - offset}px`,
`height: ${timelineTrackHeight(metrics)}px`,
`top: ${TIMELINE_AXIS_PADDING_PX}px`,
].join("; ");
}
@ -789,21 +847,21 @@
}
}
function jumpTimelineRatio(event: MouseEvent) {
if (
!consoleBodyElement ||
!(event.currentTarget instanceof HTMLElement)
) {
function scrollConsoleToTimelineRailPosition(
rail: HTMLElement,
clientY: number,
behavior: ScrollBehavior,
) {
if (!consoleBodyElement) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const targetPx = Math.max(
0,
Math.min(rect.height, event.clientY - rect.top),
);
const rect = rail.getBoundingClientRect();
const trackHeight = Math.max(rect.height, 1);
const targetPx = Math.max(0, Math.min(trackHeight, clientY - rect.top));
const axisPosition = (targetPx / trackHeight) * timelineLayout.axisSize;
const sourcePercent = unmapTimelinePosition(
timelineLayout.scale,
targetPx,
axisPosition,
);
consoleBodyElement.scrollTo({
top:
@ -813,10 +871,50 @@
consoleBodyElement.clientHeight,
0,
),
behavior: "smooth",
behavior,
});
}
function handleTimelineRailPointerDown(event: PointerEvent) {
if (!(event.currentTarget instanceof HTMLElement)) {
return;
}
event.preventDefault();
const rail = event.currentTarget;
const pointerId = event.pointerId;
scrollConsoleToTimelineRailPosition(rail, event.clientY, "auto");
const handleMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
moveEvent.preventDefault();
scrollConsoleToTimelineRailPosition(rail, moveEvent.clientY, "auto");
};
const stopDrag = (finishEvent: PointerEvent) => {
if (finishEvent.pointerId !== pointerId) {
return;
}
timelineRailDragCleanup?.();
};
timelineRailDragCleanup?.();
timelineRailDragCleanup = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", stopDrag);
window.removeEventListener("pointercancel", stopDrag);
timelineRailDragCleanup = null;
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", stopDrag);
window.addEventListener("pointercancel", stopDrag);
try {
rail.setPointerCapture(pointerId);
} catch {
// Pointer capture can fail if the pointer is already released.
}
}
function cssEscape(value: string): string {
return typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(value)
@ -877,6 +975,10 @@
}
});
$effect(() => {
return () => timelineRailDragCleanup?.();
});
$effect(() => {
const target = consoleTarget;
resetObservedEvents();
@ -955,15 +1057,14 @@
</article>
</div>
{#if timelineOpen}
<ConsoleTimeline
marks={timelineMarks}
thumbStyle={timelineThumb}
axisStyle={timelineAxisStyle}
onRailClick={jumpTimelineRatio}
onMarkClick={jumpToTimelineMark}
/>
{/if}
<ConsoleTimeline
marks={timelineMarks}
thumbStyle={timelineThumb}
axisStyle={timelineAxisStyle}
expanded={timelineOpen}
onRailPointerDown={handleTimelineRailPointerDown}
onMarkClick={jumpToTimelineMark}
/>
</section>
{#if workerDetailsOpen}
@ -1053,39 +1154,58 @@
{/if}
<form class="console-composer card" onsubmit={sendMessage}>
<textarea
id="worker-console-message"
aria-label="Console input"
aria-keyshortcuts="Meta+Enter Control+Enter"
bind:value={draft}
use:chatSubmit={{
enabled: inputReady && !sending,
onSubmit: (value) => void submitDraft(value),
}}
use:fitTextarea={{ value: draft, maxRows: 10 }}
onkeydown={handleComposerKeydown}
disabled={!inputReady || sending}></textarea>
{#if completionBusy || completionError || completionEntries.length > 0}
<div class="composer-completions" aria-live="polite">
{#if completionBusy}
<span>completing…</span>
{:else if completionError}
<span class="error">{completionError}</span>
{:else}
<span
>Tab: {completionToken?.sigil}{completionEntries[0]
?.value}</span
>
{#if completionEntries.length > 1}
<span>{completionEntries.length - 1} more</span>
<div class="composer-input-shell" onclick={handleComposerShellClick}>
<textarea
id="worker-console-message"
aria-label="Console input"
aria-keyshortcuts="Meta+Enter Control+Enter"
bind:this={composerTextareaElement}
bind:value={draft}
use:chatSubmit={{
enabled: inputReady && !sending,
onSubmit: (value) => void submitDraft(value),
}}
use:fitTextarea={{ value: draft, maxRows: 10 }}
onkeydown={handleComposerKeydown}
disabled={!inputReady || sending}></textarea>
<div class="composer-input-footer">
<div class="composer-footer-slot">
{#if completionBusy || completionError || completionEntries.length > 0}
<div class="composer-completions" aria-live="polite">
{#if completionBusy}
<span>completing…</span>
{:else if completionError}
<span class="error">{completionError}</span>
{:else}
<span
>Tab: {completionToken?.sigil}{completionEntries[0]
?.value}</span
>
{#if completionEntries.length > 1}
<span>{completionEntries.length - 1} more</span>
{/if}
{/if}
</div>
{/if}
{/if}
</div>
<button
class="composer-send-button"
type="submit"
aria-label={sending ? "Sending message" : "Send message"}
disabled={!canSend}
>
<svg
class="composer-send-icon"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path d="M8 6L12 2L16 6" />
<path d="M12 2V22" />
</svg>
</button>
</div>
{/if}
</div>
<div class="composer-actions">
<button type="submit" disabled={!canSend}>
{sending ? "Sending…" : "Send"}
</button>
{#if composerNotice}
<span class="composer-notice">{composerNotice}</span>
{/if}
@ -1203,32 +1323,13 @@
min-width: 0;
min-height: 0;
overflow-y: auto;
padding-right: var(--space-2);
scrollbar-color: color-mix(in srgb, var(--tui-cyan) 60%, var(--line))
color-mix(in srgb, var(--bg-raised) 70%, transparent);
scrollbar-gutter: stable;
scrollbar-width: thin;
padding-right: 0;
scrollbar-gutter: auto;
scrollbar-width: none;
}
.console-scroll::-webkit-scrollbar {
width: 0.65rem;
}
.console-scroll::-webkit-scrollbar-track {
border-radius: 999px;
background: color-mix(in srgb, var(--bg-raised) 70%, transparent);
}
.console-scroll::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: color-mix(in srgb, var(--tui-cyan) 60%, var(--line));
background-clip: padding-box;
}
.console-scroll::-webkit-scrollbar-thumb:hover {
background: var(--tui-cyan);
background-clip: padding-box;
display: none;
}
.console-log {
@ -1309,25 +1410,82 @@
background: var(--bg);
}
.composer-input-shell {
display: grid;
gap: var(--space-2);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--bg-raised);
cursor: text;
padding: 0.35rem;
}
.composer-input-shell:focus-within {
border-color: color-mix(in srgb, var(--tui-cyan) 60%, var(--line));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-cyan) 18%, transparent);
}
.composer-input-footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: end;
min-height: 2.35rem;
padding-left: 0.65rem;
}
.composer-footer-slot {
min-width: 0;
}
.console-composer textarea {
box-sizing: border-box;
width: 100%;
min-height: 0;
resize: none;
overflow-y: hidden;
border: 1px solid var(--line);
border: 0;
border-radius: 14px;
padding: 0.85rem 1rem;
background: transparent;
padding: 0.55rem 0.65rem;
font: inherit;
line-height: 1.45;
color: var(--text-strong);
outline: none;
}
.console-composer textarea:disabled {
background: var(--bg-raised);
color: var(--text-muted);
}
.composer-send-button {
display: inline-grid;
width: 2.35rem;
height: 2.35rem;
place-items: center;
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--bg);
cursor: pointer;
padding: 0;
}
.composer-send-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.composer-send-icon {
width: 1.2rem;
height: 1.2rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.composer-completions {
display: flex;
flex-wrap: wrap;
@ -1354,21 +1512,6 @@
margin-right: auto;
}
.composer-actions button {
border: 0;
border-radius: 999px;
padding: 0.65rem 1rem;
background: var(--accent);
color: var(--bg);
font-weight: 800;
cursor: pointer;
}
.composer-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (max-width: 960px) {
.console-header {
flex-direction: column;

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { workspaceRoute } from '$lib/workspace-api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace-settings/model';
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace-api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace-settings/model';
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,20 +1,20 @@
<script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import DecodalSourceEditor from '$lib/workspace-settings/DecodalSourceEditor.svelte';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import DecodalSourceEditor from '$lib/workspace/settings/DecodalSourceEditor.svelte';
import {
fetchProfileSettings,
fetchProfileSourceTree,
deleteProfileTreeFile,
fetchProfileTreeFile,
writeProfileTreeFile,
} from '$lib/workspace-settings/profile-api';
import type { Diagnostic } from '$lib/workspace-settings/model';
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
} from '$lib/workspace/settings/profile-api';
import type { Diagnostic } from '$lib/workspace/settings/model';
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from '$lib/workspace/settings/profile-routes';
import type {
ProfileSettingsResponse,
WorkspaceProfileSourceTreeFileResponse,
WorkspaceProfileSourceTreeResponse,
} from '$lib/workspace-settings/profile-types';
} from '$lib/workspace/settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,18 +1,18 @@
<script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import DecodalSourceEditor from '$lib/workspace-settings/DecodalSourceEditor.svelte';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import DecodalSourceEditor from '$lib/workspace/settings/DecodalSourceEditor.svelte';
import {
deleteProfileTreeFile,
fetchProfileSourceTree,
fetchProfileTreeFile,
writeProfileTreeFile,
} from '$lib/workspace-settings/profile-api';
import { profileSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
import type { Diagnostic } from '$lib/workspace-settings/model';
} from '$lib/workspace/settings/profile-api';
import { profileSettingsHref, virtualProfilePathForCreate } from '$lib/workspace/settings/profile-routes';
import type { Diagnostic } from '$lib/workspace/settings/model';
import type {
WorkspaceProfileSourceTreeFileResponse,
WorkspaceProfileSourceTreeResponse,
} from '$lib/workspace-settings/profile-types';
} from '$lib/workspace/settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,12 +1,12 @@
<script lang="ts">
import { workspaceApiPath } from '$lib/workspace-api/http';
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import { workspaceApiPath } from '$lib/workspace/api/http';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import type {
Diagnostic,
RemoteRuntimeTestResponse,
RuntimeConnectionMutationResponse,
RuntimeConnectionSettingsResponse
} from '$lib/workspace-settings/model';
} from '$lib/workspace/settings/model';
import type { PageProps } from './$types';
type RemoteAddForm = {

View File

@ -1,11 +1,11 @@
<script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadataSettings,
updateWorkspaceMetadataSettings
} from '$lib/workspace-settings/profile-api';
import type { Diagnostic } from '$lib/workspace-settings/model';
import type { WorkspaceMetadataSettingsResponse } from '$lib/workspace-settings/profile-types';
} from '$lib/workspace/settings/profile-api';
import type { Diagnostic } from '$lib/workspace/settings/model';
import type { WorkspaceMetadataSettingsResponse } from '$lib/workspace/settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();

View File

@ -1,9 +1,9 @@
<script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace-alerts/store';
import { workspaceApiPath } from '$lib/workspace-api/http';
import { workerConsoleHref } from '$lib/workspace-console/model';
import { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers';
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace-sidebar/types';
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { workerConsoleHref } from '$lib/workspace/console/model';
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
type WorkerActionKind = 'pin' | 'delete';

View File

@ -1,5 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace-sidebar/types";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {

View File

@ -1,13 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { workspaceApiPath } from '$lib/workspace-api/http';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace-sidebar/worker-launch';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
import type {
BrowserCreateWorkerResponse,
BrowserWorkingDirectoryCreateResponse,
Diagnostic,
WorkerLaunchOptionsResponse,
} from '$lib/workspace-sidebar/types';
} from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
type DisplayError = {