style: format web workspace

This commit is contained in:
Keisuke Hirata 2026-07-24 17:54:16 +09:00
parent 1c2cb01882
commit 54bb7209a4
No known key found for this signature in database
32 changed files with 974 additions and 529 deletions

View File

@ -2,7 +2,8 @@
SvelteKit static SPA for the Yoi workspace control plane.
The frontend is intentionally static. Workspace authority, validation, and API behavior live in the Rust `yoi-workspace-server` backend.
The frontend is intentionally static. Workspace authority, validation, and API
behavior live in the Rust `yoi-workspace-server` backend.
## Development
@ -22,7 +23,9 @@ cd web/workspace
deno task dev
```
The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot reload works while the Rust backend serves the workspace API. Open the Vite URL printed by `deno task dev`.
The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot
reload works while the Rust backend serves the workspace API. Open the Vite URL
printed by `deno task dev`.
If you want to run the backend from the repository root instead:
@ -30,7 +33,10 @@ If you want to run the backend from the repository root instead:
cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787
```
The backend reads Workspace records from the Yoi server DB at `<data_dir>/server/server.db`. Run `cargo run -p yoi-workspace-server -- init --workspace .` first when the server DB has not been initialized.
The backend reads Workspace records from the Yoi server DB at
`<data_dir>/server/server.db`. Run
`cargo run -p yoi-workspace-server -- init --workspace .` first when the server
DB has not been initialized.
## Static build
@ -40,7 +46,8 @@ Build the SPA:
deno task build
```
Static asset packaging is a deployment concern. Local development normally uses the Vite dev server proxy plus the Rust backend command above.
Static asset packaging is a deployment concern. Local development normally uses
the Vite dev server proxy plus the Rust backend command above.
## Checks

View File

@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />

View File

@ -12,112 +12,294 @@ export type WorkerStatus = "idle" | "running" | "paused";
export type TurnResult = "finished" | "paused";
export type InvokeKind = "user_send" | "notify" | "worker_event" | "system_reminder" | "wakeup";
export type InvokeKind =
| "user_send"
| "notify"
| "worker_event"
| "system_reminder"
| "wakeup";
export type RunResult = "finished" | "paused" | "limit_reached" | "rolled_back";
export type ErrorCode = "already_running" | "not_running" | "not_paused" | "provider_error" | "tool_error" | "invalid_request" | "internal";
export type ErrorCode =
| "already_running"
| "not_running"
| "not_paused"
| "provider_error"
| "tool_error"
| "invalid_request"
| "internal";
export type Permission = "read" | "write";
export type InFlightToolCallState = "pending" | "streaming_args" | "done";
export type ScopeRule = {
/**
/**
* Target path. Must be absolute by the time a `Scope` is built from
* this rule relative paths are resolved per-layer against the
* manifest file's directory (cwd for overlay layers) before cascade
* merge.
*/
target: string,
/**
target: string;
/**
* Permission level this rule grants (allow) or caps strictly below
* (deny).
*/
permission: Permission,
/**
permission: Permission;
/**
* When `false`, the rule only matches the target itself and its
* direct children. Defaults to `true`.
*/
recursive: boolean, };
recursive: boolean;
};
export type CompletionEntry = { value: string, is_dir: boolean, };
export type CompletionEntry = { value: string; is_dir: boolean };
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
export type RewindTargetId = {
segment_id: string;
user_input_entry_index: number;
};
export type RewindTarget = { id: RewindTargetId, expected_head_entries: number, truncate_entries: number, turn_index: number, timestamp_ms: number | null, preview: string, eligible: boolean, disabled_reason: string | null, warning: string | null, };
export type RewindTarget = {
id: RewindTargetId;
expected_head_entries: number;
truncate_entries: number;
turn_index: number;
timestamp_ms: number | null;
preview: string;
eligible: boolean;
disabled_reason: string | null;
warning: string | null;
};
export type RewindSummary = { truncated_to_entries: number, discarded_entries: number, tool_side_effect_warning: boolean, };
export type RewindSummary = {
truncated_to_entries: number;
discarded_entries: number;
tool_side_effect_warning: boolean;
};
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
export type InFlightBlock =
| { "kind": "text"; text: string; finished?: boolean }
| { "kind": "thinking"; text: string; finished?: boolean }
| {
"kind": "tool_call";
id: string;
name: string;
args: string;
state?: InFlightToolCallState;
};
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, };
export type InFlightSnapshot = { blocks?: Array<InFlightBlock> };
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>,
/**
export type Greeting = {
worker_name: string;
cwd: string;
provider: string;
model: string;
scope_summary: string;
tools: Array<string>;
/**
* Model context window in tokens. Always filled by the Worker greeting.
*/
context_window: number,
/**
context_window: number;
/**
* Estimated current session context tokens at connect time.
*/
context_tokens: number, };
context_tokens: number;
};
export type Alert = { level: AlertLevel, source: AlertSource, message: string,
/**
export type Alert = {
level: AlertLevel;
source: AlertSource;
message: string;
/**
* Milliseconds since the Unix epoch.
*/
timestamp_ms: number, };
timestamp_ms: number;
};
export type MemoryWorkerEvent = { worker: string, status: string, run_id: string, trigger: string, reason: string,
/**
export type MemoryWorkerEvent = {
worker: string;
status: string;
run_id: string;
trigger: string;
reason: string;
/**
* Human-readable compact form for actionbar rendering.
*/
message: string,
/**
message: string;
/**
* Milliseconds since the Unix epoch.
*/
timestamp_ms: number, };
timestamp_ms: number;
};
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "unknown" };
export type Segment =
| { "kind": "text"; content: string }
| {
"kind": "paste";
id: number;
chars: number;
lines: number;
content: string;
}
| { "kind": "file_ref"; path: string }
| { "kind": "unknown" };
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
/**
export type WorkerEvent =
| { "kind": "turn_ended"; worker_name: string }
| { "kind": "errored"; worker_name: string; message: string }
| { "kind": "shut_down"; worker_name: string }
| {
"kind": "scope_sub_delegated";
/**
* Sub-delegating Worker (= the sender itself).
*/
parent_worker: string,
/**
parent_worker: string;
/**
* Name of the grandchild Worker.
*/
sub_worker: string,
/**
sub_worker: string;
/**
* Unix-socket path where the grandchild is reachable.
*/
sub_socket: string,
/**
sub_socket: string;
/**
* Scope delegated to the grandchild.
*/
scope: Array<ScopeRule>, };
scope: Array<ScopeRule>;
};
export type Method = { "method": "run", "params": { input: Array<Segment>, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Method =
| { "method": "run"; "params": { input: Array<Segment> } }
| { "method": "notify"; "params": { message: string; auto_run?: boolean } }
| { "method": "worker_event"; "params": WorkerEvent }
| { "method": "resume" }
| { "method": "cancel" }
| { "method": "pause" }
| { "method": "compact" }
| { "method": "list_rewind_targets" }
| {
"method": "rewind_to";
"params": { target: RewindTargetId; expected_head_entries: number };
}
| { "method": "shutdown" }
| {
"method": "list_completions";
"params": { kind: CompletionKind; prefix: string };
}
| { "method": "list_workers" }
| { "method": "restore_worker"; "params": { name: string } }
| { "method": "register_peer"; "params": { name: string } };
export type Event = { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
/**
export type Event =
| { "event": "user_message"; "data": { segments: Array<Segment> } }
| { "event": "system_item"; "data": { item: unknown } }
| { "event": "invoke_start"; "data": { kind: InvokeKind } }
| { "event": "turn_start"; "data": { turn: number } }
| { "event": "turn_end"; "data": { turn: number; result: TurnResult } }
| { "event": "llm_call_start"; "data": { llm_call: number } }
| { "event": "llm_call_end"; "data": { llm_call: number } }
| {
"event": "llm_retry";
"data": {
llm_call: number;
/**
* The attempt that just failed. 1 origin.
*/
failed_attempt: number, max_attempts: number, wait_ms: number, elapsed_ms: number, status?: number | null, error: string, } } | { "event": "llm_continuation", "data": { llm_call: number, attempt: number, max_attempts: number, reason: string, } } | { "event": "text_delta", "data": { text: string, } } | { "event": "text_done", "data": { text: string, } } | { "event": "thinking_start" } | { "event": "thinking_delta", "data": { text: string, } } | { "event": "thinking_done", "data": { text: string, } } | { "event": "tool_call_start", "data": { id: string, name: string, } } | { "event": "tool_call_args_delta", "data": { id: string, json: string, } } | { "event": "tool_call_done", "data": { id: string, name: string, arguments: string, } } | { "event": "tool_result", "data": { id: string,
/**
failed_attempt: number;
max_attempts: number;
wait_ms: number;
elapsed_ms: number;
status?: number | null;
error: string;
};
}
| {
"event": "llm_continuation";
"data": {
llm_call: number;
attempt: number;
max_attempts: number;
reason: string;
};
}
| { "event": "text_delta"; "data": { text: string } }
| { "event": "text_done"; "data": { text: string } }
| { "event": "thinking_start" }
| { "event": "thinking_delta"; "data": { text: string } }
| { "event": "thinking_done"; "data": { text: string } }
| { "event": "tool_call_start"; "data": { id: string; name: string } }
| { "event": "tool_call_args_delta"; "data": { id: string; json: string } }
| {
"event": "tool_call_done";
"data": { id: string; name: string; arguments: string };
}
| {
"event": "tool_result";
"data": {
id: string;
/**
* Short human-readable summary. Always present; used by clients
* that only want a 1-line rendering (e.g. collapsed views).
*/
summary: string,
/**
summary: string;
/**
* Full tool output. Absent when the tool chose to return
* summary-only, or when the result was pruned.
*/
output?: string | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array<unknown>, greeting: Greeting, status: WorkerStatus,
/**
output?: string | null;
is_error: boolean;
};
}
| {
"event": "usage";
"data": {
input_tokens: number | null;
output_tokens: number | null;
cache_read_input_tokens?: number | null;
};
}
| { "event": "run_end"; "data": { result: RunResult } }
| { "event": "error"; "data": { code: ErrorCode; message: string } }
| {
"event": "snapshot";
"data": {
entries: Array<unknown>;
greeting: Greeting;
status: WorkerStatus;
/**
* Unfinished model output that has already streamed in the current
* run but is not yet represented by committed snapshot entries.
*/
in_flight?: InFlightSnapshot, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
in_flight?: InFlightSnapshot;
};
}
| { "event": "segment_rotated"; "data": { entry: unknown } }
| { "event": "status"; "data": { status: WorkerStatus } }
| {
"event": "completions";
"data": { kind: CompletionKind; entries: Array<CompletionEntry> };
}
| {
"event": "rewind_targets";
"data": { head_entries: number; targets: Array<RewindTarget> };
}
| {
"event": "rewind_applied";
"data": {
entries: Array<unknown>;
input: Array<Segment>;
summary: RewindSummary;
};
}
| { "event": "workers_listed"; "data": { workers: unknown } }
| { "event": "worker_restored"; "data": { result: unknown } }
| { "event": "peer_registered"; "data": { result: unknown } }
| { "event": "alert"; "data": Alert }
| { "event": "memory_worker"; "data": MemoryWorkerEvent }
| { "event": "compact_start" }
| { "event": "compact_done"; "data": { new_segment_id: string } }
| { "event": "compact_failed"; "data": { error: string } }
| { "event": "shutdown" };

View File

@ -1,4 +1,10 @@
export type WorkspaceAlertLevel = "success" | "info" | "warning" | "error" | "system" | "debug";
export type WorkspaceAlertLevel =
| "success"
| "info"
| "warning"
| "error"
| "system"
| "debug";
export type WorkspaceAlert = {
id: string;
@ -32,7 +38,8 @@ export function pushWorkspaceAlert(
message: string,
options: { title?: string; id?: string } = {},
): string {
const id = options.id ?? `${Date.now().toString(36)}-${(sequence++).toString(36)}`;
const id = options.id ??
`${Date.now().toString(36)}-${(sequence++).toString(36)}`;
alerts = [
...alerts.filter((alert) => alert.id !== id),
{

View File

@ -1,20 +1,22 @@
import {
authenticationCredentialToJson,
isPublicKeyCredential,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type DeviceApprovalResponse,
isPublicKeyCredential,
type PasskeyLoginOptionsResponse,
type PasskeyRegistrationOptionsResponse,
type PasskeyUserResponse,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type WhoamiResponse,
} from "./model";
async function jsonOrThrow<T>(response: Response): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`);
throw new Error(
`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`,
);
}
return text ? JSON.parse(text) as T : (null as T);
}
@ -23,8 +25,12 @@ function browserOrigin(): string | null {
return globalThis.location?.origin ?? null;
}
export async function loadWhoami(fetcher: typeof fetch = fetch): Promise<WhoamiResponse> {
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>);
export async function loadWhoami(
fetcher: typeof fetch = fetch,
): Promise<WhoamiResponse> {
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(
jsonOrThrow<WhoamiResponse>,
);
}
export async function registerPasskey(
@ -36,14 +42,20 @@ export async function registerPasskey(
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ handle, display_name: displayName, browser_origin: browserOrigin() }),
body: JSON.stringify({
handle,
display_name: displayName,
browser_origin: browserOrigin(),
}),
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
const credential = await navigator.credentials.create({
publicKey: prepareRegistrationOptions(options),
});
if (!isPublicKeyCredential(credential)) {
throw new Error("Passkey registration did not return a public-key credential.");
throw new Error(
"Passkey registration did not return a public-key credential.",
);
}
return await fetcher("/api/auth/passkeys/registration/complete", {

View File

@ -11,7 +11,9 @@ declare const Deno: {
function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
throw new Error(
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
@ -19,7 +21,9 @@ function bytes(buffer: BufferSource): number[] {
if (buffer instanceof ArrayBuffer) {
return [...new Uint8Array(buffer)];
}
return [...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)];
return [
...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
];
}
Deno.test("base64url helpers round-trip binary data", () => {
@ -43,13 +47,20 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
displayName: "Local User",
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
excludeCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
excludeCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
},
});
assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.user.id), [4, 5, 6]);
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [7, 8, 9]);
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [
7,
8,
9,
]);
});
Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () => {
@ -57,10 +68,17 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
challenge_id: "challenge-1",
public_key: {
challenge: "AQID" as unknown as BufferSource,
allowCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
allowCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
},
});
assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [7, 8, 9]);
assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [
7,
8,
9,
]);
});

View File

@ -15,12 +15,16 @@ export type WhoamiResponse = {
export type PasskeyRegistrationOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialCreationOptions | { publicKey: PublicKeyCredentialCreationOptions };
public_key: PublicKeyCredentialCreationOptions | {
publicKey: PublicKeyCredentialCreationOptions;
};
};
export type PasskeyLoginOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialRequestOptions | { publicKey: PublicKeyCredentialRequestOptions };
public_key: PublicKeyCredentialRequestOptions | {
publicKey: PublicKeyCredentialRequestOptions;
};
};
export type PasskeyUserResponse = {
@ -65,7 +69,10 @@ export function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
/=+$/g,
"",
);
}
function unwrapPublicKey<T>(value: T | { publicKey: T }): T {
@ -79,12 +86,16 @@ export function prepareRegistrationOptions(
options: PasskeyRegistrationOptionsResponse,
): PublicKeyCredentialCreationOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
publicKey.challenge = base64UrlToBuffer(
publicKey.challenge as unknown as string,
);
publicKey.user = {
...publicKey.user,
id: base64UrlToBuffer(publicKey.user.id as unknown as string),
};
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((credential) => ({
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((
credential,
) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
@ -95,8 +106,12 @@ export function prepareLoginOptions(
options: PasskeyLoginOptionsResponse,
): PublicKeyCredentialRequestOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
publicKey.allowCredentials = publicKey.allowCredentials?.map((credential) => ({
publicKey.challenge = base64UrlToBuffer(
publicKey.challenge as unknown as string,
);
publicKey.allowCredentials = publicKey.allowCredentials?.map((
credential,
) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
@ -131,11 +146,15 @@ export function authenticationCredentialToJson(
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
userHandle: response.userHandle
? bufferToBase64Url(response.userHandle)
: null,
},
};
}
export function isPublicKeyCredential(credential: Credential | null): credential is PublicKeyCredential {
export function isPublicKeyCredential(
credential: Credential | null,
): credential is PublicKeyCredential {
return credential != null && credential.type === "public-key";
}

View File

@ -53,9 +53,16 @@ Deno.test("shouldSubmitChatKey still supports explicit Cmd+Enter behavior", () =
});
Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () => {
const options = { mode: "mod-enter" as const, modKey: "meta" as const, enabled: true };
const options = {
mode: "mod-enter" as const,
modKey: "meta" as const,
enabled: true,
};
assertEquals(
shouldSubmitChatKey({ key: "Enter", metaKey: true, isComposing: true }, options),
shouldSubmitChatKey(
{ key: "Enter", metaKey: true, isComposing: true },
options,
),
false,
);
assertEquals(
@ -69,7 +76,14 @@ Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () =
});
Deno.test("shouldSubmitChatKey supports enter submit mode", () => {
const options = { mode: "enter" as const, modKey: "meta" as const, enabled: true };
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);
assertEquals(
shouldSubmitChatKey({ key: "Enter", shiftKey: true }, options),
false,
);
});

View File

@ -23,7 +23,9 @@ export type ChatSubmitKeyEventLike = {
which?: number;
};
function normalizeOptions(options: ChatSubmitOptions): NormalizedChatSubmitOptions {
function normalizeOptions(
options: ChatSubmitOptions,
): NormalizedChatSubmitOptions {
return {
mode: "mod-enter",
modKey: "auto",
@ -47,7 +49,8 @@ function isApplePlatform(): boolean {
}
const platform = navigator.platform || "";
const userAgent = navigator.userAgent || "";
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac|iPhone|iPad|iPod/.test(userAgent);
return /Mac|iPhone|iPad|iPod/.test(platform) ||
/Mac|iPhone|iPad|iPod/.test(userAgent);
}
function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" {
@ -57,8 +60,13 @@ function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" {
return modKey;
}
function isModPressed(event: ChatSubmitKeyEventLike, modKey: ChatSubmitModKey): boolean {
return resolveModKey(modKey) === "meta" ? event.metaKey === true : event.ctrlKey === true;
function isModPressed(
event: ChatSubmitKeyEventLike,
modKey: ChatSubmitModKey,
): boolean {
return resolveModKey(modKey) === "meta"
? event.metaKey === true
: event.ctrlKey === true;
}
export function shouldSubmitChatKey(
@ -79,7 +87,10 @@ export function shouldSubmitChatKey(
return isModPressed(event, options.modKey);
}
export function chatSubmit(node: HTMLTextAreaElement, options: ChatSubmitOptions) {
export function chatSubmit(
node: HTMLTextAreaElement,
options: ChatSubmitOptions,
) {
let current = normalizeOptions(options);
let isComposing = false;

View File

@ -16,5 +16,8 @@ Deno.test("parseSigilSegments turns file sigils into file refs", () => {
});
Deno.test("parseSigilSegments leaves hash sigils as plain text", () => {
assertEquals(parseSigilSegments("ask #memory"), [{ kind: "text", content: "ask #memory" }]);
assertEquals(parseSigilSegments("ask #memory"), [{
kind: "text",
content: "ask #memory",
}]);
});

View File

@ -25,7 +25,8 @@ type CommandSpec = {
const COMMANDS: Record<string, CommandSpec> = {
help: {
usage: ":help [command]",
description: "Show available Web Console commands or details for one command.",
description:
"Show available Web Console commands or details for one command.",
},
"?": {
usage: ":? [command]",
@ -49,7 +50,8 @@ const COMMANDS: Record<string, CommandSpec> = {
},
peer: {
usage: ":peer <worker-name>",
description: "Register another existing Worker as a reciprocal metadata peer.",
description:
"Register another existing Worker as a reciprocal metadata peer.",
},
system: {
usage: ":system <message>",
@ -71,7 +73,9 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
request: {
kind: "user",
content,
segments: segments.some((segment) => segment.kind !== "text") ? segments : undefined,
segments: segments.some((segment) => segment.kind !== "text")
? segments
: undefined,
},
};
}
@ -97,13 +101,21 @@ function buildColonCommand(commandLine: string): ComposerCommandResult {
if (argv.length > 0) {
return invalidUsage("compact");
}
return { ok: true, request: { kind: "compact", content: "" }, notice: "compact requested" };
return {
ok: true,
request: { kind: "compact", content: "" },
notice: "compact requested",
};
case "rewind":
case "rollback":
if (argv.length > 0) {
return invalidUsage("rewind");
}
return { ok: true, request: { kind: "list_rewind_targets", content: "" }, notice: "rewind targets requested" };
return {
ok: true,
request: { kind: "list_rewind_targets", content: "" },
notice: "rewind targets requested",
};
case "peer":
if (argv.length !== 1) {
return invalidUsage("peer");
@ -156,7 +168,10 @@ function helpCommand(argv: string[]): ComposerCommandResult {
}
function invalidUsage(name: string): ComposerCommandResult {
return { ok: false, message: `Invalid arguments. Usage: ${COMMANDS[name].usage}` };
return {
ok: false,
message: `Invalid arguments. Usage: ${COMMANDS[name].usage}`,
};
}
export function parseSigilSegments(input: string): Segment[] {
@ -178,7 +193,9 @@ export function parseSigilSegments(input: string): Segment[] {
if (cursor < input.length) {
segments.push({ kind: "text", content: input.slice(cursor) });
}
return coalesceTextSegments(segments.length > 0 ? segments : [{ kind: "text", content: input }]);
return coalesceTextSegments(
segments.length > 0 ? segments : [{ kind: "text", content: input }],
);
}
function sigilSegment(sigil: string, value: string): Segment {

View File

@ -1,11 +1,11 @@
import type { Event } from "$lib/generated/protocol";
import {
type ConsoleLine,
createConsoleProjector,
projectConsole,
segmentsToText,
selectConsoleTimelineLines,
workerConsoleHref,
type ConsoleLine,
} from "./model.ts";
declare const Deno: {
@ -68,8 +68,6 @@ Deno.test("workerConsoleHref encodes runtime and worker target authority", () =>
);
});
Deno.test("projectConsole projects visible protocol rows", () => {
const projection = projectConsole([
{
@ -192,7 +190,8 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
data: {
id: "call-1",
summary: "command completed",
output: "/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12",
output:
"/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12",
is_error: false,
},
} satisfies Event,
@ -269,7 +268,10 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
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('"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");
@ -305,7 +307,10 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
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("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");
@ -840,7 +845,9 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
assertEquals(projection.status, "running");
assertEquals(
projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`),
projection.lines.map((line) =>
`${line.kind}:${line.body}:${line.streaming}`
),
[
"user:seed user:false",
"user:new user:false",
@ -903,7 +910,9 @@ Deno.test("selectConsoleTimelineLines keeps all users and only last assistant pe
];
assertEquals(
selectConsoleTimelineLines(items).map(({ item, index }) => `${index}:${item.id}`),
selectConsoleTimelineLines(items).map(({ item, index }) =>
`${index}:${item.id}`
),
["0:u1", "3:a2", "4:u2", "6:a4"],
);
});
@ -940,7 +949,10 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
data: {
id: "write-rel",
name: "Write",
arguments: JSON.stringify({ file_path: "/repo/out.txt", content: "ok" }),
arguments: JSON.stringify({
file_path: "/repo/out.txt",
content: "ok",
}),
},
} satisfies Event,
},
@ -1001,7 +1013,8 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
data: {
id: "glob-rel",
summary: "Found 2 file(s) matching **/*.rs",
output: "Found 2 file(s) matching **/*.rs\n/repo/src/main.rs\n/outside/lib.rs",
output:
"Found 2 file(s) matching **/*.rs\n/repo/src/main.rs\n/outside/lib.rs",
is_error: false,
},
} satisfies Event,
@ -1031,14 +1044,18 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
},
]);
const bodies = projection.lines.filter((line) => line.kind === "tool").map((line) => line.body);
const bodies = projection.lines.filter((line) => line.kind === "tool").map((
line,
) => line.body);
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
assert(
projection.lines[0].detail?.includes("from src/main.rs"),
"Read summary detail path should be relative",
);
assert(
bodies.some((body) => body.includes("Write — out.txt") && body.includes("Wrote out.txt")),
bodies.some((body) =>
body.includes("Write — out.txt") && body.includes("Wrote out.txt")
),
"Write header and known result path should be relative",
);
assert(
@ -1055,7 +1072,8 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
);
assert(
bodies.some((body) =>
body.includes("src/main.rs:12:needle") && body.includes("/outside/lib.rs:1:needle")
body.includes("src/main.rs:12:needle") &&
body.includes("/outside/lib.rs:1:needle")
),
"Grep should relativize line-start cwd paths and keep outside paths absolute",
);

View File

@ -1,6 +1,6 @@
import type {
Event as ProtocolEvent,
Alert,
Event as ProtocolEvent,
InFlightBlock,
InFlightToolCallState,
Segment,
@ -148,7 +148,10 @@ export function emptyConsoleProjection(): ConsoleProjection {
export function projectConsole(
events: ConsoleEventInput[] = [],
): ConsoleProjection {
const projection = events.reduce(applyProtocolEvent, emptyConsoleProjection());
const projection = events.reduce(
applyProtocolEvent,
emptyConsoleProjection(),
);
return projectVisibleConsole(projection);
}
@ -171,7 +174,9 @@ export function createConsoleProjector() {
};
}
function projectVisibleConsole(projection: ConsoleProjection): ConsoleProjection {
function projectVisibleConsole(
projection: ConsoleProjection,
): ConsoleProjection {
return {
...projection,
lines: aggregateReadToolLines(projection.lines),
@ -302,7 +307,9 @@ export function applyProtocolEvent(
next.status = event.data.status;
break;
case "segment_rotated":
next.lines = snapshotLinesFromEntries(envelope.eventId, [event.data.entry], next.cwd);
next.lines = snapshotLinesFromEntries(envelope.eventId, [
event.data.entry,
], next.cwd);
break;
case "invoke_start":
case "turn_start":
@ -730,7 +737,9 @@ function readDetail(toolCall: ToolCallView): string {
`state: ${stateSuffix(toolCall.state)}`,
`path: ${readPath(toolCall)}`,
toolCall.summary
? `summary: ${normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)}`
? `summary: ${
normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)
}`
: undefined,
]);
}
@ -902,7 +911,9 @@ function toolCallDetail(toolCall: ToolCallView): string {
`id: ${toolCall.id}`,
`state: ${stateSuffix(toolCall.state)}`,
toolCall.summary
? `summary: ${normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)}`
? `summary: ${
normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)
}`
: undefined,
argsText(toolCall) ? `arguments:\n${argsText(toolCall)}` : undefined,
]);
@ -1155,7 +1166,8 @@ function applyExtensionEntry(
}
const blockId = stringField(payload, "block_id") || "compact";
const state = stringField(payload, "state") || "running";
const message = stringField(payload, "message") || compactMessageForState(state, payload);
const message = stringField(payload, "message") ||
compactMessageForState(state, payload);
upsertStatusLine(
projection,
blockId,
@ -1204,26 +1216,38 @@ function applyLoggedItem(
break;
}
case "reasoning": {
const text = stringField(item, "text") ?? arrayField(item, "summary").filter((value) => typeof value === "string").join("\n");
const text = stringField(item, "text") ??
arrayField(item, "summary").filter((value) => typeof value === "string")
.join("\n");
if (text) {
projection.lines.push(line(eventId, "thinking", "Thought", text));
}
break;
}
case "tool_call":
upsertToolCall(projection, eventId, stringField(item, "call_id") ?? eventId, {
upsertToolCall(
projection,
eventId,
stringField(item, "call_id") ?? eventId,
{
name: stringField(item, "name") ?? "Tool",
arguments: stringField(item, "arguments") ?? "",
argsStream: stringField(item, "arguments") ?? "",
state: "running",
});
},
);
break;
case "tool_result":
attachToolResult(projection, eventId, stringField(item, "call_id") ?? eventId, {
attachToolResult(
projection,
eventId,
stringField(item, "call_id") ?? eventId,
{
summary: stringField(item, "summary") ?? "",
output: stringField(item, "content"),
isError: item["is_error"] === true,
});
},
);
break;
default:
break;

View File

@ -10,7 +10,9 @@ function assert(condition: unknown, message: string): asserts condition {
}
Deno.test("workspace app css uses bundled UI fonts", async () => {
const appCss = await Deno.readTextFile(new URL("./../../../app.css", import.meta.url));
const appCss = await Deno.readTextFile(
new URL("./../../../app.css", import.meta.url),
);
assert(
appCss.includes("gen-interface-jp/400.css") &&
appCss.includes("gen-interface-jp/500.css") &&
@ -34,12 +36,20 @@ Deno.test("workspace app css uses bundled UI fonts", async () => {
});
Deno.test("workspace feature css is owned outside app css", async () => {
const appCss = await Deno.readTextFile(new URL("./../../../app.css", import.meta.url));
const appCss = await Deno.readTextFile(
new URL("./../../../app.css", import.meta.url),
);
const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
),
);
const settingsLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/settings/+layout.svelte", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/settings/+layout.svelte",
import.meta.url,
),
);
const accountPage = await Deno.readTextFile(
new URL("./../../../routes/account/+page.svelte", import.meta.url),
@ -86,7 +96,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
workspacePage.includes("runtimeSettingsHref") &&
workspacePage.includes("workersHref") &&
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
workspacePage.includes("workspaceRoute(workspaceId, '/settings/runtimes')") &&
workspacePage.includes(
"workspaceRoute(workspaceId, '/settings/runtimes')",
) &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
);
@ -99,7 +111,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
workersPage.includes('<table class="workers-table">') &&
workersPage.includes('class="icon-action"') &&
workersPage.includes('Delete ${worker.label}'),
workersPage.includes("Delete ${worker.label}"),
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
);
assert(
@ -122,10 +134,16 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
);
const ticketsLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.ts", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/tickets/+page.ts",
import.meta.url,
),
);
const ticketsPage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.svelte", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/tickets/+page.svelte",
import.meta.url,
),
);
const ticketDetailLoad = await Deno.readTextFile(
new URL(
@ -146,14 +164,18 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
"Tickets sidebar section should link to the workspace Tickets surface",
);
assert(
ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') &&
ticketsLoad.includes(
'`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`',
) &&
ticketsPage.includes("Notion-style filtering and sorting") &&
ticketsPage.includes("toggleSort('updated_at')") &&
ticketsPage.includes("bind:value={visibilityFilter}") &&
ticketsPage.includes("sortKey = $state<SortKey>('panel')") &&
ticketsPage.includes("workspace_action_priority") &&
ticketsPage.includes("bind:value={stateFilter}") &&
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
ticketsPage.includes(
"workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)",
),
"Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links",
);
assert(
@ -170,10 +192,16 @@ Deno.test("workspace Memory Staging surface uses read-only scoped memory API", a
new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
);
const memoryLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.ts", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/memory/staging/+page.ts",
import.meta.url,
),
);
const memoryPage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.svelte", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/memory/staging/+page.svelte",
import.meta.url,
),
);
assert(
@ -182,7 +210,8 @@ Deno.test("workspace Memory Staging surface uses read-only scoped memory API", a
"Memory sidebar section should link to the workspace Memory Staging surface",
);
assert(
memoryLoad.includes("workspaceApiPath(params.workspaceId, '/memory/staging')") &&
memoryLoad.includes("workspaceApiPath(params.workspaceId") &&
memoryLoad.includes('"/memory/staging"') &&
memoryPage.includes("Memory Staging") &&
memoryPage.includes("Workspace Server memory authority") &&
memoryPage.includes("data.staging.data.invalid_count") &&
@ -236,7 +265,9 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
assert(
consoleLine.includes("function shouldRenderMarkdown") &&
consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes('<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>') &&
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",
@ -249,7 +280,9 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
);
assert(
consoleLine.includes('<div class="console-diff" role="group" aria-label="Edit diff">') &&
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"'),
@ -276,7 +309,7 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
consolePage.includes("jumpToTimelineMark") &&
consoleLine.includes("data-console-line-id={item.id}") &&
consolePage.includes("class:timeline-open={timelineOpen}") &&
consolePage.includes("class=\"timeline-fold\"") &&
consolePage.includes('class="timeline-fold"') &&
consolePage.includes("expanded={timelineOpen}") &&
!consolePage.includes("{#if timelineOpen}") &&
consolePage.includes("handleTimelineRailPointerDown") &&
@ -301,17 +334,19 @@ Deno.test("Worker Console composer fits to content without manual resize", async
);
assert(
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
consolePage.includes("<div class=\"composer-input-shell\">") &&
consolePage.includes('<div class="composer-input-shell">') &&
!consolePage.includes("handleComposerShellClick") &&
consolePage.includes("bind:this={composerTextareaElement}") &&
consolePage.includes('event.key === "PageUp" || event.key === "PageDown"') &&
consolePage.includes(
'event.key === "PageUp" || event.key === "PageDown"',
) &&
consolePage.includes("scrollConsoleByPage") &&
consolePage.includes("class=\"composer-input-footer\"") &&
consolePage.includes('class="composer-input-footer"') &&
consolePage.includes("pointer-events: none") &&
consolePage.includes("class=\"composer-footer-slot\"") &&
consolePage.includes("class=\"composer-send-button\"") &&
consolePage.includes('class="composer-footer-slot"') &&
consolePage.includes('class="composer-send-button"') &&
consolePage.includes("pointer-events: auto") &&
consolePage.includes("class=\"composer-send-icon\"") &&
consolePage.includes('class="composer-send-icon"') &&
consolePage.includes('d="M8 6L12 2L16 6"') &&
consolePage.includes(".console-composer textarea") &&
consolePage.includes("resize: none") &&
@ -366,14 +401,16 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
assert(
!sidebar.includes("RuntimesNavSection") &&
settingsModel.includes('id: "runtime-inventory"') &&
settingsModel.includes('return `${SETTINGS_ROUTE}/runtimes`;'),
settingsModel.includes("return `${SETTINGS_ROUTE}/runtimes`;"),
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
);
assert(
runtimesPage.includes("Runtime Inventory") &&
runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("runtimes-table") &&
runtimesPage.includes("/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs"),
runtimesPage.includes(
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
),
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
);
assert(
@ -431,7 +468,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(") &&
consolePage.includes("`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(") &&
consolePage.includes(
"`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(",
) &&
consolePage.includes("target.workerId"),
"Worker detail should use the scoped backend Worker detail API",
);
@ -460,14 +499,16 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
"target-change effect should load data without depending on manual refresh state reads",
);
assert(
consolePage.includes('const workerRunning = $derived(workerState === "running");') &&
consolePage.includes(
'const workerRunning = $derived(workerState === "running");',
) &&
consolePage.includes(
'const composerEditable = $derived(protocolState === "open" && !sending);',
) &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
consolePage.includes("enabled: canSubmitDraft") &&
consolePage.includes("disabled={!composerEditable}") &&
consolePage.includes('class:stop={workerRunning}') &&
consolePage.includes("class:stop={workerRunning}") &&
consolePage.includes('"Stop Worker"') &&
consolePage.includes("disabled={composerSubmitDisabled}") &&
!consolePage.includes("disabled={!inputReady || sending}") &&
@ -502,7 +543,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
new URL("../sidebar/sidebar.css", import.meta.url),
);
const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
),
);
const workspaceLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url),
@ -562,7 +606,9 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
assert(
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
workspaceLayout.includes("WorkspaceSidebar") &&
workspaceLayout.includes("<SidebarOverride sidebar={workspaceSidebar} />") &&
workspaceLayout.includes(
"<SidebarOverride sidebar={workspaceSidebar} />",
) &&
workspaceLayoutLoad.includes("params.workspaceId") &&
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
@ -592,7 +638,8 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"SidebarOverride should register and clean up the child-provided sidebar snippet",
);
assert(
rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'),
rootLayoutLoad.includes('"/account"') &&
rootLayoutLoad.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace",
);
});

View File

@ -6,7 +6,10 @@ import {
SETTINGS_SECTIONS,
settingsSectionHref,
} from "./model.ts";
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from "./profile-routes.ts";
import {
profileSourceTreeSettingsHref,
virtualProfilePathForCreate,
} from "./profile-routes.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
@ -104,7 +107,6 @@ Deno.test("diagnostic labels preserve severity and code", () => {
);
});
Deno.test("profile source tree routes use encoded scoped ids", () => {
const href = profileSourceTreeSettingsHref("workspace a", "project/tree");
assert(
@ -115,7 +117,18 @@ Deno.test("profile source tree routes use encoded scoped ids", () => {
});
Deno.test("profile source create paths are normalized to virtual profile paths", () => {
assert(virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl", "bare file names are scoped");
assert(virtualProfilePathForCreate("profiles/alpha.dcdl") === "profiles/alpha.dcdl", "virtual paths are preserved");
assert(virtualProfilePathForCreate("project:profiles/alpha.dcdl") === "project:profiles/alpha.dcdl", "safe virtual namespaces are preserved");
assert(
virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl",
"bare file names are scoped",
);
assert(
virtualProfilePathForCreate("profiles/alpha.dcdl") ===
"profiles/alpha.dcdl",
"virtual paths are preserved",
);
assert(
virtualProfilePathForCreate("project:profiles/alpha.dcdl") ===
"project:profiles/alpha.dcdl",
"safe virtual namespaces are preserved",
);
});

View File

@ -1,7 +1,4 @@
import {
workspaceApiJson,
workspaceApiJsonWithBody,
} from "../api/http";
import { workspaceApiJson, workspaceApiJsonWithBody } from "../api/http";
import type {
ProfileSettingsMutationResponse,
ProfileSettingsResponse,
@ -124,7 +121,9 @@ export function fetchProfileSourceTree(
sourceTreeId: string,
): Promise<WorkspaceProfileSourceTreeResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}`,
);
}
@ -134,7 +133,9 @@ export function fetchProfileTreeFile(
path: string,
): Promise<WorkspaceProfileSourceTreeFileResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file?path=${encodeURIComponent(path)}`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}/file?path=${encodeURIComponent(path)}`,
);
}
@ -144,7 +145,9 @@ export function writeProfileTreeFile(
request: { path: string; content: string; revision?: string | null },
): Promise<WorkspaceProfileSourceTreeFileResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}/file`,
{ method: "PUT", body: JSON.stringify(request) },
);
}
@ -155,7 +158,9 @@ export function deleteProfileTreeFile(
request: { path: string; revision: string },
): Promise<WorkspaceProfileSourceTreeResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}/file`,
{ method: "DELETE", body: JSON.stringify(request) },
);
}

View File

@ -2,14 +2,21 @@ export function profileSettingsHref(workspaceId: string): string {
return `/w/${encodeURIComponent(workspaceId)}/settings/profiles`;
}
export function profileSourceTreeSettingsHref(workspaceId: string, sourceTreeId: string): string {
return `${profileSettingsHref(workspaceId)}/trees/${encodeURIComponent(sourceTreeId)}`;
export function profileSourceTreeSettingsHref(
workspaceId: string,
sourceTreeId: string,
): string {
return `${profileSettingsHref(workspaceId)}/trees/${
encodeURIComponent(sourceTreeId)
}`;
}
export function virtualProfilePathForCreate(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) return trimmed;
if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) {
return trimmed;
}
if (trimmed.startsWith("profiles/")) return trimmed;
return `profiles/${trimmed}`;
}

View File

@ -25,7 +25,8 @@ function repositories(
}
Deno.test("repository nav does not invent main for an empty registry", () => {
const projection = projectRepositoryNav({
const projection = projectRepositoryNav(
{
workspace_id: "workspace-1",
items: [],
source: "workspace_backend_config",
@ -36,7 +37,10 @@ Deno.test("repository nav does not invent main for an empty registry", () => {
message: "No repositories configured",
},
],
}, "/w/workspace-1", "workspace-1");
},
"/w/workspace-1",
"workspace-1",
);
assertEquals(projection.count, 0);
assertEquals(projection.items, []);

View File

@ -25,7 +25,10 @@ export function projectRepositoryNav(
count: summaries.length,
diagnostics: repositories?.diagnostics ?? [],
items: summaries.map((repository) => {
const href = workspaceRoute(workspaceId, `/repositories/${encodeURIComponent(repository.id)}`);
const href = workspaceRoute(
workspaceId,
`/repositories/${encodeURIComponent(repository.id)}`,
);
return {
id: repository.id,
title: repository.display_name || repository.id,

View File

@ -319,12 +319,12 @@ export type RepositoryLogResponse = {
};
export type MemoryCandidateKind =
| 'preference'
| 'working_assumption'
| 'constraint'
| 'decision'
| 'open_question'
| 'lesson';
| "preference"
| "working_assumption"
| "constraint"
| "decision"
| "open_question"
| "lesson";
export type MemorySourceRef = {
segment_id: string;
@ -387,7 +387,11 @@ export type TicketSummary = {
updated_at?: string | null;
queued_by?: string | null;
queued_at?: string | null;
workspace_action_priority?: 'ready_for_queue' | 'active_work' | 'background' | null;
workspace_action_priority?:
| "ready_for_queue"
| "active_work"
| "background"
| null;
record_source?: string;
};

View File

@ -35,17 +35,23 @@ export function defaultWorkerLaunchForm(
const preferredProfile =
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
options?.profiles[0];
const availableWorkingDirectories = options?.working_directories.filter((directory) =>
const availableWorkingDirectories =
options?.working_directories.filter((directory) =>
directory.status === "active" &&
directory.cleanliness === "clean" &&
directory.primary_worker_id == null &&
directory.occupied_by == null
) ?? [];
const selectedRuntime = current.runtime_id
? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id)
? options?.runtimes.find((runtime) =>
runtime.runtime_id === current.runtime_id
)
: preferredRuntime;
const workdirlessRuntime = selectedRuntime?.working_directory_required === false;
const preferredWorkingDirectory = workdirlessRuntime ? undefined : availableWorkingDirectories[0];
const workdirlessRuntime =
selectedRuntime?.working_directory_required === false;
const preferredWorkingDirectory = workdirlessRuntime
? undefined
: availableWorkingDirectories[0];
const preferredRepository =
options?.repositories.find((repository) =>
repository.id === current.working_directory_repository_id
@ -60,7 +66,8 @@ export function defaultWorkerLaunchForm(
? current.profile
: preferredProfile?.id || "",
initial_text: current.initial_text,
working_directory_id: !workdirlessRuntime && availableWorkingDirectories.some(
working_directory_id:
!workdirlessRuntime && availableWorkingDirectories.some(
(directory) =>
directory.working_directory_id === current.working_directory_id,
)

View File

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

View File

@ -1,13 +1,13 @@
import { loadJson, workspaceApiPath } from '$lib/workspace/api/http';
import type { MemoryStagingListResponse } from '$lib/workspace/sidebar/types';
import type { PageLoad } from './$types';
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { MemoryStagingListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId: params.workspaceId,
staging: await loadJson<MemoryStagingListResponse>(
fetch,
`${workspaceApiPath(params.workspaceId, '/memory/staging')}?limit=200`,
`${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
),
};
};

View File

@ -9,7 +9,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectiveId = params.objectiveId;
const [objectives, objective] = await Promise.all([
loadJson<ObjectiveListResponse>(fetch, apiPath('/objectives')),
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
loadJson<ObjectiveDetail>(
fetch,
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),

View File

@ -1,5 +1,7 @@
export function load(
{ params }: { params: { workspaceId: string; runtimeId: string; workerId: string } },
{ params }: {
params: { workspaceId: string; runtimeId: string; workerId: string };
},
) {
return {
workspaceId: params.workspaceId,

View File

@ -1,4 +1,4 @@
import type { PageLoad } from './$types';
import type { PageLoad } from "./$types";
export const load: PageLoad = ({ params }) => ({
sourceTreeId: params.sourceTreeId,

View File

@ -10,7 +10,10 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const runtimeId = params.runtimeId;
const [runtimes, workdirs, cleanupPlan] = await Promise.all([
loadJson<ListResponse<Runtime>>(fetch, workspaceApiPath(params.workspaceId, "/runtimes")),
loadJson<ListResponse<Runtime>>(
fetch,
workspaceApiPath(params.workspaceId, "/runtimes"),
),
loadJson<BrowserWorkingDirectoryListResponse>(
fetch,
workspaceApiPath(
@ -20,7 +23,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
),
loadJson<RuntimeCleanupPlanResponse>(
fetch,
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`,
),
),
]);

View File

@ -6,7 +6,10 @@ export const load = (async ({ fetch, params }) => {
const ticketId = params.ticketId;
const ticket = await loadJson<TicketDetail>(
fetch,
workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(ticketId)}`),
workspaceApiPath(
params.workspaceId,
`/tickets/${encodeURIComponent(ticketId)}`,
),
);
return {

View File

@ -1,5 +1,9 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace/sidebar/types";
import type {
ListResponse,
RuntimeCleanupPlanResponse,
Worker,
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
@ -7,12 +11,17 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch,
workspaceApiPath(params.workspaceId, "/workers"),
);
const runtimeIds = Array.from(new Set(workers.data?.items.map((worker) => worker.runtime_id) ?? []));
const runtimeIds = Array.from(
new Set(workers.data?.items.map((worker) => worker.runtime_id) ?? []),
);
const cleanupPlanEntries = await Promise.all(
runtimeIds.map(async (runtimeId) => {
const cleanupPlan = await loadJson<RuntimeCleanupPlanResponse>(
fetch,
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`,
),
);
return [runtimeId, cleanupPlan] as const;
}),

View File

@ -1,18 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
pages: "build",
assets: "build",
fallback: "index.html",
precompress: false,
strict: true
})
}
strict: true,
}),
},
};
export default config;

View File

@ -1,20 +1,20 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
allowedHosts: ['develop.hareworks.net'],
watch: { ignored: ['**/.tmp*', '**/.tmp*/**'] },
allowedHosts: ["develop.hareworks.net"],
watch: { ignored: ["**/.tmp*", "**/.tmp*/**"] },
proxy: {
'/api': {
target: 'http://127.0.0.1:8787',
"/api": {
target: "http://127.0.0.1:8787",
changeOrigin: true,
ws: true
}
}
}
ws: true,
},
},
},
});