web: make running composer stoppable

This commit is contained in:
Keisuke Hirata 2026-07-24 15:53:02 +09:00
parent 342c3dabb7
commit 862f8f7f98
No known key found for this signature in database
2 changed files with 66 additions and 16 deletions

View File

@ -416,6 +416,21 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
!consolePage.includes("void refreshConsole();\n });\n\n $effect"), !consolePage.includes("void refreshConsole();\n });\n\n $effect"),
"target-change effect should load data without depending on manual refresh state reads", "target-change effect should load data without depending on manual refresh state reads",
); );
assert(
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('"Stop Worker"') &&
consolePage.includes("disabled={composerSubmitDisabled}") &&
!consolePage.includes("disabled={!inputReady || sending}") &&
!consolePage.includes("enabled: inputReady && !sending"),
"Worker Console composer should stay editable during runs and turn the submit button into a Stop control",
);
}); });
Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => { Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => {

View File

@ -156,8 +156,15 @@
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics), mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
); );
const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading"); const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading");
const workerRunning = $derived(workerState === "running");
const inputReady = $derived(workerState === "idle"); const inputReady = $derived(workerState === "idle");
const canSend = $derived(inputReady && draft.trim().length > 0 && !sending); const composerEditable = $derived(protocolState === "open" && !sending);
const canSubmitDraft = $derived(inputReady && composerEditable);
const canSend = $derived(canSubmitDraft && draft.trim().length > 0);
const canStopFromComposer = $derived(workerRunning && composerEditable);
const composerSubmitDisabled = $derived(
workerRunning ? !canStopFromComposer : !canSend,
);
async function getJson<T>(path: string): Promise<T> { async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path); const response = await fetch(path);
@ -433,6 +440,14 @@
} }
} }
function handleComposerSubmit(value = draft) {
if (workerRunning) {
sendControl({ method: "cancel" }, "Stop");
return;
}
void submitDraft(value);
}
async function submitDraft(value = draft) { async function submitDraft(value = draft) {
const command = buildComposerRequest(value); const command = buildComposerRequest(value);
if (!command.ok) { if (!command.ok) {
@ -466,9 +481,9 @@
} }
} }
async function sendMessage(event: SubmitEvent) { function sendMessage(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
await submitDraft(); handleComposerSubmit();
} }
function workerStateFromProtocolEvent( function workerStateFromProtocolEvent(
@ -1314,12 +1329,12 @@
bind:this={composerTextareaElement} bind:this={composerTextareaElement}
bind:value={draft} bind:value={draft}
use:chatSubmit={{ use:chatSubmit={{
enabled: inputReady && !sending, enabled: canSubmitDraft,
onSubmit: (value) => void submitDraft(value), onSubmit: (value) => handleComposerSubmit(value),
}} }}
use:fitTextarea={{ value: draft, maxRows: 10 }} use:fitTextarea={{ value: draft, maxRows: 10 }}
onkeydown={handleComposerKeydown} onkeydown={handleComposerKeydown}
disabled={!inputReady || sending}></textarea> disabled={!composerEditable}></textarea>
<div class="composer-input-footer"> <div class="composer-input-footer">
<div class="composer-footer-slot"> <div class="composer-footer-slot">
{#if completionBusy || completionError || completionEntries.length > 0} {#if completionBusy || completionError || completionEntries.length > 0}
@ -1342,18 +1357,33 @@
</div> </div>
<button <button
class="composer-send-button" class="composer-send-button"
class:stop={workerRunning}
type="submit" type="submit"
aria-label={sending ? "Sending message" : "Send message"} aria-label={workerRunning
disabled={!canSend} ? "Stop Worker"
: sending
? "Sending message"
: "Send message"}
disabled={composerSubmitDisabled}
> >
<svg {#if workerRunning}
class="composer-send-icon" <svg
aria-hidden="true" class="composer-send-icon"
viewBox="0 0 24 24" aria-hidden="true"
> viewBox="0 0 24 24"
<path d="M8 6L12 2L16 6" /> >
<path d="M12 2V22" /> <path d="M7 7H17V17H7Z" />
</svg> </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> </button>
</div> </div>
</div> </div>
@ -1657,6 +1687,11 @@
pointer-events: auto; pointer-events: auto;
} }
.composer-send-button.stop {
background: var(--danger);
color: var(--bg);
}
.composer-send-button:disabled { .composer-send-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;