fix: restore console input and attachment workflows
Restore durable busy Submit queues and TUI Enter delivery, expose Web Queue beside Notify, and separate the Composer toolbar from its editor without persistent success notices. Support pasted image attachments and restore canonical Worker Session uploaded-file operations, including retention, binding, cleanup and authenticated upload context. Validated Worker, Protocol and Session Store suites, 278 TUI tests, 59 Web tests and Svelte check. Runtime suite could not complete due to external Workdir transport timeouts.
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import type { Segment } from "$lib/generated/protocol.ts";
|
||||
import {
|
||||
handleComposerImagePaste,
|
||||
measureComposerPaste,
|
||||
type ComposerPasteMeasurement,
|
||||
} from "$lib/workspace/console/composer-paste.ts";
|
||||
@@ -55,6 +56,7 @@
|
||||
onchange?: (snapshot: ComposerDraftSnapshot) => void;
|
||||
onkeydown?: (event: KeyboardEvent) => void;
|
||||
onsubmit?: () => void;
|
||||
onpasteimages?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -65,6 +67,7 @@
|
||||
onchange,
|
||||
onkeydown,
|
||||
onsubmit,
|
||||
onpasteimages,
|
||||
}: Props = $props();
|
||||
|
||||
let mountElement: HTMLDivElement;
|
||||
@@ -265,6 +268,7 @@
|
||||
|
||||
function handlePasteEvent(event: ClipboardEvent): boolean {
|
||||
if (disabled || view?.state.readOnly) return false;
|
||||
if (handleComposerImagePaste(event, onpasteimages)) return true;
|
||||
const content = event.clipboardData?.getData("text/plain");
|
||||
if (!content) return false;
|
||||
const measurement = measureComposerPaste(content);
|
||||
@@ -557,7 +561,7 @@
|
||||
}
|
||||
|
||||
.composer-input :global(.cm-editor) {
|
||||
min-height: 5.35rem;
|
||||
min-height: 2.65rem;
|
||||
max-height: 10rem;
|
||||
}
|
||||
|
||||
@@ -566,8 +570,8 @@
|
||||
}
|
||||
|
||||
.composer-input :global(.cm-content) {
|
||||
min-height: 5.35rem;
|
||||
padding: 0.55rem 3.4rem 3rem 0.65rem;
|
||||
min-height: 2.65rem;
|
||||
padding: 0.55rem 0.65rem 0.35rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,77 @@ const base = {
|
||||
hasAttachments: false,
|
||||
};
|
||||
|
||||
Deno.test("Queue accepts text and attachments while running or paused", () => {
|
||||
for (const workerState of ["running", "paused"]) {
|
||||
for (
|
||||
const payload of [
|
||||
{ hasText: true, hasAttachments: false },
|
||||
{ hasText: false, hasAttachments: true },
|
||||
{ hasText: true, hasAttachments: true },
|
||||
]
|
||||
) {
|
||||
assertEquals(
|
||||
canDeliverComposerDraft({
|
||||
...base,
|
||||
...payload,
|
||||
workerState,
|
||||
delivery: "queue",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Queue respects lifecycle, transport, send and empty-input fences", () => {
|
||||
for (const workerState of ["idle", "stopped", "loading", "unknown"]) {
|
||||
assertEquals(
|
||||
canDeliverComposerDraft({
|
||||
...base,
|
||||
workerState,
|
||||
delivery: "queue",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (
|
||||
const fence of [
|
||||
{ protocolOpen: false },
|
||||
{ sending: true },
|
||||
{ hasText: false, hasAttachments: false },
|
||||
]
|
||||
) {
|
||||
const state = {
|
||||
...base,
|
||||
...fence,
|
||||
workerState: "running",
|
||||
delivery: "queue" as const,
|
||||
};
|
||||
assertEquals(canDeliverComposerDraft(state), false);
|
||||
let sent = false;
|
||||
assertEquals(
|
||||
sendComposerDelivery(state, "submit", () => {
|
||||
sent = true;
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assertEquals(sent, false);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Queue dispatches Submit, not Notify", () => {
|
||||
const sent: string[] = [];
|
||||
assertEquals(
|
||||
sendComposerDelivery(
|
||||
{ ...base, workerState: "running", delivery: "queue" },
|
||||
"submit",
|
||||
(method) => sent.push(method),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assertEquals(sent.join(","), "submit");
|
||||
});
|
||||
|
||||
Deno.test("running Composer enables Notify but not Submit", () => {
|
||||
assertEquals(
|
||||
canDeliverComposerDraft({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ComposerDelivery = "submit" | "notify";
|
||||
export type ComposerDelivery = "submit" | "queue" | "notify";
|
||||
|
||||
export type ComposerDeliveryState = {
|
||||
delivery: ComposerDelivery;
|
||||
@@ -11,7 +11,8 @@ export type ComposerDeliveryState = {
|
||||
|
||||
/**
|
||||
* Resolve whether the current Composer draft can use one delivery action.
|
||||
* Immediate Submit is idle-only; Notify is running-only.
|
||||
* Immediate Submit is idle-only; Queue submits to the busy Worker's FIFO.
|
||||
* Notify is running-only and remains distinct from next-turn input.
|
||||
*/
|
||||
export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean {
|
||||
if (!state.protocolOpen || state.sending) return false;
|
||||
@@ -20,6 +21,9 @@ export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean {
|
||||
switch (state.delivery) {
|
||||
case "submit":
|
||||
return state.workerState === "idle" && hasInput;
|
||||
case "queue":
|
||||
return (state.workerState === "running" || state.workerState === "paused") &&
|
||||
hasInput;
|
||||
case "notify":
|
||||
return state.workerState === "running" && state.hasText &&
|
||||
!state.hasAttachments;
|
||||
|
||||
@@ -10,10 +10,58 @@ export interface ComposerPasteMeasurement {
|
||||
}
|
||||
|
||||
export interface ComposerPasteEvent {
|
||||
clipboardData: { getData(format: string): string } | null;
|
||||
clipboardData: {
|
||||
getData(format: string): string;
|
||||
files?: ArrayLike<File>;
|
||||
items?: ArrayLike<{
|
||||
kind: string;
|
||||
type: string;
|
||||
getAsFile(): File | null;
|
||||
}>;
|
||||
} | null;
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
/** Keep repeated screenshots from colliding with unsubmitted image.png uploads. */
|
||||
export function namePastedImage(file: File): File {
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-${crypto.randomUUID()}-${file.name || "image"}`,
|
||||
{
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer image files over alternate text/HTML clipboard representations.
|
||||
* Use items only as a fallback: browsers can expose the same image in both
|
||||
* collections. Validation and upload remain the attachment handler's job.
|
||||
*/
|
||||
export function handleComposerImagePaste(
|
||||
event: ComposerPasteEvent,
|
||||
attachImages: ((files: File[]) => void) | undefined,
|
||||
enabled = true,
|
||||
): boolean {
|
||||
if (!enabled || !attachImages || !event.clipboardData) return false;
|
||||
const clipboard = event.clipboardData;
|
||||
let images = Array.from(clipboard.files ?? []).filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
if (images.length === 0) {
|
||||
images = Array.from(clipboard.items ?? []).flatMap((item) => {
|
||||
if (item.kind !== "file" || !item.type.startsWith("image/")) return [];
|
||||
const file = item.getAsFile();
|
||||
return file ? [file] : [];
|
||||
});
|
||||
}
|
||||
if (images.length === 0) return false;
|
||||
event.preventDefault();
|
||||
attachImages(images);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count user-visible Unicode scalar values rather than UTF-16 code units.
|
||||
* JavaScript string iteration combines a valid surrogate pair into one value.
|
||||
|
||||
@@ -805,7 +805,6 @@ Deno.test("Worker Console composer keeps a compact bounded chip editor", async (
|
||||
) &&
|
||||
consolePage.includes("scrollConsoleByPage") &&
|
||||
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("pointer-events: auto") &&
|
||||
@@ -818,6 +817,78 @@ Deno.test("Worker Console composer keeps a compact bounded chip editor", async (
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console composer separates the editor from its icon toolbar", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const composerInput = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const footerStyle = consolePage.match(/\.composer-input-footer\s*\{([^}]+)\}/)?.[1];
|
||||
assert(
|
||||
footerStyle?.includes("display: grid") &&
|
||||
!/position:\s*(absolute|fixed)/.test(footerStyle) &&
|
||||
!footerStyle.includes("pointer-events: none"),
|
||||
"Composer toolbar must stay in normal flow below the scrollable editor",
|
||||
);
|
||||
assert(
|
||||
composerInput.includes("min-height: 2.65rem") &&
|
||||
composerInput.includes("padding: 0.55rem 0.65rem 0.35rem") &&
|
||||
!composerInput.includes("padding: 0.55rem 3.4rem 3rem 0.65rem"),
|
||||
"Editor must not reserve an overlapping toolbar inside its scrolling content",
|
||||
);
|
||||
const attachButton = consolePage.match(
|
||||
/<button\s+class="composer-attach-button"[\s\S]*?<\/button>/,
|
||||
)?.[0];
|
||||
assert(
|
||||
attachButton?.includes('aria-label="Attach file"') &&
|
||||
attachButton.includes('title="Attach file"') &&
|
||||
attachButton.includes('class="composer-attach-icon"') &&
|
||||
attachButton.includes('aria-hidden="true"') &&
|
||||
attachButton.includes('d="M12 5V19M5 12H19"') &&
|
||||
attachButton.includes("fileInput?.click()") &&
|
||||
!attachButton.includes(">Attach file</button>"),
|
||||
"Attach file must remain an accessible file-picker button with a plus icon",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console keeps Notify beside Submit without persistent notices", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const actions = consolePage.match(
|
||||
/<div class="composer-submit-actions">([\s\S]*?)<\/div>/,
|
||||
)?.[1];
|
||||
assert(
|
||||
actions?.includes("{#if workerRunning}") &&
|
||||
actions.includes('class="composer-notify-button"') &&
|
||||
actions.includes('type="button"') &&
|
||||
actions.includes('aria-label="Notify Worker"') &&
|
||||
actions.includes('aria-hidden="true"') &&
|
||||
actions.includes('class="composer-notify-icon"') &&
|
||||
actions.includes("disabled={!canNotifyDraft}") &&
|
||||
actions.includes("onclick={handleNotifySubmit}") &&
|
||||
actions.includes('class="composer-send-button"') &&
|
||||
actions.indexOf('class="composer-notify-button"') <
|
||||
actions.indexOf('class="composer-send-button"'),
|
||||
"Running Worker Notify must be an accessible bell button beside Submit/Stop",
|
||||
);
|
||||
assert(
|
||||
!consolePage.includes("composerNotice") &&
|
||||
!consolePage.includes("composer-notice") &&
|
||||
!consolePage.includes('class="composer-actions"') &&
|
||||
!consolePage.includes("Sent through Worker protocol.") &&
|
||||
consolePage.includes('{#if sendError}<p class="error" role="alert">'),
|
||||
"Composer must remove persistent notices and empty action rows but retain send errors",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
@@ -854,6 +925,29 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console routes image paste through the existing attachment upload", async () => {
|
||||
const consolePage = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url,
|
||||
));
|
||||
const input = await Deno.readTextFile(new URL("./ComposerInput.svelte", import.meta.url));
|
||||
const paste = input.slice(input.indexOf("function handlePasteEvent"), input.indexOf("function selectedClipboardContent"));
|
||||
assert(
|
||||
paste.indexOf("if (disabled || view?.state.readOnly)") < paste.indexOf("handleComposerImagePaste(event, onpasteimages)") &&
|
||||
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") < paste.indexOf('getData("text/plain")') &&
|
||||
consolePage.includes("onpasteimages={addPastedImages}") &&
|
||||
consolePage.includes("addAttachmentFiles(files.map(namePastedImage))"),
|
||||
"image paste must respect editability and take priority over text before using the attachment callback",
|
||||
);
|
||||
const attach = consolePage.slice(consolePage.indexOf("function addAttachmentFiles"), consolePage.indexOf("async function removeAttachment"));
|
||||
assert(
|
||||
attach.includes("if (!composerEditable) return") &&
|
||||
attach.includes("MAX_FILES_PER_SUBMISSION - attachments.length") &&
|
||||
attach.includes(".slice(0, available)") &&
|
||||
attach.includes("startAttachmentUpload(attachment)"),
|
||||
"pasted images must share upload validation, lifecycle gating and the attachment count limit",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
||||
const editor = await Deno.readTextFile(
|
||||
new URL(
|
||||
@@ -1342,7 +1436,9 @@ Deno.test("Web Console uses Notify while running and keeps recovery pending cont
|
||||
'method: "continue_pending"',
|
||||
"handleNotifySubmit",
|
||||
"disabled={!canNotifyDraft}",
|
||||
">Notify</button>",
|
||||
'class="composer-notify-button"',
|
||||
'aria-label="Notify Worker"',
|
||||
'class="composer-notify-icon"',
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
@@ -1352,10 +1448,21 @@ Deno.test("Web Console uses Notify while running and keeps recovery pending cont
|
||||
}
|
||||
|
||||
assert(
|
||||
!consolePage.includes("handleQueueSubmit") &&
|
||||
!consolePage.includes(">Queue Submit</button>") &&
|
||||
!consolePage.includes('delivery: "queue"'),
|
||||
"running Console must not offer Submit queueing; callers should use Notify",
|
||||
consolePage.includes("handleQueueSubmit") &&
|
||||
consolePage.includes('delivery: "queue"') &&
|
||||
consolePage.includes('disabled={!canQueueDraft}') &&
|
||||
consolePage.includes('aria-label="Queue Submit"') &&
|
||||
consolePage.includes('class="composer-queue-icon"') &&
|
||||
consolePage.includes('{#if workerRunning || workerPaused}') &&
|
||||
consolePage.includes('delivery === "queue" && request.kind !== "user"') &&
|
||||
consolePage.includes('method.method === "submit" && delivery === "submit"'),
|
||||
"busy Console must offer explicit Queue without changing paused state or queueing commands",
|
||||
);
|
||||
const actions = consolePage.slice(consolePage.indexOf('class="composer-submit-actions"'));
|
||||
assert(
|
||||
actions.indexOf('class="composer-queue-button"') < actions.indexOf('class="composer-notify-button"') &&
|
||||
actions.indexOf('class="composer-notify-button"') < actions.indexOf('class="composer-send-button"'),
|
||||
"Queue must sit beside Notify and Submit/Stop",
|
||||
);
|
||||
|
||||
const userCase = consolePage.slice(
|
||||
|
||||
+143
-77
@@ -5,6 +5,7 @@
|
||||
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
|
||||
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
||||
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
|
||||
import { namePastedImage } from "$lib/workspace/console/composer-paste";
|
||||
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
|
||||
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
|
||||
import {
|
||||
@@ -171,7 +172,6 @@
|
||||
let sendError = $state<string | null>(null);
|
||||
let rewindTargets = $state<RewindTarget[]>([]);
|
||||
let rewindHeadEntries = $state(0);
|
||||
let composerNotice = $state<string | null>(null);
|
||||
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
@@ -286,6 +286,16 @@
|
||||
hasAttachments: draftHasAttachments,
|
||||
}),
|
||||
);
|
||||
const canQueueDraft = $derived(
|
||||
canDeliverComposerDraft({
|
||||
delivery: "queue",
|
||||
workerState,
|
||||
protocolOpen: protocolState === "open",
|
||||
sending,
|
||||
hasText: draftHasText,
|
||||
hasAttachments: draftHasAttachments,
|
||||
}),
|
||||
);
|
||||
const canNotifyDraft = $derived(
|
||||
canDeliverComposerDraft({
|
||||
delivery: "notify",
|
||||
@@ -560,10 +570,6 @@
|
||||
token.end,
|
||||
`${entries[0].value} `,
|
||||
);
|
||||
composerNotice =
|
||||
entries.length > 1
|
||||
? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)`
|
||||
: null;
|
||||
} catch (error) {
|
||||
completionError =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -837,6 +843,10 @@
|
||||
void submitDraft(composerInputElement?.snapshot() ?? draft);
|
||||
}
|
||||
|
||||
function handleQueueSubmit() {
|
||||
void submitDraft(composerInputElement?.snapshot() ?? draft, "queue");
|
||||
}
|
||||
|
||||
function handleNotifySubmit() {
|
||||
void submitDraft(composerInputElement?.snapshot() ?? draft, "notify");
|
||||
}
|
||||
@@ -892,7 +902,12 @@
|
||||
updateAttachment(attachment.id, { request });
|
||||
}
|
||||
|
||||
function addPastedImages(files: File[]): void {
|
||||
addAttachmentFiles(files.map(namePastedImage));
|
||||
}
|
||||
|
||||
function addAttachmentFiles(files: Iterable<File>): void {
|
||||
if (!composerEditable) return;
|
||||
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length);
|
||||
for (const file of Array.from(files).slice(0, available)) {
|
||||
const attachment: ComposerAttachment = {
|
||||
@@ -949,15 +964,13 @@
|
||||
delivery: ComposerDelivery = "submit",
|
||||
) {
|
||||
if (delivery === "notify" && attachments.length > 0) {
|
||||
composerNotice = null;
|
||||
sendError = "Notify accepts text only; remove attachments or wait until the Worker is idle to Submit.";
|
||||
sendError = "Notify accepts text only; remove attachments or queue a Submit.";
|
||||
return;
|
||||
}
|
||||
const incompleteAttachment = attachments.find((attachment) =>
|
||||
attachment.state !== "uploaded" || !attachment.reference
|
||||
);
|
||||
if (incompleteAttachment) {
|
||||
composerNotice = null;
|
||||
sendError = incompleteAttachment.state === "uploading"
|
||||
? "Wait for file uploads to finish before sending."
|
||||
: incompleteAttachment.error ?? "Retry or remove the failed attachment.";
|
||||
@@ -973,11 +986,9 @@
|
||||
preserveExactText: value.textPastes.length > 0,
|
||||
});
|
||||
if (!command.ok) {
|
||||
composerNotice = null;
|
||||
sendError = command.message;
|
||||
return;
|
||||
}
|
||||
composerNotice = command.notice ?? null;
|
||||
if (!command.request) {
|
||||
composerInputElement?.clear();
|
||||
return;
|
||||
@@ -995,9 +1006,12 @@
|
||||
}
|
||||
|
||||
let request: WorkerConsoleInputRequest = command.request;
|
||||
if (delivery === "queue" && request.kind !== "user") {
|
||||
sendError = "Queue accepts ordinary input, not a Composer command.";
|
||||
return;
|
||||
}
|
||||
if (delivery === "notify") {
|
||||
if (request.kind !== "user") {
|
||||
composerNotice = null;
|
||||
sendError = "Notify accepts ordinary text, not a Composer command.";
|
||||
return;
|
||||
}
|
||||
@@ -1013,10 +1027,9 @@
|
||||
composerInputElement?.recordHistory(value);
|
||||
composerInputElement?.clear();
|
||||
attachments = [];
|
||||
if (method.method === "submit" || method.method === "notify") {
|
||||
if (method.method === "submit" && delivery === "submit") {
|
||||
liveWorkerState = "running";
|
||||
}
|
||||
composerNotice = "Sent through Worker protocol.";
|
||||
} catch (error) {
|
||||
sendError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
@@ -2236,6 +2249,7 @@
|
||||
onchange={handleComposerChange}
|
||||
onkeydown={handleComposerKeydown}
|
||||
onsubmit={handleComposerSubmit}
|
||||
onpasteimages={addPastedImages}
|
||||
/>
|
||||
{#if attachments.length > 0}
|
||||
<div class="composer-attachments" aria-live="polite">
|
||||
@@ -2265,9 +2279,19 @@
|
||||
<button
|
||||
class="composer-attach-button"
|
||||
type="button"
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
disabled={!composerEditable || attachments.length >= MAX_FILES_PER_SUBMISSION}
|
||||
onclick={() => fileInput?.click()}
|
||||
>Attach file</button>
|
||||
>
|
||||
<svg
|
||||
class="composer-attach-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 5V19M5 12H19" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if completionBusy || completionError || completionEntries.length > 0}
|
||||
<div class="composer-completions" aria-live="polite">
|
||||
{#if completionBusy}
|
||||
@@ -2286,51 +2310,78 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="composer-send-button"
|
||||
class:stop={workerRunning}
|
||||
type="submit"
|
||||
aria-label={workerRunning
|
||||
? "Stop Worker"
|
||||
: sending
|
||||
? "Sending message"
|
||||
: "Send message"}
|
||||
disabled={composerSubmitDisabled}
|
||||
>
|
||||
{#if workerRunning}
|
||||
<svg
|
||||
class="composer-send-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
<div class="composer-submit-actions">
|
||||
{#if workerRunning || workerPaused}
|
||||
<button
|
||||
class="composer-queue-button"
|
||||
type="button"
|
||||
aria-label="Queue Submit"
|
||||
title="Queue Submit"
|
||||
disabled={!canQueueDraft}
|
||||
onclick={handleQueueSubmit}
|
||||
>
|
||||
<path d="M7 7H17V17H7Z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="composer-send-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 6L12 2L16 6" />
|
||||
<path d="M12 2V22" />
|
||||
</svg>
|
||||
<svg
|
||||
class="composer-queue-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M4 6H20M4 12H14M4 18H10M18 14V22M14 18H22" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
{#if workerRunning}
|
||||
<button
|
||||
class="composer-notify-button"
|
||||
type="button"
|
||||
aria-label="Notify Worker"
|
||||
title="Notify Worker"
|
||||
disabled={!canNotifyDraft}
|
||||
onclick={handleNotifySubmit}
|
||||
>
|
||||
<svg
|
||||
class="composer-notify-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18 8A6 6 0 0 0 6 8C6 15 3 15 3 17H21C21 15 18 15 18 8Z" />
|
||||
<path d="M10 21H14" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="composer-send-button"
|
||||
class:stop={workerRunning}
|
||||
type="submit"
|
||||
aria-label={workerRunning
|
||||
? "Stop Worker"
|
||||
: sending
|
||||
? "Sending message"
|
||||
: "Send message"}
|
||||
disabled={composerSubmitDisabled}
|
||||
>
|
||||
{#if workerRunning}
|
||||
<svg
|
||||
class="composer-send-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M7 7H17V17H7Z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="composer-send-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 6L12 2L16 6" />
|
||||
<path d="M12 2V22" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-actions">
|
||||
{#if workerRunning}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canNotifyDraft}
|
||||
onclick={handleNotifySubmit}
|
||||
>Notify</button>
|
||||
{/if}
|
||||
{#if composerNotice}
|
||||
<span class="composer-notice">{composerNotice}</span>
|
||||
{/if}
|
||||
{#if sendError}<p class="error">{sendError}</p>{/if}
|
||||
</div>
|
||||
{#if sendError}<p class="error" role="alert">{sendError}</p>{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2642,7 +2693,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3) 3rem;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.composer-attachment {
|
||||
@@ -2672,7 +2723,9 @@
|
||||
}
|
||||
|
||||
.composer-attachment button,
|
||||
.composer-attach-button {
|
||||
.composer-attach-button,
|
||||
.composer-queue-button,
|
||||
.composer-notify-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
@@ -2681,24 +2734,45 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.composer-attach-button {
|
||||
padding: 0.35rem 0;
|
||||
.composer-attach-button,
|
||||
.composer-queue-button,
|
||||
.composer-notify-button {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer-attach-button:hover:not(:disabled),
|
||||
.composer-queue-button:hover:not(:disabled),
|
||||
.composer-notify-button:hover:not(:disabled) {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.composer-attach-button:disabled,
|
||||
.composer-queue-button:disabled,
|
||||
.composer-notify-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.composer-input-footer {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
bottom: 0.7rem;
|
||||
left: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
align-items: end;
|
||||
min-height: 2.35rem;
|
||||
pointer-events: none;
|
||||
padding: 0 0.35rem 0.35rem 0.65rem;
|
||||
}
|
||||
|
||||
.composer-footer-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -2726,6 +2800,9 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.composer-attach-icon,
|
||||
.composer-queue-icon,
|
||||
.composer-notify-icon,
|
||||
.composer-send-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
@@ -2745,21 +2822,10 @@
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.composer-notice {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
.composer-submit-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.composer-actions .composer-notice {
|
||||
margin-right: auto;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
||||
@@ -3,11 +3,14 @@ import fixtureJson from "../../../tests/fixtures/composer-paste-policy.json" wit
|
||||
};
|
||||
|
||||
import {
|
||||
type ComposerPasteEvent,
|
||||
type ComposerPasteMeasurement,
|
||||
handleComposerImagePaste,
|
||||
handleComposerPaste,
|
||||
MAX_PLAIN_TEXT_PASTE_CHARS,
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||
measureComposerPaste,
|
||||
namePastedImage,
|
||||
} from "../src/lib/workspace/console/composer-paste.ts";
|
||||
|
||||
declare const Deno: {
|
||||
@@ -30,6 +33,142 @@ function assertEquals(
|
||||
}
|
||||
}
|
||||
|
||||
function imagePasteEvent(clipboardData: ComposerPasteEvent["clipboardData"]) {
|
||||
let prevented = 0;
|
||||
return {
|
||||
event: {
|
||||
clipboardData,
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
},
|
||||
prevented: () => prevented,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("image paste attaches original files once without reading alternate text", () => {
|
||||
const image = new File(["png"], "image.png", { type: "image/png" });
|
||||
const { event, prevented } = imagePasteEvent({
|
||||
getData: () => {
|
||||
throw new Error("image must take priority over text");
|
||||
},
|
||||
files: [image],
|
||||
items: [{ kind: "file", type: image.type, getAsFile: () => image }],
|
||||
});
|
||||
const batches: File[][] = [];
|
||||
assertEquals(
|
||||
handleComposerImagePaste(event, (files) => batches.push(files)),
|
||||
true,
|
||||
);
|
||||
assertEquals(prevented(), 1);
|
||||
assertEquals(batches.length, 1);
|
||||
assertEquals(batches[0].length, 1);
|
||||
assertEquals(batches[0][0] === image, true);
|
||||
});
|
||||
|
||||
Deno.test("image paste falls back to file items and ignores text, other files and null entries", () => {
|
||||
const image = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });
|
||||
const { event, prevented } = imagePasteEvent({
|
||||
getData: () => "alternate image title",
|
||||
files: [],
|
||||
items: [
|
||||
{ kind: "string", type: "text/plain", getAsFile: () => null },
|
||||
{
|
||||
kind: "file",
|
||||
type: "application/pdf",
|
||||
getAsFile: () => new File([], "a.pdf"),
|
||||
},
|
||||
{ kind: "file", type: "image/png", getAsFile: () => null },
|
||||
{ kind: "file", type: image.type, getAsFile: () => image },
|
||||
],
|
||||
});
|
||||
let files: File[] = [];
|
||||
assertEquals(
|
||||
handleComposerImagePaste(event, (value) => {
|
||||
files = value;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assertEquals(files.length, 1);
|
||||
assertEquals(files[0] === image, true);
|
||||
assertEquals(prevented(), 1);
|
||||
});
|
||||
|
||||
Deno.test("multiple pasted images retain order and use attachment validation for unsupported types", () => {
|
||||
const images = ["image/png", "image/webp", "image/bmp"].map((type) =>
|
||||
new File([type], "image", { type })
|
||||
);
|
||||
const { event } = imagePasteEvent({
|
||||
getData: () => "",
|
||||
files: [
|
||||
images[0],
|
||||
new File([], "note.txt", { type: "text/plain" }),
|
||||
...images.slice(1),
|
||||
],
|
||||
});
|
||||
let attached: File[] = [];
|
||||
assertEquals(
|
||||
handleComposerImagePaste(event, (files) => {
|
||||
attached = files;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assertEquals(attached.length, images.length);
|
||||
assertEquals(attached.every((file, index) => file === images[index]), true);
|
||||
});
|
||||
|
||||
Deno.test("non-image or absent clipboard leaves text paste and its exact content untouched", () => {
|
||||
for (
|
||||
const clipboardData of [null, { getData: () => " text\r\n" }, {
|
||||
getData: () => "file name",
|
||||
files: [new File([], "file.pdf", { type: "application/pdf" })],
|
||||
}]
|
||||
) {
|
||||
const { event, prevented } = imagePasteEvent(clipboardData);
|
||||
assertEquals(
|
||||
handleComposerImagePaste(event, () => {
|
||||
throw new Error("unexpected attachment");
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assertEquals(prevented(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("image paste does not attach when disabled or no callback is installed", () => {
|
||||
const { event, prevented } = imagePasteEvent({
|
||||
getData: () => "",
|
||||
files: [new File(["png"], "image.png", { type: "image/png" })],
|
||||
});
|
||||
assertEquals(handleComposerImagePaste(event, undefined), false);
|
||||
assertEquals(
|
||||
handleComposerImagePaste(event, () => {
|
||||
throw new Error("disabled attachment");
|
||||
}, false),
|
||||
false,
|
||||
);
|
||||
assertEquals(prevented(), 0);
|
||||
});
|
||||
|
||||
Deno.test("pasted screenshot names are unique without changing bytes or media type", async () => {
|
||||
const file = new File([new Uint8Array([137, 80, 78, 71])], "image.png", {
|
||||
type: "image/png",
|
||||
lastModified: 1234,
|
||||
});
|
||||
const first = namePastedImage(file);
|
||||
const second = namePastedImage(file);
|
||||
assertEquals(first.name === second.name, false);
|
||||
assertEquals(first.name.endsWith("-image.png"), true);
|
||||
assertEquals(first.type, file.type);
|
||||
assertEquals(first.lastModified, file.lastModified);
|
||||
assertEquals(Array.from(new Uint8Array(await first.arrayBuffer())), [
|
||||
137,
|
||||
80,
|
||||
78,
|
||||
71,
|
||||
]);
|
||||
});
|
||||
|
||||
interface FixturePart {
|
||||
value: string;
|
||||
repeat: number;
|
||||
|
||||
Reference in New Issue
Block a user