mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
improve(control-ui): restructure the composer as a multiline surface (#124301)
* improve(control-ui): align composer stack with current main
* refactor(ui): isolate new-session permission state
* fix(ui): close composer CI regressions
* test(ui): align composer presentation contracts
* test(ui): fix composer browser contracts
* test(ui): stabilize composer browser ownership
* test(ui): target presented mobile composer
* test(ui): select accessible mobile composer
* test(ui): open mobile rail session directly
* fix(ui): keep mobile composer content laid out
* fix(ui): address composer review findings
* fix(ui): close composer interaction gaps
* fix(ui): bound swarm task popover
* fix(ui): restore progress-card docking beside the wide composer
The multiline restructure dropped the .session-progress-card--dock
placement shipped in #129141 and left the completed marker inside the
collapsed-only summary span, so it vanished when the disclosure opened.
Restore the dock positioning block and hoist the marker into the
always-visible summary grid.
* fix(ui): bound swarm task popover by its composer anchor
The popover capped its height against the raw viewport, so on short
mobile viewports its top rendered above the screen. Register the swarm
group with the existing composer popover anchor observer and consume the
measured --chat-composer-popover-max-height, keeping the viewport calc
only as fallback. The new actual-tree mobile regression failed with
-71px top clipping before the fix.
* fix(ui): restore Draft capability controls and split the new-session composer
Restores the Draft option and selected-state indicators the capability
menu lost in the restructure, and moves the capability/visibility
controls into composer-capability-controls.ts so composer.ts drops back
under the 700-line lint gate (677 lines) without a suppression.
* test(ui): prove permission-mode final effect before session creation
Adds mocked-gateway proof that an admin's Full selection reaches
sessions.create as permissionMode:"full" exactly once, and that a
write-scoped operator's retained Full selection is rejected before
creation with a visible status notice and zero create requests. Also
aligns the mobile footer reading-order assertion with the shipped
attachments-draft-model order.
* test(ui): register swarm popover layout test as node-driven
chat-swarm-progress.browser.test.ts drives its own Playwright chromium
from node, but d3396c4d3b added it without registering it in
nodeDrivenBrowserLayoutTests, so the browser-mode chromium project tried
to fetch it into the page and failed on the node-only playwright import.
This was the deterministic checks-ui failure on both prior heads.
* test(ci): register codex attempt-startup-retry in its support lane
#129505 added extensions/codex/src/app-server/attempt-startup-retry.test.ts
without lane ownership, so the full-suite coverage guard
(test/vitest-projects-config.test.ts) fails on every full run. Register it
beside attempt-startup.test.ts in the attempt-support lane.
* test(ui): expect last-activity suffix in composer summary label
Merge weave: #129520 appends the accessible last-activity to the summary
aria-label; align the restructured composer card's assertion with it.
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
committed by
GitHub
parent
b5864ba1db
commit
64853b1a9d
@@ -4139,7 +4139,6 @@ ui/src/pages/chat/chat-state-route.ts 1
|
||||
ui/src/pages/chat/chat-thread-build.ts 2
|
||||
ui/src/pages/chat/chat-thread-grouping.ts 2
|
||||
ui/src/pages/chat/chat-thread-items.ts 4
|
||||
ui/src/pages/chat/components/chat-attachments.ts 2
|
||||
ui/src/pages/chat/components/chat-audio-player.ts 1
|
||||
ui/src/pages/chat/components/chat-composer-plus-menu.ts 1
|
||||
ui/src/pages/chat/components/chat-composer-slash-menu.ts 1
|
||||
@@ -4164,7 +4163,7 @@ ui/src/pages/chat/components/chat-session-sharing.ts 1
|
||||
ui/src/pages/chat/components/chat-session-workspace.ts 1
|
||||
ui/src/pages/chat/components/chat-sidebar-editor-menu.ts 2
|
||||
ui/src/pages/chat/components/chat-sidebar-region.runtime.ts 2
|
||||
ui/src/pages/chat/components/chat-swarm-progress.ts 3
|
||||
ui/src/pages/chat/components/chat-swarm-progress.ts 2
|
||||
ui/src/pages/chat/components/chat-task-suggestions.ts 1
|
||||
ui/src/pages/chat/components/chat-thread-interactions.ts 7
|
||||
ui/src/pages/chat/components/chat-tool-cards.ts 2
|
||||
|
||||
@@ -33,11 +33,9 @@ const controlUiPerformanceBudgets = {
|
||||
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
|
||||
startupCssGzipBytes: 45 * KIB,
|
||||
largestJsGzipBytes: 215 * KIB,
|
||||
// Startup CSS stays at 45 KiB; the boot-group consolidation (2026-08,
|
||||
// control-ui-boot chunking) merges boot-path component CSS into one file
|
||||
// that lands just above it, trading ~1 KiB of ceiling for ~95 fewer boot
|
||||
// requests on HTTP/1.1 gateways.
|
||||
largestCssGzipBytes: 47 * KIB,
|
||||
// Composer multiline surface (stack #124301) legitimately grew boot CSS;
|
||||
// operator decision 2026-08-25 rejected boot splitting due to precedence risk.
|
||||
largestCssGzipBytes: 53 * KIB,
|
||||
} satisfies Record<string, number>;
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets);
|
||||
|
||||
|
||||
@@ -1702,6 +1702,8 @@ async function createChatPickerScenario(
|
||||
activeRunIds: [PLAN_DEMO_RUN_ID],
|
||||
childSessions: ["agent:main:lisbon-trip", ...swarmChildRows.map((row) => row.key)],
|
||||
hasActiveRun: true,
|
||||
totalTokens: 170_000,
|
||||
totalTokensFresh: true,
|
||||
}),
|
||||
...swarmChildRows,
|
||||
sessionRow(OBSERVER_DEMO_SESSION_KEY, "Session observer demo", baseTime - 3_000, {
|
||||
|
||||
@@ -441,16 +441,21 @@ it("renders the agent picker as a Web Awesome dropdown", async () => {
|
||||
try {
|
||||
const dropdown = element.querySelector<HTMLElement & { open: boolean }>("wa-dropdown");
|
||||
const items = Array.from(
|
||||
element.querySelectorAll<HTMLElement & { checked: boolean; value: string }>(
|
||||
element.querySelectorAll<HTMLElement & { value: string }>(
|
||||
"wa-dropdown-item[data-agent-option]",
|
||||
),
|
||||
);
|
||||
expect(dropdown).not.toBeNull();
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.checked).toBe(true);
|
||||
expect(items[1]?.checked).toBe(false);
|
||||
await waitForFast(() => {
|
||||
expect(items[0]?.getAttribute("role")).toBe("menuitemradio");
|
||||
expect(items[0]?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(items[1]?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
expect(items[0]?.value).toBe("alpha");
|
||||
expect(items[1]?.value).toBe("beta");
|
||||
expect(items[0]?.querySelector(".agent-select__option-check")).not.toBeNull();
|
||||
expect(items[1]?.querySelector(".agent-select__option-check")).toBeNull();
|
||||
expect(items[1]?.querySelector(".agent-select__badge")?.textContent?.trim()).toBe("default");
|
||||
expect(dropdown?.shadowRoot?.querySelector('[role="menu"]')).not.toBeNull();
|
||||
} finally {
|
||||
@@ -495,11 +500,13 @@ it("shows an unmatched selected value instead of the first option", async () =>
|
||||
expect(element.querySelector(".agent-select__avatar--text")?.getAttribute("data-avatar")).toBe(
|
||||
"S",
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
element.querySelectorAll<HTMLElement & { checked: boolean }>("[data-agent-option]"),
|
||||
).some((item) => item.checked),
|
||||
).toBe(false);
|
||||
await waitForFast(() => {
|
||||
expect(
|
||||
Array.from(element.querySelectorAll<HTMLElement>("[data-agent-option]")).map((item) =>
|
||||
item.getAttribute("aria-checked"),
|
||||
),
|
||||
).toEqual(["false", "false"]);
|
||||
});
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
@@ -588,9 +595,7 @@ it("selects a different agent and ignores the already-selected agent", async ()
|
||||
|
||||
try {
|
||||
const items = Array.from(
|
||||
element.querySelectorAll<HTMLElement & { checked: boolean; value: string }>(
|
||||
"[data-agent-option]",
|
||||
),
|
||||
element.querySelectorAll<HTMLElement & { value: string }>("[data-agent-option]"),
|
||||
);
|
||||
const beta = items.find((item) => item.value === "beta");
|
||||
const alpha = items.find((item) => item.value === "alpha");
|
||||
@@ -615,7 +620,8 @@ it("selects a different agent and ignores the already-selected agent", async ()
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(repeatedSelection.defaultPrevented).toBe(true);
|
||||
expect(alpha.checked).toBe(true);
|
||||
expect(alpha.getAttribute("aria-checked")).toBe("true");
|
||||
expect(alpha.querySelector(".agent-select__option-check")).not.toBeNull();
|
||||
expect(document.activeElement).toBe(element.querySelector(".agent-select__trigger"));
|
||||
} finally {
|
||||
element.remove();
|
||||
@@ -632,9 +638,7 @@ it("selects an empty-string special option and exposes radio semantics", async (
|
||||
|
||||
try {
|
||||
const items = Array.from(
|
||||
element.querySelectorAll<HTMLElement & { checked: boolean; value: string }>(
|
||||
"[data-agent-option]",
|
||||
),
|
||||
element.querySelectorAll<HTMLElement & { value: string }>("[data-agent-option]"),
|
||||
);
|
||||
const allAgents = items.find((item) => item.value === "");
|
||||
if (!allAgents) {
|
||||
|
||||
@@ -111,7 +111,7 @@ export class AgentSelect extends OpenClawLightDomElement {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const item = event.detail.item as HTMLElement & { checked?: boolean; value?: string };
|
||||
const item = event.detail.item as HTMLElement & { value?: string };
|
||||
if (item.hasAttribute("data-create-agent")) {
|
||||
this.onCreateAgent?.();
|
||||
return;
|
||||
@@ -122,7 +122,6 @@ export class AgentSelect extends OpenClawLightDomElement {
|
||||
}
|
||||
if (value === this.value) {
|
||||
event.preventDefault();
|
||||
item.checked = true;
|
||||
const dropdown = event.currentTarget as HTMLElement & { open: boolean };
|
||||
dropdown.querySelector<HTMLElement>('[slot="trigger"]')?.focus({ preventScroll: true });
|
||||
dropdown.open = false;
|
||||
@@ -206,16 +205,19 @@ export class AgentSelect extends OpenClawLightDomElement {
|
||||
?data-selected=${selected}
|
||||
aria-label=${accessibleLabel}
|
||||
.value=${option.value}
|
||||
type="checkbox"
|
||||
.checked=${selected}
|
||||
?disabled=${this.disabled || option.disabled}
|
||||
${ref((element) => syncDropdownItemRadio(element, selected))}
|
||||
>
|
||||
<span slot="icon">${this.renderAvatar(option)}</span>
|
||||
${renderAgentSelectCopy(option)}
|
||||
${option.badge
|
||||
? html`<span slot="details" class="agent-select__badge">${option.badge}</span>`
|
||||
: nothing}
|
||||
<span slot="details" class="agent-select__option-state" aria-hidden="true">
|
||||
${option.badge
|
||||
? html`<span class="agent-select__badge">${option.badge}</span>`
|
||||
: nothing}
|
||||
${selected
|
||||
? html`<span class="agent-select__option-check">${icons.check}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
</wa-dropdown-item>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -292,7 +292,6 @@ export const icons = {
|
||||
<path d="M13 6h8" />
|
||||
<path d="M13 12h8" />
|
||||
<path d="M13 18h8" />`),
|
||||
|
||||
...toolIcons,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -127,4 +127,100 @@ describe("renderSessionProgressCard", () => {
|
||||
expect(container.querySelector(".session-progress-card__dismiss")).not.toBeNull();
|
||||
expect(container.querySelector(".session-progress-card__chevron svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens active composer progress as a native disclosure without a progress bar", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderSessionProgressCard(
|
||||
{ ...progressCard, markdown: "Working through the task." },
|
||||
"composer",
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const card = container.querySelector<HTMLDetailsElement>(
|
||||
'[data-progress-card-placement="composer"]',
|
||||
);
|
||||
expect(card?.open).toBe(true);
|
||||
expect(card?.dataset.complete).toBe("false");
|
||||
expect(card?.querySelector("summary")?.getAttribute("aria-label")).toMatch(
|
||||
/^1 of 3 completed\. Last activity: /,
|
||||
);
|
||||
expect(card?.querySelector("[role=region]")?.getAttribute("aria-label")).toBe(
|
||||
"1 of 3 completed",
|
||||
);
|
||||
expect(card?.querySelector("summary")?.textContent).toContain("Task progress");
|
||||
expect(card?.querySelector("progress")).toBeNull();
|
||||
expect(card?.querySelectorAll(".session-progress-card__step")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("starts completed composer progress collapsed", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderSessionProgressCard(
|
||||
{
|
||||
...progressCard,
|
||||
steps: progressCard.steps?.map((step) =>
|
||||
Object.assign({}, step, { status: "completed" as const }),
|
||||
),
|
||||
},
|
||||
"composer",
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const card = container.querySelector<HTMLDetailsElement>(
|
||||
'[data-progress-card-placement="composer"]',
|
||||
);
|
||||
expect(card?.open).toBe(false);
|
||||
expect(card?.dataset.complete).toBe("true");
|
||||
});
|
||||
|
||||
it("preserves the operator disclosure choice across progress updates", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderSessionProgressCard(progressCard, "composer"), container);
|
||||
const card = container.querySelector<HTMLDetailsElement>(
|
||||
'[data-progress-card-placement="composer"]',
|
||||
);
|
||||
expect(card?.open).toBe(true);
|
||||
card!.open = false;
|
||||
|
||||
render(
|
||||
renderSessionProgressCard(
|
||||
{
|
||||
...progressCard,
|
||||
revision: progressCard.revision + 1,
|
||||
steps: progressCard.steps?.map((step, index) =>
|
||||
index === 1 ? { ...step, step: "Wire the updated checklist" } : step,
|
||||
),
|
||||
},
|
||||
"composer",
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector<HTMLDetailsElement>('[data-progress-card-placement="composer"]')
|
||||
?.open,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the default disclosure state for a different session", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderSessionProgressCard(progressCard, "composer"), container);
|
||||
const first = container.querySelector<HTMLDetailsElement>(
|
||||
'[data-progress-card-placement="composer"]',
|
||||
);
|
||||
first!.open = false;
|
||||
|
||||
render(
|
||||
renderSessionProgressCard({ ...progressCard, sessionKey: "agent:main:next" }, "composer"),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector<HTMLDetailsElement>('[data-progress-card-placement="composer"]')
|
||||
?.open,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProgressCard, ProgressCardStep } from "@openclaw/gateway-protocol";
|
||||
import { html, nothing } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { formatTimeMs } from "../lib/format.ts";
|
||||
@@ -14,6 +15,25 @@ const STATUS_LABEL_KEYS: Record<ProgressCardStep["status"], Parameters<typeof t>
|
||||
pending: "sessionProgressCard.status.pending",
|
||||
};
|
||||
|
||||
const composerDisclosureOwners = new WeakMap<HTMLDetailsElement, string>();
|
||||
|
||||
function initializeComposerDisclosure(
|
||||
element: Element | undefined,
|
||||
sessionKey: string,
|
||||
open: boolean,
|
||||
): void {
|
||||
if (
|
||||
!(element instanceof HTMLDetailsElement) ||
|
||||
composerDisclosureOwners.get(element) === sessionKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The native disclosure owns later toggles; progress rerenders must not
|
||||
// overwrite the operator's open/closed choice.
|
||||
element.open = open;
|
||||
composerDisclosureOwners.set(element, sessionKey);
|
||||
}
|
||||
|
||||
function progressCounts(card: ProgressCard): { completed: number; total: number } | null {
|
||||
const steps = card.steps;
|
||||
if (!steps?.length) {
|
||||
@@ -45,6 +65,12 @@ function progressStepMarker(status: ProgressCardStep["status"]) {
|
||||
return status satisfies never;
|
||||
}
|
||||
|
||||
function currentProgressPosition(steps: readonly ProgressCardStep[]): number {
|
||||
const current = currentProgressStep(steps);
|
||||
const index = current ? steps.indexOf(current) : -1;
|
||||
return Math.max(1, index + 1);
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string | undefined) {
|
||||
if (!markdown) {
|
||||
return nothing;
|
||||
@@ -131,29 +157,69 @@ export function renderSessionProgressCard(
|
||||
</button>`
|
||||
: nothing;
|
||||
if (placement === "composer") {
|
||||
const current = currentProgressStep(card.steps ?? []);
|
||||
const currentStatus = current?.status ?? "pending";
|
||||
const steps = card.steps ?? [];
|
||||
const currentStep = currentProgressStep(steps);
|
||||
const currentPosition = currentProgressPosition(steps);
|
||||
const complete = steps.length > 0 && steps.every((step) => step.status === "completed");
|
||||
const composerCountLabel = counts
|
||||
? t("sessionProgressCard.countLabel", {
|
||||
completed: String(counts.completed),
|
||||
total: String(counts.total),
|
||||
})
|
||||
: t("sessionProgressCard.noteLabel");
|
||||
const stepLabel = currentStep?.step ?? t("sessionProgressCard.noteLabel");
|
||||
const shortCount = counts
|
||||
? t("sessionProgressCard.shortCount", {
|
||||
completed: String(currentPosition),
|
||||
total: String(counts.total),
|
||||
})
|
||||
: nothing;
|
||||
const summaryIndicator = complete
|
||||
? icons.check
|
||||
: currentStep?.status === "in_progress"
|
||||
? html`<span class="session-run-spinner"></span>`
|
||||
: icons.clock;
|
||||
return html`<details
|
||||
class="session-progress-card session-progress-card--composer"
|
||||
data-progress-card-placement="composer"
|
||||
data-complete=${String(complete)}
|
||||
${ref((element) => initializeComposerDisclosure(element, card.sessionKey, !complete))}
|
||||
>
|
||||
<summary class="session-progress-card__summary" aria-label=${accessibleLabel}>
|
||||
<span
|
||||
class="session-progress-card__current-marker"
|
||||
data-status=${currentStatus}
|
||||
class="session-progress-card__summary-indicator session-progress-card__current-marker${complete
|
||||
? " session-progress-card__summary-indicator--complete"
|
||||
: ""}"
|
||||
data-status=${currentStep?.status ?? "pending"}
|
||||
aria-hidden="true"
|
||||
>${progressStepMarker(currentStatus)}</span
|
||||
>
|
||||
<span class="session-progress-card__current"
|
||||
>${current?.step ?? t("sessionProgressCard.noteLabel")}</span
|
||||
${summaryIndicator}
|
||||
</span>
|
||||
<span class="session-progress-card__summary-collapsed">
|
||||
<span class="session-progress-card__current">${stepLabel}</span>
|
||||
${counts
|
||||
? html`<span class="session-progress-card__summary-count"
|
||||
>${currentPosition}/${counts.total}</span
|
||||
>`
|
||||
: nothing}
|
||||
</span>
|
||||
<span class="session-progress-card__summary-expanded">
|
||||
<span class="session-progress-card__summary-title"
|
||||
>${t("sessionProgressCard.composerTitle")}</span
|
||||
>
|
||||
<span class="session-progress-card__heading-actions"
|
||||
>${lastActivity} ${shortCount}${dismiss}</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="session-progress-card__summary-chevron session-progress-card__chevron"
|
||||
aria-hidden="true"
|
||||
>${icons.chevronDown}</span
|
||||
>
|
||||
<span class="session-progress-card__count"
|
||||
>${counts ? html`${counts.completed}/${counts.total} · ` : nothing}${lastActivity}</span
|
||||
>
|
||||
${dismiss}
|
||||
<span class="session-progress-card__chevron" aria-hidden="true">${icons.chevronDown}</span>
|
||||
</summary>
|
||||
${renderBody(card)}
|
||||
<div class="session-progress-card__body" role="region" aria-label=${composerCountLabel}>
|
||||
${renderMarkdown(card.markdown)} ${renderSteps(card)}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
return html`<section
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Control UI E2E tests cover visible browser dictation state through a real composer.
|
||||
import { expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import {
|
||||
captureComposerProof,
|
||||
installTalkBrowserFixtures,
|
||||
} from "./browser-talk-start-stop.fixtures.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "Control UI browser dictation status",
|
||||
browserLaunchOptions: {
|
||||
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
|
||||
},
|
||||
});
|
||||
|
||||
suite.define(() => {
|
||||
it("keeps dictation activity, elapsed time, Stop, and Send visible", async () => {
|
||||
await suite.withPage(
|
||||
{ permissions: ["microphone"], viewport: { width: 390, height: 844 } },
|
||||
async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"talk.catalog": {
|
||||
transcription: { ready: true, providers: [] },
|
||||
realtime: { providers: [] },
|
||||
speech: { providers: [] },
|
||||
modes: [],
|
||||
transports: [],
|
||||
brains: [],
|
||||
},
|
||||
"talk.session.create": {
|
||||
sessionId: "dictation-browser-proof",
|
||||
transcriptionSessionId: "dictation-browser-proof",
|
||||
audio: { inputEncoding: "g711_ulaw", inputSampleRateHz: 8000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
await installTalkBrowserFixtures(page);
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
|
||||
const microphone = page.getByRole("button", { name: "Start voice input" });
|
||||
const microphoneBox = await microphone.boundingBox();
|
||||
expect(microphoneBox).not.toBeNull();
|
||||
if (!microphoneBox) {
|
||||
throw new Error("expected microphone layout box");
|
||||
}
|
||||
await page.mouse.move(
|
||||
microphoneBox.x + microphoneBox.width / 2,
|
||||
microphoneBox.y + microphoneBox.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await gateway.waitForRequest("talk.session.create");
|
||||
|
||||
const composer = page.locator(".agent-chat__input--dictating");
|
||||
const activity = composer.locator(
|
||||
".agent-chat__dictation-wave .agent-chat__voice-activity",
|
||||
);
|
||||
const elapsed = composer.locator(".agent-chat__dictation-elapsed");
|
||||
const stop = composer.getByRole("button", { name: "Stop dictation" });
|
||||
const send = composer.getByRole("button", { name: "Send message" });
|
||||
await expect.poll(() => activity.isVisible()).toBe(true);
|
||||
expect(await activity.locator(".agent-chat__voice-activity-bar").count()).toBe(48);
|
||||
await expect.poll(() => elapsed.textContent()).toBe("0:01");
|
||||
await expect.poll(() => stop.isVisible()).toBe(true);
|
||||
await expect.poll(() => send.isVisible()).toBe(true);
|
||||
await captureComposerProof(page, "dictation-waveform-timer-actions.png");
|
||||
const composerBox = await composer.boundingBox();
|
||||
expect(composerBox).not.toBeNull();
|
||||
if (!composerBox) {
|
||||
throw new Error("expected active dictation composer layout box");
|
||||
}
|
||||
for (const control of [activity, elapsed, stop, send]) {
|
||||
const box = await control.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) {
|
||||
throw new Error("expected visible dictation control layout box");
|
||||
}
|
||||
expect(box.x).toBeGreaterThanOrEqual(composerBox.x);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(composerBox.x + composerBox.width);
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect.poll(() => microphone.isVisible()).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -330,7 +330,7 @@ suite.define(() => {
|
||||
}
|
||||
proof.finish();
|
||||
});
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await expect.poll(() => send.isEnabled()).toBe(true);
|
||||
await send.click();
|
||||
|
||||
@@ -419,9 +419,8 @@ suite.define(() => {
|
||||
proof.finish();
|
||||
});
|
||||
await page
|
||||
.locator(
|
||||
'openclaw-chat-pane[aria-hidden="false"] .chat-attachment-thumb img[alt="Attachment preview"]',
|
||||
)
|
||||
.locator('openclaw-chat-pane[aria-hidden="false"]')
|
||||
.getByRole("img", { name: "pixel.png" })
|
||||
.waitFor();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -128,15 +128,17 @@ suite.define(() => {
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await outside.focus();
|
||||
await composer.locator(".agent-chat__input-btn--attach").click();
|
||||
const attachTrigger = composer.locator(".agent-chat__input-btn--attach");
|
||||
await attachTrigger.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator(".agent-chat__attach-menu-option")
|
||||
.first()
|
||||
.evaluate((element) => document.activeElement === element),
|
||||
)
|
||||
.poll(() => attachTrigger.evaluate((element) => document.activeElement === element))
|
||||
.toBe(true);
|
||||
expect(
|
||||
await page
|
||||
.locator(".agent-chat__attach-menu-option")
|
||||
.first()
|
||||
.evaluate((element) => document.activeElement === element),
|
||||
).toBe(false);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,10 @@ async function openMenu(page: Page) {
|
||||
return composer;
|
||||
}
|
||||
|
||||
function webSearchItem(menu: import("playwright").Locator) {
|
||||
return menu.locator('wa-dropdown-item[value="toggle-web-search"]');
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("renders the root stack, proxies attachments, patches sparse overrides, and clears the pill", async () => {
|
||||
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
||||
@@ -228,9 +232,7 @@ suite.define(() => {
|
||||
expect.stringContaining("Manage plugins"),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(() => dropdown.getByRole("menuitemcheckbox", { name: "Web search" }).isVisible())
|
||||
.toBe(true);
|
||||
await expect.poll(() => webSearchItem(dropdown).isVisible()).toBe(true);
|
||||
const skillsRoot = dropdown.getByRole("menuitem", { name: /^Skills/ });
|
||||
await skillsRoot.focus();
|
||||
await skillsRoot.evaluate((item) => {
|
||||
@@ -239,14 +241,7 @@ suite.define(() => {
|
||||
?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } }));
|
||||
});
|
||||
await expect.poll(() => dropdown.getAttribute("data-view")).toBe("skills");
|
||||
await expect
|
||||
.poll(() => dropdown.locator("wa-dropdown-item:focus").textContent())
|
||||
.toContain("Back");
|
||||
await dropdown.locator("wa-dropdown-item:focus").evaluate((item) => {
|
||||
item
|
||||
.closest("wa-dropdown")
|
||||
?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } }));
|
||||
});
|
||||
await dropdown.getByRole("menuitem", { name: "Back" }).click();
|
||||
await expect.poll(() => dropdown.getAttribute("data-view")).toBe("root");
|
||||
await expect
|
||||
.poll(() => dropdown.locator("wa-dropdown-item:focus").textContent())
|
||||
@@ -305,14 +300,26 @@ suite.define(() => {
|
||||
.toBe(false);
|
||||
|
||||
await menu.getByRole("menuitem", { name: "Back" }).click();
|
||||
const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" });
|
||||
await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true");
|
||||
await webSearch.click();
|
||||
const webSearch = webSearchItem(menu);
|
||||
await expect
|
||||
.poll(() =>
|
||||
webSearch.evaluate((node) => (node as HTMLElement & { checked: boolean }).checked),
|
||||
)
|
||||
.toBe(true);
|
||||
await webSearchItem(menu).click();
|
||||
await expect.poll(() => latestToolOverrides(gateway)).toEqual({ webSearch: false });
|
||||
await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("false");
|
||||
await webSearch.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
webSearch.evaluate((node) => (node as HTMLElement & { checked: boolean }).checked),
|
||||
)
|
||||
.toBe(false);
|
||||
await webSearchItem(menu).click();
|
||||
await expect.poll(() => latestToolOverrides(gateway)).toEqual({});
|
||||
await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
webSearch.evaluate((node) => (node as HTMLElement & { checked: boolean }).checked),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const themeBackgrounds: string[] = [];
|
||||
for (const mode of ["dark", "light"] as const) {
|
||||
@@ -579,9 +586,7 @@ suite.define(() => {
|
||||
const clear = composer.getByRole("button", { name: "Clear session overrides" });
|
||||
await expect.poll(() => clear.isDisabled()).toBe(true);
|
||||
await expect.poll(() => clear.getAttribute("title")).toContain("operator.admin access");
|
||||
await expect
|
||||
.poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).isDisabled())
|
||||
.toBe(true);
|
||||
await expect.poll(() => webSearchItem(menu).isDisabled()).toBe(true);
|
||||
await menu.getByRole("menuitem", { name: /^Skills/ }).click();
|
||||
const docs = menu.getByRole("menuitem", { name: /^Docs/ });
|
||||
await expect.poll(() => docs.isDisabled()).toBe(true);
|
||||
@@ -620,7 +625,7 @@ suite.define(() => {
|
||||
]);
|
||||
const composer = await openMenu(page);
|
||||
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
|
||||
const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" });
|
||||
const webSearch = webSearchItem(menu);
|
||||
await expect.poll(() => webSearch.isDisabled()).toBe(true);
|
||||
await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…");
|
||||
await webSearch.evaluate((item) => {
|
||||
@@ -637,13 +642,13 @@ suite.define(() => {
|
||||
webSearch: true,
|
||||
}),
|
||||
);
|
||||
await expect.poll(() => webSearch.isDisabled()).toBe(true);
|
||||
await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…");
|
||||
await expect.poll(() => webSearchItem(menu).isDisabled()).toBe(true);
|
||||
await expect.poll(() => webSearchItem(menu).getAttribute("title")).toBe("Loading…");
|
||||
expect(await gateway.getRequests("sessions.patch")).toHaveLength(0);
|
||||
|
||||
await gateway.resolveDeferred("config.get", configResponse({}, false));
|
||||
await expect.poll(() => webSearch.isDisabled()).toBe(false);
|
||||
await webSearch.click();
|
||||
await expect.poll(() => webSearchItem(menu).isDisabled()).toBe(false);
|
||||
await webSearchItem(menu).click();
|
||||
await expect
|
||||
.poll(() => latestToolOverrides(gateway))
|
||||
.toEqual({
|
||||
@@ -669,9 +674,7 @@ suite.define(() => {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const composer = await openMenu(page);
|
||||
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
|
||||
await expect
|
||||
.poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).count())
|
||||
.toBe(1);
|
||||
await expect.poll(() => webSearchItem(menu).count()).toBe(1);
|
||||
await menu.getByRole("menuitem", { name: /^Skills/ }).click();
|
||||
await expect.poll(() => menu.getByText("No skills available.").isVisible()).toBe(true);
|
||||
await menu.getByRole("menuitem", { name: "Back" }).click();
|
||||
|
||||
@@ -122,8 +122,10 @@ suite.define(() => {
|
||||
};
|
||||
});
|
||||
|
||||
expect(focused.borderColor).not.toBe(unfocused.borderColor);
|
||||
expect(focused.boxShadow).not.toBe(unfocused.boxShadow);
|
||||
expect([focused.borderColor, focused.boxShadow]).not.toEqual([
|
||||
unfocused.borderColor,
|
||||
unfocused.boxShadow,
|
||||
]);
|
||||
expect(focused.neutralChannelSpread).toBeLessThanOrEqual(24);
|
||||
expect(focused.contrast).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
|
||||
@@ -339,9 +339,10 @@ describeControlUiE2e("Control UI composer pointer controls", () => {
|
||||
|
||||
await textarea.fill("Verify keyboard Send");
|
||||
await textarea.focus();
|
||||
// Tab order after the single-primary redesign: mic, then the
|
||||
// focus-revealed device-picker trigger, then the primary send button.
|
||||
const keyboardSend = page.getByRole("button", { name: "Send message" });
|
||||
// Send holds the trailing end of the action row, behind the microphone, so
|
||||
// it is no longer one Tab away. What this proves is that plain forward
|
||||
// tabbing still reaches it — no trap, no skipped control.
|
||||
for (let tabs = 0; tabs < 4; tabs += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
if (await keyboardSend.evaluate((node) => document.activeElement === node)) {
|
||||
@@ -349,7 +350,13 @@ describeControlUiE2e("Control UI composer pointer controls", () => {
|
||||
}
|
||||
}
|
||||
await expect
|
||||
.poll(() => keyboardSend.evaluate((node) => document.activeElement === node))
|
||||
.poll(async () => {
|
||||
if (await keyboardSend.evaluate((node) => document.activeElement === node)) {
|
||||
return true;
|
||||
}
|
||||
await page.keyboard.press("Tab");
|
||||
return false;
|
||||
})
|
||||
.toBe(true);
|
||||
await keyboardSend.press("Enter");
|
||||
await expect.poll(async () => (await gateway.getRequests("chat.send")).length).toBe(2);
|
||||
|
||||
@@ -9,82 +9,29 @@ const suite = createControlUiE2eSuite({
|
||||
|
||||
// Browser contexts preserve test isolation; keep one process warm for this file.
|
||||
suite.define(() => {
|
||||
it("uses only authoritative catalog snapshots to gate the composer", async () => {
|
||||
await suite.withPage({ viewport: { width: 1280, height: 800 } }, async ({ page }) => {
|
||||
const coldModels = [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
const gateway = await installMockGateway(page, {
|
||||
agentModel: "openai/gpt-5.5",
|
||||
models: coldModels,
|
||||
});
|
||||
it("keeps offline status in one bounded composer row", async () => {
|
||||
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page);
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
|
||||
const composer = page.locator(".agent-chat__input");
|
||||
const textarea = composer.locator("textarea");
|
||||
const model = composer.locator('[data-chat-model-select="true"]');
|
||||
const disabledReason = composer.locator(".agent-chat__disabled-reason");
|
||||
const authFailure =
|
||||
"Authentication failed. Review the provider credential or sign-in, then retry.";
|
||||
|
||||
await expect.poll(() => textarea.isDisabled()).toBe(true);
|
||||
await expect.poll(async () => (await disabledReason.textContent())?.trim()).toBe(authFailure);
|
||||
await expect.poll(() => textarea.getAttribute("placeholder")).toBe("Message OpenClaw");
|
||||
|
||||
await gateway.setOnline(false);
|
||||
|
||||
const statusBand = page.locator(".agent-chat__composer-status-band");
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await model.locator(".chat-controls__inline-select-label").textContent())?.trim(),
|
||||
.poll(() => statusBand.locator("xpath=..").getAttribute("data-tone"))
|
||||
.toBe("warn");
|
||||
await expect.poll(() => statusBand.textContent()).toContain("Offline");
|
||||
await expect
|
||||
.poll(() =>
|
||||
statusBand.locator("svg").evaluate((node) => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
return [bounds.width, bounds.height];
|
||||
}),
|
||||
)
|
||||
.toBe("Offline");
|
||||
await expect.poll(() => textarea.isEnabled()).toBe(true);
|
||||
await expect.poll(() => disabledReason.count()).toBe(0);
|
||||
|
||||
await gateway.deferNext("chat.startup");
|
||||
await gateway.setOnline(true);
|
||||
await gateway.waitForRequest("chat.startup", { after: 1 });
|
||||
.toEqual([16, 16]);
|
||||
await expect
|
||||
.poll(() => composer.locator('[data-chat-model-catalog-state="refreshing"]').count())
|
||||
.toBe(1);
|
||||
await expect.poll(() => textarea.isEnabled()).toBe(true);
|
||||
await expect.poll(() => disabledReason.count()).toBe(0);
|
||||
|
||||
await gateway.resolveDeferred("chat.startup");
|
||||
await expect.poll(() => textarea.isDisabled()).toBe(true);
|
||||
await expect.poll(async () => (await disabledReason.textContent())?.trim()).toBe(authFailure);
|
||||
|
||||
await gateway.setMethodResponse("models.list", {
|
||||
__mockError: { code: "UNAVAILABLE", message: "mock catalog refresh failed" },
|
||||
});
|
||||
const beforeFailure = (await gateway.getRequests("models.list")).length;
|
||||
await model.click();
|
||||
await gateway.waitForRequest("models.list", { after: beforeFailure });
|
||||
await expect.poll(() => composer.locator("[data-chat-model-catalog-state]").count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => composer.locator('[data-chat-model-option="openai/gpt-5.5"]').count())
|
||||
.toBe(1);
|
||||
await expect.poll(() => textarea.isEnabled()).toBe(true);
|
||||
await expect.poll(() => disabledReason.count()).toBe(0);
|
||||
|
||||
await gateway.setMethodResponse("models.list", {
|
||||
models: [{ ...coldModels[0], available: true }],
|
||||
});
|
||||
const beforeRetry = (await gateway.getRequests("models.list")).length;
|
||||
await model.click();
|
||||
await model.click();
|
||||
const retry = await gateway.waitForRequest("models.list", { after: beforeRetry });
|
||||
expect(retry.params).toEqual({ agentId: "main", view: "configured", refresh: true });
|
||||
await expect.poll(() => textarea.isEnabled()).toBe(true);
|
||||
await expect.poll(() => composer.locator("[data-chat-model-catalog-state]").count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => composer.locator('[data-chat-model-option="openai/gpt-5.5"]').isEnabled())
|
||||
.toBe(true);
|
||||
.poll(() => statusBand.evaluate((node) => node.getBoundingClientRect().height))
|
||||
.toBe(44);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +87,7 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the model in the bottom bar, session settings in the header, and switches the primary action with input state", async () => {
|
||||
it("keeps the model in the bottom bar, session settings in the header, and holds send beside the microphone in every input state", async () => {
|
||||
await suite.withPage({ viewport: { width: 1920, height: 1080 } }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
assistantName: "Rosita",
|
||||
@@ -210,7 +157,6 @@ suite.define(() => {
|
||||
label: "Main",
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
permissionMode: "workspace",
|
||||
status: "done",
|
||||
totalTokens: 46_000,
|
||||
totalTokensFresh: true,
|
||||
@@ -231,7 +177,6 @@ suite.define(() => {
|
||||
const chatMain = page.locator(".chat-workbench__main");
|
||||
const model = composer.locator('[data-chat-model-select="true"]');
|
||||
const effort = composer.locator('[data-chat-thinking-select="true"]');
|
||||
const permission = composer.locator('[data-chat-permission-select="true"]');
|
||||
const usage = composer.locator('[data-chat-provider-usage="true"]');
|
||||
const contextUsage = composer.locator(".context-ring");
|
||||
const textarea = composer.locator("textarea");
|
||||
@@ -247,7 +192,6 @@ suite.define(() => {
|
||||
const microphonePickerShell = page.locator(".chat-talk-input-picker");
|
||||
|
||||
await expect.poll(() => model.isVisible()).toBe(true);
|
||||
await expect.poll(() => permission.isVisible()).toBe(true);
|
||||
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
||||
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
||||
await expect.poll(() => contextUsage.isVisible()).toBe(true);
|
||||
@@ -266,38 +210,29 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start video talk" }).count())
|
||||
.toBe(0);
|
||||
// The editor's row holds nothing but the text: attachments open from the
|
||||
// leading end of the action row and voice sits with the primary action at
|
||||
// its trailing end, so the whole bottom row is one band of controls.
|
||||
await expect
|
||||
.poll(() =>
|
||||
attach.evaluate((node) => node.closest(".agent-chat__composer-input-row") != null),
|
||||
)
|
||||
.poll(() => attach.evaluate((node) => node.closest(".agent-chat__composer-lead") != null))
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
voice.evaluate((node) => node.closest(".agent-chat__composer-input-row") != null),
|
||||
)
|
||||
.poll(() => voice.evaluate((node) => node.closest(".agent-chat__composer-trail") != null))
|
||||
.toBe(true);
|
||||
// The device chevron is hidden at rest and grows out of the microphone's
|
||||
// leading edge on approach, so the resting action row shows one circular
|
||||
// mic and nothing beside it. It must claim no width while collapsed, or it
|
||||
// would hold an empty gap in the row it is supposed to stay out of.
|
||||
const pickerWidth = () =>
|
||||
microphonePicker.evaluate((node) => node.getBoundingClientRect().width);
|
||||
await expect.poll(pickerWidth).toBe(0);
|
||||
await voice.hover();
|
||||
await expect.poll(pickerWidth).toBeGreaterThanOrEqual(12);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect.poll(pickerWidth).toBe(0);
|
||||
await expect
|
||||
.poll(() => model.evaluate((node) => node.closest(".agent-chat__composer-footer") != null))
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
permission.evaluate((node) => node.closest(".agent-chat__composer-meta") != null),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
permission.evaluate((node) => node.closest(".chat-composer-model-control") == null),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [permissionBox, modelBox] = await Promise.all([
|
||||
permission.boundingBox(),
|
||||
model.boundingBox(),
|
||||
]);
|
||||
return Boolean(permissionBox && modelBox && permissionBox.x < modelBox.x);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => settings.evaluate((node) => node.closest(".chat-pane__header") != null))
|
||||
.toBe(true);
|
||||
@@ -312,21 +247,13 @@ suite.define(() => {
|
||||
(await effort.locator(".chat-controls__inline-select-label").textContent())?.trim(),
|
||||
)
|
||||
.toBe("High");
|
||||
for (const trigger of [model, effort]) {
|
||||
const title = await trigger.getAttribute("title");
|
||||
expect(title).toBeTruthy();
|
||||
await trigger.hover();
|
||||
expect(await trigger.getAttribute("title")).toBe("");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect.poll(() => trigger.getAttribute("title")).toBe(title);
|
||||
}
|
||||
await expect.poll(() => contextUsage.locator(".context-ring__detail").count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => contextUsage.getAttribute("aria-label"))
|
||||
.toBe("Session context usage: 46k of 200k (23%)");
|
||||
await expect
|
||||
.poll(() =>
|
||||
contextUsage.evaluate((node) => node.closest(".agent-chat__composer-context") != null),
|
||||
contextUsage.evaluate((node) => node.closest(".agent-chat__composer-meta") != null),
|
||||
)
|
||||
.toBe(true);
|
||||
await contextUsage.click();
|
||||
@@ -519,6 +446,25 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start voice input" }).isVisible())
|
||||
.toBe(true);
|
||||
// Every other control here is a step of the surface itself, so colour is
|
||||
// what marks the one committed action once there is something to send.
|
||||
const brandFill = await page.evaluate(() => {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--primary")
|
||||
.trim();
|
||||
document.body.append(probe);
|
||||
const resolved = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return resolved;
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.getByRole("button", { name: "Send message" })
|
||||
.evaluate((node) => getComputedStyle(node).backgroundColor),
|
||||
)
|
||||
.toBe(brandFill);
|
||||
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
const sendRequest = await gateway.waitForRequest("chat.send");
|
||||
@@ -585,25 +531,11 @@ suite.define(() => {
|
||||
await textarea.fill("");
|
||||
const stop = page.getByRole("button", { name: "Stop generating" });
|
||||
await expect.poll(() => stop.isVisible()).toBe(true);
|
||||
await voice.hover();
|
||||
// Stop is deliberately left out of the brand fill: commit and interrupt
|
||||
// share one slot, so they must not share one colour.
|
||||
await expect
|
||||
.poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity))
|
||||
.toBe("1");
|
||||
const [runningVoiceBox, runningPickerBox, runningStopBox] = await Promise.all([
|
||||
voice.boundingBox(),
|
||||
microphonePicker.boundingBox(),
|
||||
stop.boundingBox(),
|
||||
]);
|
||||
expect(runningVoiceBox).not.toBeNull();
|
||||
expect(runningPickerBox).not.toBeNull();
|
||||
expect(runningStopBox).not.toBeNull();
|
||||
if (!runningVoiceBox || !runningPickerBox || !runningStopBox) {
|
||||
throw new Error("expected running composer action layout boxes");
|
||||
}
|
||||
const microphonePickerGap = runningPickerBox.x - (runningVoiceBox.x + runningVoiceBox.width);
|
||||
const stopGap = runningStopBox.x - (runningPickerBox.x + runningPickerBox.width);
|
||||
expect(microphonePickerGap).toBeLessThanOrEqual(1);
|
||||
expect(stopGap).toBeGreaterThanOrEqual(8);
|
||||
.poll(() => stop.evaluate((node) => getComputedStyle(node).backgroundColor))
|
||||
.not.toBe(brandFill);
|
||||
await textarea.press("Escape");
|
||||
const abortRequest = await gateway.waitForRequest("chat.abort");
|
||||
expect(abortRequest.params).toMatchObject({
|
||||
@@ -618,13 +550,25 @@ suite.define(() => {
|
||||
.toBe(true);
|
||||
await expect.poll(() => emptySend.isVisible()).toBe(true);
|
||||
await expect.poll(() => emptySend.isDisabled()).toBe(true);
|
||||
// Send holds its place with nothing to send: it goes unavailable rather
|
||||
// than disappearing, so the composer never looks like it lost the control
|
||||
// that commits a turn.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [voiceRect, sendRect] = await Promise.all([
|
||||
voice.boundingBox(),
|
||||
emptySend.boundingBox(),
|
||||
]);
|
||||
return voiceRect && sendRect ? sendRect.x - (voiceRect.x + voiceRect.width) : null;
|
||||
})
|
||||
.toBeGreaterThanOrEqual(-1);
|
||||
|
||||
await page.setViewportSize({ width: 393, height: 852 });
|
||||
await expect.poll(() => camera.count()).toBe(0);
|
||||
expect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(false);
|
||||
await expect
|
||||
.poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity))
|
||||
.toBe("0");
|
||||
.poll(() => microphonePickerShell.evaluate((node) => node.getBoundingClientRect().width))
|
||||
.toBe(0);
|
||||
// Resize re-layout is async; wait for the header controls to adopt the
|
||||
// mobile width before sampling one-shot bounding boxes below.
|
||||
await expect
|
||||
@@ -662,7 +606,7 @@ suite.define(() => {
|
||||
return [style.paddingInlineStart, style.paddingInlineEnd];
|
||||
}),
|
||||
)
|
||||
.toEqual(["10px", "10px"]);
|
||||
.toEqual(["0px", "0px"]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
effort.evaluate((node) => {
|
||||
@@ -670,7 +614,7 @@ suite.define(() => {
|
||||
return [style.paddingInlineStart, style.paddingInlineEnd];
|
||||
}),
|
||||
)
|
||||
.toEqual(["9px", "11px"]);
|
||||
.toEqual(["4px", "4px"]);
|
||||
for (const control of [mobileModelBox, mobileContextBox]) {
|
||||
expect(
|
||||
Math.abs(control.y + control.height / 2 - (mobileModelBox.y + mobileModelBox.height / 2)),
|
||||
@@ -731,36 +675,34 @@ suite.define(() => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gateway.setOnline(false);
|
||||
await expect.poll(() => voice.isDisabled()).toBe(true);
|
||||
// The device chevron is a modifier on the microphone, not a second half of
|
||||
// a split pill: it carries no ground of its own in any state, so an
|
||||
// unavailable microphone cannot leave a tinted segment stranded beside it.
|
||||
await expect
|
||||
.poll(() => microphonePicker.evaluate((node) => getComputedStyle(node).backgroundColor))
|
||||
.toBe("rgba(0, 0, 0, 0)");
|
||||
await expect
|
||||
.poll(() => microphonePicker.evaluate((node) => getComputedStyle(node).borderLeftWidth))
|
||||
.toBe("0px");
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
if (artifactDir) {
|
||||
await composerShell.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${artifactDir}/voice-picker-disabled-background.png`,
|
||||
});
|
||||
}
|
||||
await page.mouse.move(0, 0);
|
||||
await expect.poll(() => page.locator("wa-tooltip[open]").count()).toBe(0);
|
||||
// The picker reserves its width while hidden; only opacity reveals it, so
|
||||
// hovering never shifts the right-aligned mic/send cluster sideways.
|
||||
await expect
|
||||
.poll(() => microphonePickerShell.evaluate((node) => node.getBoundingClientRect().width))
|
||||
.toBe(22);
|
||||
await expect
|
||||
.poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity))
|
||||
.toBe("0");
|
||||
.toBe(0);
|
||||
await expect.poll(() => voice.evaluate((node) => getComputedStyle(node).opacity)).toBe("0.4");
|
||||
const idleVoiceBox = await voice.boundingBox();
|
||||
expect(idleVoiceBox).not.toBeNull();
|
||||
|
||||
await voice.hover();
|
||||
await expect
|
||||
.poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity))
|
||||
.toBe("1");
|
||||
await expect
|
||||
.poll(() => microphonePickerShell.evaluate((node) => node.getBoundingClientRect().width))
|
||||
.toBe(22);
|
||||
const hoveredVoiceBox = await voice.boundingBox();
|
||||
expect(hoveredVoiceBox).not.toBeNull();
|
||||
if (!idleVoiceBox || !hoveredVoiceBox) {
|
||||
throw new Error("expected voice button layout boxes around hover");
|
||||
}
|
||||
expect(hoveredVoiceBox.x).toBe(idleVoiceBox.x);
|
||||
await microphonePicker.click();
|
||||
await expect.poll(() => microphonePicker.getAttribute("aria-expanded")).toBe("true");
|
||||
await expect.poll(() => page.locator(".chat-talk-input-picker[open]").count()).toBe(1);
|
||||
.toBe(0);
|
||||
await expect.poll(() => microphonePicker.isVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,10 +272,6 @@ suite.define(() => {
|
||||
|
||||
await waitForChatScrollIdle(page);
|
||||
await scrollChatThreadToTop(page);
|
||||
await activeThread.getByText("User history question 10").waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await scrollChatThreadToTop(page);
|
||||
await activeThread.getByText("User history question 0").waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
@@ -734,7 +730,11 @@ suite.define(() => {
|
||||
await composer.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
await gateway.setOnline(false);
|
||||
await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 });
|
||||
await page
|
||||
.locator(
|
||||
'.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band',
|
||||
)
|
||||
.waitFor({ timeout: 10_000 });
|
||||
|
||||
const prompt = "send this when the Gateway returns";
|
||||
const attachmentName = "offline-proof.txt";
|
||||
@@ -878,7 +878,11 @@ suite.define(() => {
|
||||
return proof.attachment || proof.prompt || proof.runId === runId;
|
||||
})
|
||||
.toBe(false);
|
||||
await page.locator(".agent-chat__offline-hint").waitFor({ state: "detached" });
|
||||
await page
|
||||
.locator(
|
||||
'.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band',
|
||||
)
|
||||
.waitFor({ state: "detached" });
|
||||
await expectRequestCountStable(gateway, "chat.send", 1);
|
||||
if (artifactDir) {
|
||||
await page.screenshot({ path: `${artifactDir}/03-online-delivered.png`, fullPage: true });
|
||||
|
||||
@@ -13,6 +13,18 @@ import {
|
||||
} from "./chat-flow.test-support.ts";
|
||||
|
||||
const suite = createChatFlowE2eSuite();
|
||||
|
||||
async function readTopTranscriptAnchor(thread: import("playwright").Locator) {
|
||||
return thread.evaluate((element) => {
|
||||
const top = element.getBoundingClientRect().top;
|
||||
const rows = [...element.querySelectorAll<HTMLElement>("[data-virtual-row-key]")];
|
||||
const row = rows.find((candidate) => candidate.getBoundingClientRect().bottom > top);
|
||||
return row
|
||||
? { key: row.dataset.virtualRowKey ?? null, offset: row.getBoundingClientRect().top - top }
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("coalesces persisted same-session split panes during cold startup", async () => {
|
||||
const context = await suite.newBrowserContext({
|
||||
@@ -137,8 +149,8 @@ suite.define(() => {
|
||||
transcript.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await waitForChatScrollIdle(page);
|
||||
const storedScrollTop = await thread.evaluate((element) => element.scrollTop);
|
||||
expect(storedScrollTop).toBeGreaterThan(0);
|
||||
const storedAnchor = await readTopTranscriptAnchor(thread);
|
||||
expect(storedAnchor?.key).not.toBeNull();
|
||||
|
||||
const sessionLink = (sessionKey: string) =>
|
||||
page.locator(
|
||||
@@ -170,10 +182,12 @@ suite.define(() => {
|
||||
scrollTop: transcript.scrollTop,
|
||||
};
|
||||
});
|
||||
const restoredAnchor = await readTopTranscriptAnchor(thread);
|
||||
expect(restoredAnchor?.key).toBe(storedAnchor?.key);
|
||||
expect(
|
||||
Math.abs(restored.scrollTop - storedScrollTop),
|
||||
JSON.stringify({ restored, storedScrollTop }),
|
||||
).toBeLessThanOrEqual(120);
|
||||
Math.abs((restoredAnchor?.offset ?? 0) - (storedAnchor?.offset ?? 0)),
|
||||
JSON.stringify({ restoredAnchor, storedAnchor }),
|
||||
).toBeLessThanOrEqual(2);
|
||||
expect(restored.distanceFromBottom).toBeGreaterThan(8);
|
||||
|
||||
const historyRequestsBeforeEndReturn = (await gateway.getRequests("chat.history")).length;
|
||||
|
||||
@@ -202,7 +202,11 @@ suite.define(() => {
|
||||
}
|
||||
await gateway.setOnline(false);
|
||||
await gateway.closeLatest();
|
||||
await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 });
|
||||
await page
|
||||
.locator(
|
||||
'.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band',
|
||||
)
|
||||
.waitFor({ timeout: 10_000 });
|
||||
|
||||
const editRow = page.locator(".chat-queue__item", { hasText: "edit before send" });
|
||||
const editButton = editRow.locator(".chat-queue__edit");
|
||||
@@ -341,7 +345,9 @@ suite.define(() => {
|
||||
await gateway.deferNext("chat.send");
|
||||
await gateway.setOnline(true);
|
||||
await page
|
||||
.locator(".agent-chat__offline-hint")
|
||||
.locator(
|
||||
'.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band',
|
||||
)
|
||||
.waitFor({ state: "detached", timeout: 10_000 });
|
||||
await gateway.emitChatFinal({ runId: activeRunId, text: "Initial run completed." });
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
|
||||
@@ -539,23 +539,24 @@ suite.define(() => {
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
await page.screenshot({ path: path.join(artifactDir, `terminal-partial-${label}.png`) });
|
||||
}
|
||||
const alert = page.locator(".chat-run-error");
|
||||
const alert = page.locator(".chat-error");
|
||||
await alert.getByText(errorText).waitFor({ timeout: 10_000 });
|
||||
expect(await alert.locator("button").count()).toBe(0);
|
||||
expect(await page.locator(".chat-thread-inner").getByText(errorText).count()).toBe(0);
|
||||
expect(
|
||||
await alert.evaluate((element) =>
|
||||
element.nextElementSibling?.classList.contains("agent-chat__composer-shell"),
|
||||
),
|
||||
).toBe(true);
|
||||
const [alertBox, composerBox] = await Promise.all([
|
||||
alert.boundingBox(),
|
||||
page.locator(".agent-chat__composer-shell").boundingBox(),
|
||||
]);
|
||||
expect(alertBox).not.toBeNull();
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(Math.abs((alertBox?.x ?? 0) - (composerBox?.x ?? 0))).toBeLessThan(1);
|
||||
expect(Math.abs((alertBox?.width ?? 0) - (composerBox?.width ?? 0))).toBeLessThan(1);
|
||||
expect(
|
||||
Math.abs(
|
||||
(alertBox?.x ?? 0) +
|
||||
(alertBox?.width ?? 0) / 2 -
|
||||
((composerBox?.x ?? 0) + (composerBox?.width ?? 0) / 2),
|
||||
),
|
||||
).toBeLessThan(1);
|
||||
expect(alertBox?.width ?? 0).toBeLessThanOrEqual(composerBox?.width ?? 0);
|
||||
|
||||
await page.locator(".agent-chat__composer-combobox textarea").fill("retry after error");
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
@@ -91,7 +91,11 @@ suite.define(() => {
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
await composer.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await gateway.setOnline(false);
|
||||
await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 });
|
||||
await page
|
||||
.locator(
|
||||
'.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band',
|
||||
)
|
||||
.waitFor({ timeout: 10_000 });
|
||||
|
||||
const prompt = "deliver the work outbox independently";
|
||||
await composer.fill(prompt);
|
||||
@@ -113,7 +117,9 @@ suite.define(() => {
|
||||
});
|
||||
await gateway.setOnline(true);
|
||||
await page
|
||||
.locator(".agent-chat__offline-hint")
|
||||
.locator(
|
||||
'.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band',
|
||||
)
|
||||
.waitFor({ state: "detached", timeout: 10_000 });
|
||||
await page.evaluate(async () => {
|
||||
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Locator, Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
controlUiBundledSettingsStorageKey,
|
||||
controlUiSessionUrl,
|
||||
installMockGateway,
|
||||
type ControlUiMockGatewayScenario,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
@@ -900,7 +901,7 @@ suite.define(() => {
|
||||
async ({ page }) => {
|
||||
await seedSettings(page, "light");
|
||||
const gateway = await installMockGateway(page, scenario());
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey));
|
||||
await page.locator(".chat-group").first().waitFor();
|
||||
await activateChatHeaderPanelAction(page, "Show session files");
|
||||
await openFromPlus(page, "Terminal");
|
||||
@@ -932,7 +933,7 @@ suite.define(() => {
|
||||
expect(companionGeometry.railTop).toBeGreaterThanOrEqual(companionGeometry.bodyTop - 1);
|
||||
expect(companionGeometry.railBottom).toBeLessThanOrEqual(companionGeometry.bodyBottom + 1);
|
||||
|
||||
const mainComposer = page.locator(".agent-chat__composer-combobox > textarea");
|
||||
const mainComposer = page.getByRole("textbox", { name: "Message OpenClaw", exact: true });
|
||||
await mainComposer.click();
|
||||
expect(await mainComposer.evaluate((element) => element === document.activeElement)).toBe(
|
||||
true,
|
||||
|
||||
@@ -3,13 +3,13 @@ import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { CHAT_RUN_STATUS_TOAST_DURATION_MS } from "../pages/chat/run-lifecycle.ts";
|
||||
import { installMockGateway, pauseVirtualClock } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "Control UI chat run lifecycle",
|
||||
});
|
||||
const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000;
|
||||
|
||||
// Browser contexts preserve test isolation; keep one process warm for this file.
|
||||
let page: Page | undefined;
|
||||
@@ -404,7 +404,7 @@ suite.define(() => {
|
||||
await gateway.resolveDeferred("sessions.list");
|
||||
});
|
||||
|
||||
it("renders a safe self-abort diagnostic while preserving interrupted status", async () => {
|
||||
it("renders a safe self-abort diagnostic without leaving stale composer status", async () => {
|
||||
const artifactDir = path.resolve(".artifacts/control-ui-e2e/chat-abort-diagnostic");
|
||||
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
if (captureProof) {
|
||||
@@ -434,7 +434,7 @@ suite.define(() => {
|
||||
const alert = currentPage.getByRole("alert").filter({ hasText: diagnostic });
|
||||
await alert.waitFor();
|
||||
expect((await alert.textContent())?.trim()).toContain(`Error: ${diagnostic}`);
|
||||
await currentPage.getByLabel("Run status: Interrupted").waitFor();
|
||||
expect(await currentPage.getByLabel("Run status: Interrupted").count()).toBe(0);
|
||||
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
|
||||
if (captureProof) {
|
||||
await currentPage.screenshot({
|
||||
|
||||
@@ -176,11 +176,55 @@ describeControlUiE2e("Control UI cloud workspace conflict recovery", () => {
|
||||
expect(await historyCard.textContent()).toContain(conflict.stagedResultRef);
|
||||
await capture(page, "01-live-conflict.png");
|
||||
|
||||
await page.getByRole("button", { name: "Dismiss workspace conflict notice" }).click();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const composer = page.locator(".agent-chat__composer-shell");
|
||||
const title = notice.locator(".chat-composer-neighbor-card__copy strong");
|
||||
const summary = notice.locator(".chat-composer-neighbor-card__copy > span");
|
||||
const dismiss = notice.getByRole("button", { name: "Dismiss workspace conflict notice" });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [composerBox, noticeBox] = await Promise.all([
|
||||
composer.boundingBox(),
|
||||
notice.boundingBox(),
|
||||
]);
|
||||
return composerBox && noticeBox
|
||||
? {
|
||||
center: Math.abs(
|
||||
composerBox.x + composerBox.width / 2 - (noticeBox.x + noticeBox.width / 2),
|
||||
),
|
||||
inset: composerBox.width - noticeBox.width,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.toEqual({ center: 0, inset: 28 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
title.evaluate((node) => ({
|
||||
title: getComputedStyle(node).whiteSpace,
|
||||
summary: getComputedStyle(node.nextElementSibling!).whiteSpace,
|
||||
})),
|
||||
)
|
||||
.toEqual({ title: "nowrap", summary: "nowrap" });
|
||||
for (const item of [title, summary, dismiss]) {
|
||||
const [itemBox, noticeBox] = await Promise.all([item.boundingBox(), notice.boundingBox()]);
|
||||
expect(itemBox).not.toBeNull();
|
||||
expect(noticeBox).not.toBeNull();
|
||||
if (!itemBox || !noticeBox) {
|
||||
throw new Error("expected mobile conflict notice layout boxes");
|
||||
}
|
||||
expect(itemBox.x).toBeGreaterThanOrEqual(noticeBox.x);
|
||||
expect(itemBox.x + itemBox.width).toBeLessThanOrEqual(noticeBox.x + noticeBox.width);
|
||||
expect(itemBox.y).toBeGreaterThanOrEqual(noticeBox.y);
|
||||
expect(itemBox.y + itemBox.height).toBeLessThanOrEqual(noticeBox.y + noticeBox.height);
|
||||
}
|
||||
await capture(page, "02-mobile-live-conflict.png");
|
||||
|
||||
await dismiss.click();
|
||||
await notice.waitFor({ state: "detached" });
|
||||
await historyCard.waitFor();
|
||||
await capture(page, "02-dismissed-live-notice.png");
|
||||
await capture(page, "03-dismissed-live-notice.png");
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gateway.setMethodResponse("sessions.list", sessionsList(false));
|
||||
await page.reload();
|
||||
await page.locator(".chat-workspace-conflict-event").waitFor({ timeout: 10_000 });
|
||||
@@ -190,7 +234,7 @@ describeControlUiE2e("Control UI cloud workspace conflict recovery", () => {
|
||||
expect(await page.locator(".chat-workspace-conflict-event").textContent()).toContain(
|
||||
conflict.stagedResultRef,
|
||||
);
|
||||
await capture(page, "03-reloaded-durable-history.png");
|
||||
await capture(page, "04-reloaded-durable-history.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -257,7 +301,7 @@ describeControlUiE2e("Control UI cloud workspace conflict recovery", () => {
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.getByText("Remote work completed successfully.").waitFor({ timeout: 10_000 });
|
||||
expect(await page.getByRole("alert").count()).toBe(0);
|
||||
await capture(page, "04-before-workspace-recovery-error.png");
|
||||
await capture(page, "05-before-workspace-recovery-error.png");
|
||||
|
||||
await gateway.setMethodResponse("sessions.list", workerRecoverySessionsList(true));
|
||||
await page.reload();
|
||||
|
||||
@@ -107,6 +107,60 @@ async function captureElement(locator: Locator, name: string) {
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("drops a pending skill completion after an agent switch", async () => {
|
||||
const context = await suite.browser.newContext(CONTEXT);
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
defaultAgentId: "main",
|
||||
deferredMethods: ["commands.list"],
|
||||
featureMethods: [
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
"commands.list",
|
||||
"sessions.create",
|
||||
"sessions.dispatch",
|
||||
],
|
||||
methodResponses: {
|
||||
"agent.identity.get": agentIdentities,
|
||||
"agents.list": agentsList,
|
||||
"chat.metadata": { models: [] },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new?agent=main`);
|
||||
await gateway.waitForRequest("agent.identity.get");
|
||||
const composer = page.locator(".new-session-page__composer textarea");
|
||||
await composer.fill("$");
|
||||
await composer.press("End");
|
||||
await composer.dispatchEvent("select");
|
||||
await gateway.waitForRequest("commands.list");
|
||||
|
||||
const picker = page.locator(".new-session-page__select--agent openclaw-agent-select");
|
||||
await picker.evaluate((element) => {
|
||||
(element as HTMLElement & { onSelect: (agentId: string) => void }).onSelect("research");
|
||||
});
|
||||
await gateway.resolveDeferred("commands.list", {
|
||||
commands: [
|
||||
{
|
||||
description: "Only available to the previous agent.",
|
||||
name: "main_only",
|
||||
source: "skill",
|
||||
skillModelVisible: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await pollLocatorText(picker.locator(".agent-select__label")).toBe("research");
|
||||
await expect
|
||||
.poll(() => page.getByRole("listbox", { name: "Skill references" }).count())
|
||||
.toBe(0);
|
||||
await expect.poll(() => composer.inputValue()).toBe("$");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["dark", "light"] as const)(
|
||||
"uses resolved identity in the New Session hero and picker in %s mode",
|
||||
async (theme) => {
|
||||
|
||||
@@ -123,12 +123,12 @@ suite.define(() => {
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await page.getByRole("button", { name: "Remove attachment" }).click();
|
||||
await expectActiveObjectUrls(0);
|
||||
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await waitForCommittedNewSessionDraft(page, "", 1);
|
||||
const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown");
|
||||
await page.locator(".new-session-page__select--agent .agent-select__trigger").click();
|
||||
@@ -176,7 +176,7 @@ suite.define(() => {
|
||||
await expectActiveObjectUrls(0);
|
||||
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await waitForCommittedNewSessionDraft(page, "", 1);
|
||||
await navigate("chat");
|
||||
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
|
||||
@@ -189,7 +189,7 @@ suite.define(() => {
|
||||
await expectActiveObjectUrls(0);
|
||||
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await expectActiveObjectUrls(1);
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
await page.waitForURL(
|
||||
|
||||
@@ -263,7 +263,7 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start session" }).isEnabled())
|
||||
.toBe(true);
|
||||
expect(await page.locator(".chat-send-btn").count()).toBe(1);
|
||||
expect(await page.locator(".new-session-page__start-submit").count()).toBe(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ suite.define(() => {
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
await composer.fill(message);
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
const startButton = page.getByRole("button", { name: "Start session" });
|
||||
await gateway.deferNext("environments.list");
|
||||
const profileRequests = (await gateway.getRequests("environments.list")).length;
|
||||
|
||||
@@ -178,6 +178,34 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts sensitive connection-link failures before rendering them", async () => {
|
||||
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
deferredMethods: ["device.pair.setupCode"],
|
||||
});
|
||||
const secret = "e2e-pairing-bearer-secret";
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
await page.locator("#new-session-where-trigger").click();
|
||||
await page.getByRole("button", { name: "Connect a machine…" }).click();
|
||||
await gateway.waitForRequest("device.pair.setupCode");
|
||||
await gateway.rejectDeferred("device.pair.setupCode", {
|
||||
message: `pairing failed: Authorization: Bearer ${secret}`,
|
||||
});
|
||||
|
||||
const alert = page
|
||||
.locator('openclaw-modal-dialog[label="Connect a machine"]')
|
||||
.getByRole("alert");
|
||||
await alert.waitFor();
|
||||
expect(await alert.textContent()).toContain("Authorization: [redacted]");
|
||||
expect(await alert.textContent()).not.toContain(secret);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes an in-flight connection dialog when the Gateway reconnects", async () => {
|
||||
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -3,8 +3,10 @@ import path from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
controlUiSessionPath,
|
||||
createNewSessionPageE2eSuite,
|
||||
installMockGateway,
|
||||
navigateInApp,
|
||||
waitForCommittedNewSessionDraft,
|
||||
} from "./new-session-page.test-support.ts";
|
||||
|
||||
@@ -245,4 +247,47 @@ suite.define(() => {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates route drafts and retires incognito and submitted drafts", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": { key: "agent:main:retired-draft", runStarted: true },
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}new?agent=main`);
|
||||
const message = page.locator(".new-session-page__message");
|
||||
await message.fill("main route draft");
|
||||
await navigateInApp(page, "new-session", "?agent=writer");
|
||||
await expect.poll(() => message.inputValue()).toBe("");
|
||||
await message.fill("writer route draft");
|
||||
await navigateInApp(page, "new-session", "?agent=main");
|
||||
await expect.poll(() => message.inputValue()).toBe("main route draft");
|
||||
|
||||
await page.getByRole("switch", { name: "Incognito" }).click();
|
||||
await page.reload();
|
||||
await expect.poll(() => message.inputValue()).toBe("");
|
||||
await navigateInApp(page, "new-session", "?agent=writer");
|
||||
await expect.poll(() => message.inputValue()).toBe("writer route draft");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === controlUiSessionPath("agent:main:retired-draft"),
|
||||
);
|
||||
await page.close();
|
||||
const restoredPage = await context.newPage();
|
||||
await installMockGateway(restoredPage);
|
||||
await restoredPage.goto(`${suite.server.baseUrl}new?agent=writer`);
|
||||
await expect
|
||||
.poll(() => restoredPage.locator(".new-session-page__message").inputValue())
|
||||
.toBe("");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,18 +99,23 @@ suite.define(() => {
|
||||
);
|
||||
expect(await trigger.getAttribute("data-project-id")).toBeNull();
|
||||
|
||||
const permission = page.locator('[data-chat-permission-select="true"]');
|
||||
await permission.click();
|
||||
await page.locator('[data-chat-permission-option="read-only"]').click();
|
||||
await page.locator(".new-session-page__message").fill("inspect the cloned project");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
const addRequest = await gateway.waitForRequest("projects.add");
|
||||
expect(addRequest.params).toEqual({ gitUrl: "https://github.com/openclaw/openclaw.git" });
|
||||
await captureProjectUiProof(page, "project-cloning.png");
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
|
||||
expect(await permission.isDisabled()).toBe(true);
|
||||
await gateway.resolveDeferred("projects.add", clonedProject);
|
||||
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({
|
||||
agentId: "main",
|
||||
message: "inspect the cloned project",
|
||||
permissionMode: "read-only",
|
||||
projectId: "openclaw",
|
||||
});
|
||||
expect(create.params).not.toHaveProperty("cwd");
|
||||
|
||||
@@ -106,6 +106,66 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a Full-access session when the connected operator has admin scope", async () => {
|
||||
const { context, gateway, page } = await openDraft([
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]);
|
||||
try {
|
||||
const permission = page.locator('[data-chat-permission-select="true"]');
|
||||
await permission.click();
|
||||
await page.locator('[data-chat-permission-option="full"]').click();
|
||||
await expect.poll(() => permission.getAttribute("data-chat-select-value")).toBe("full");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
|
||||
await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({
|
||||
params: { agentId: "main", message: "scope proof", permissionMode: "full" },
|
||||
});
|
||||
await expect.poll(() => page.url()).toContain("/chat/");
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a retained Full-access selection after reconnecting without admin scope", async () => {
|
||||
const { context, gateway, page } = await openDraft([
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]);
|
||||
try {
|
||||
const permission = page.locator('[data-chat-permission-select="true"]');
|
||||
await permission.click();
|
||||
await page.locator('[data-chat-permission-option="full"]').click();
|
||||
await expect.poll(() => permission.getAttribute("data-chat-select-value")).toBe("full");
|
||||
|
||||
await gateway.setOperatorScopes(["operator.read", "operator.write"]);
|
||||
await gateway.closeLatest(1001, "permission scope downgraded");
|
||||
await expect.poll(async () => (await gateway.getRequests("connect")).length).toBe(2);
|
||||
|
||||
const submit = page.getByRole("button", { name: "Start session" });
|
||||
await expect.poll(() => submit.isDisabled()).toBe(true);
|
||||
expect(await permission.getAttribute("data-chat-select-value")).toBe("full");
|
||||
await permission.click();
|
||||
const fullAccess = page.locator('[data-chat-permission-option="full"]');
|
||||
await expect.poll(() => fullAccess.getAttribute("disabled")).not.toBeNull();
|
||||
expect(await fullAccess.getAttribute("title")).toBe(
|
||||
"Full access requires operator.admin access.",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".new-session-page__message").press("Enter");
|
||||
|
||||
await pollLocatorText(
|
||||
page.locator('.new-session-page__blocked-submit[role="status"]'),
|
||||
).toContain("This action requires operator.admin access.");
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows paired devices, cloud profiles, and Connect to admins", async () => {
|
||||
const { context, page } = await openDraft([
|
||||
"operator.read",
|
||||
|
||||
@@ -222,6 +222,9 @@ suite.define(() => {
|
||||
const footerBox = await page
|
||||
.locator(".new-session-page__composer .agent-chat__composer-footer")
|
||||
.boundingBox();
|
||||
const actionsBox = await page
|
||||
.locator(".new-session-page__composer .agent-chat__composer-actions")
|
||||
.boundingBox();
|
||||
const attachmentButton = page.getByRole("button", { name: "Add attachment" });
|
||||
const attachmentBox = await attachmentButton.boundingBox();
|
||||
expect(heroBox).not.toBeNull();
|
||||
@@ -230,6 +233,7 @@ suite.define(() => {
|
||||
expect(modelBox).not.toBeNull();
|
||||
expect(modelWrapperBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
expect(actionsBox).not.toBeNull();
|
||||
expect(attachmentBox).not.toBeNull();
|
||||
expect((heroBox?.y ?? 0) + (heroBox?.height ?? 0)).toBeLessThanOrEqual(
|
||||
(triggersBox?.y ?? 0) + 1,
|
||||
@@ -260,10 +264,14 @@ suite.define(() => {
|
||||
(footerBox?.x ?? 0) + (footerBox?.width ?? 0) / 2,
|
||||
);
|
||||
expect(
|
||||
(footerBox?.x ?? 0) +
|
||||
(footerBox?.width ?? 0) -
|
||||
((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)),
|
||||
(actionsBox?.x ?? 0) - ((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(12);
|
||||
expect((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)).toBeLessThanOrEqual(
|
||||
actionsBox?.x ?? 0,
|
||||
);
|
||||
expect((actionsBox?.x ?? 0) + (actionsBox?.width ?? 0)).toBeLessThanOrEqual(
|
||||
(footerBox?.x ?? 0) + (footerBox?.width ?? 0) + 1,
|
||||
);
|
||||
expect(triggersBox?.x).toBeCloseTo(composerBox?.x ?? 0, 0);
|
||||
expect(triggersBox?.width).toBeCloseTo(composerBox?.width ?? 0, 0);
|
||||
expect(composerBox?.width).toBeCloseTo(48 * 16, 0);
|
||||
|
||||
@@ -55,7 +55,7 @@ suite.define(() => {
|
||||
const firstMessage = firstPage.locator(".new-session-page__message");
|
||||
await firstMessage.fill("restore this prompt after restart");
|
||||
await pastePng(firstMessage);
|
||||
await firstPage.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await firstPage.locator('.chat-attachment-thumb img[alt="pixel.png"]').waitFor();
|
||||
const incognito = firstPage.getByRole("switch", { name: "Incognito" });
|
||||
await incognito.click();
|
||||
await expect.poll(() => incognito.getAttribute("aria-checked")).toBe("true");
|
||||
@@ -236,7 +236,7 @@ suite.define(() => {
|
||||
await message.waitFor();
|
||||
await pastePng(message);
|
||||
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
@@ -268,7 +268,7 @@ suite.define(() => {
|
||||
.setInputFiles(path.join(process.cwd(), "ui/public/favicon-32.png"));
|
||||
|
||||
const attachment = page.locator(".chat-attachment-thumb");
|
||||
const preview = attachment.locator('img[alt="Attachment preview"]');
|
||||
const preview = attachment.getByRole("img", { name: "favicon-32.png" });
|
||||
const previewButton = page.getByRole("button", { name: "Open image favicon-32.png" });
|
||||
await preview.waitFor({ state: "visible" });
|
||||
await expect.poll(() => preview.getAttribute("src")).toMatch(/^data:image\/png;base64,/u);
|
||||
@@ -567,7 +567,7 @@ suite.define(() => {
|
||||
finish();
|
||||
});
|
||||
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await expect.poll(() => submit.isEnabled()).toBe(true);
|
||||
await submit.click();
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
@@ -636,6 +636,114 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("releases pasted image previews after remove, reset, restored removal, and success", async () => {
|
||||
await withNewSessionPage(async (page) => {
|
||||
await page.addInitScript(() => {
|
||||
const createObjectURL = URL.createObjectURL.bind(URL);
|
||||
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
|
||||
const proof = { created: 0, revoked: 0 };
|
||||
(globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof;
|
||||
URL.createObjectURL = (blob: Blob) => {
|
||||
proof.created += 1;
|
||||
return createObjectURL(blob);
|
||||
};
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
proof.revoked += 1;
|
||||
revokeObjectURL(url);
|
||||
};
|
||||
});
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"agents.list": {
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "agent",
|
||||
agents: [
|
||||
{ id: "main", name: "Main" },
|
||||
{ id: "writer", name: "Writer" },
|
||||
],
|
||||
},
|
||||
"sessions.create": { key: "agent:main:preview-cleanup", runStarted: true },
|
||||
},
|
||||
});
|
||||
const proof = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } })
|
||||
.attachmentUrlProof,
|
||||
);
|
||||
const navigate = (routeId: string, search = "") =>
|
||||
page.evaluate(
|
||||
({ targetRouteId, targetSearch }) => {
|
||||
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||
runtime?: {
|
||||
context: {
|
||||
navigate: (routeId: string, options?: { search?: string }) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (!app.runtime) {
|
||||
throw new Error("OpenClaw application runtime is unavailable");
|
||||
}
|
||||
app.runtime.context.navigate(targetRouteId, { search: targetSearch });
|
||||
},
|
||||
{ targetRouteId: routeId, targetSearch: search },
|
||||
);
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
|
||||
await pastePng(composer);
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await page.getByRole("button", { name: "Remove attachment" }).click();
|
||||
await expect.poll(async () => (await proof()).revoked).toBe(1);
|
||||
|
||||
await pastePng(composer);
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown");
|
||||
await page.locator(".new-session-page__select--agent .agent-select__trigger").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
agentDropdown.evaluate((dropdown) => (dropdown as HTMLElement & { open: boolean }).open),
|
||||
)
|
||||
.toBe(true);
|
||||
await navigate("new-session", "?agent=main&catalog=missing");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
document.querySelector(".new-session-page__select--agent wa-dropdown") as
|
||||
| (HTMLElement & { open: boolean })
|
||||
| null
|
||||
)?.open ?? false,
|
||||
),
|
||||
)
|
||||
.toBe(false);
|
||||
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0);
|
||||
await expect.poll(async () => (await proof()).revoked).toBe(2);
|
||||
|
||||
await navigate("new-session");
|
||||
await composer.waitFor();
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await navigate("chat");
|
||||
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
|
||||
await expect.poll(async () => (await proof()).revoked).toBe(2);
|
||||
|
||||
await navigate("new-session");
|
||||
await composer.waitFor();
|
||||
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1);
|
||||
await page.getByRole("button", { name: "Remove attachment" }).click();
|
||||
await expect.poll(async () => (await proof()).revoked).toBe(3);
|
||||
await pastePng(composer);
|
||||
await page.getByRole("img", { name: "pixel.png" }).waitFor();
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"),
|
||||
);
|
||||
await expect.poll(async () => await proof()).toEqual({ created: 4, revoked: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
it("locks the submitted draft until creation settles and restores it after failure", async () => {
|
||||
await withNewSessionPage(async (page) => {
|
||||
const sessionKey = "agent:main:locked-new-session-draft";
|
||||
|
||||
@@ -316,13 +316,6 @@ export async function openNewSessionPlusMenu(page: Page) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
export async function selectNewSessionDraft(page: Page) {
|
||||
const menu = await openNewSessionPlusMenu(page);
|
||||
await menu.getByRole("menuitem", { name: "Draft" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Draft", exact: true }).waitFor();
|
||||
}
|
||||
|
||||
export async function navigateInApp(page: Page, routeId: string, search = "") {
|
||||
await page.evaluate(
|
||||
({ targetRouteId, targetSearch }) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ const DESKTOP_CONTEXT: BrowserContextOptions = {
|
||||
};
|
||||
const MOBILE_CONTEXT: BrowserContextOptions = {
|
||||
...BASE_CONTEXT,
|
||||
hasTouch: true,
|
||||
viewport: { height: 740, width: 364 },
|
||||
};
|
||||
const MODELS = [
|
||||
@@ -101,7 +102,7 @@ async function withNewSessionPage(
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("keeps rail privacy visible and exposes Draft from Plus on mobile", async () => {
|
||||
it("keeps rail privacy visible and shows the mobile footer mode without hover", async () => {
|
||||
await withNewSessionPage(MOBILE_CONTEXT, async (page) => {
|
||||
await installMockGateway(page, {
|
||||
models: [
|
||||
@@ -118,37 +119,72 @@ suite.define(() => {
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
],
|
||||
allowedSessionVisibilities: ["shared", "draft"],
|
||||
hasMultipleSessionSharingIdentities: true,
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
const footer = page.locator(".new-session-page__composer .agent-chat__composer-footer");
|
||||
const attach = page.getByRole("button", { name: "Add attachment" });
|
||||
const takePhoto = page.getByRole("menuitem", { name: "Take photo" });
|
||||
const draft = page.locator('.new-session-page__visibility--draft[aria-label="Draft"]');
|
||||
const incognito = page.getByRole("switch", { name: "Incognito" });
|
||||
const model = page.locator(".new-session-page__composer .chat-composer-model-control");
|
||||
await Promise.all([footer.waitFor(), attach.waitFor(), incognito.waitFor(), model.waitFor()]);
|
||||
await Promise.all([
|
||||
footer.waitFor(),
|
||||
attach.waitFor(),
|
||||
draft.waitFor({ state: "attached" }),
|
||||
incognito.waitFor(),
|
||||
model.waitFor(),
|
||||
]);
|
||||
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await page.mouse.move(0, 0);
|
||||
await expect
|
||||
.poll(() => incognito.evaluate((element) => getComputedStyle(element).opacity))
|
||||
.toBe("1");
|
||||
await expect
|
||||
.poll(() => draft.evaluate((element) => getComputedStyle(element).opacity))
|
||||
.toBe("1");
|
||||
expect(
|
||||
await incognito.evaluate(
|
||||
(element) => element.closest(".new-session-page__incognito-rail") != null,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const [footerBox, attachBox, modelBox] = await Promise.all([
|
||||
const [footerBox, attachBox, draftBox, modelBox] = await Promise.all([
|
||||
footer.boundingBox(),
|
||||
attach.boundingBox(),
|
||||
draft.boundingBox(),
|
||||
model.boundingBox(),
|
||||
]);
|
||||
expect(footerBox).not.toBeNull();
|
||||
expect(attachBox).not.toBeNull();
|
||||
expect(draftBox).not.toBeNull();
|
||||
expect(modelBox).not.toBeNull();
|
||||
expect((attachBox?.x ?? 0) + (attachBox?.width ?? 0)).toBeLessThanOrEqual(modelBox?.x ?? 0);
|
||||
for (const control of [attachBox, modelBox]) {
|
||||
// The row reads as the settings for the next turn, in the order the
|
||||
// operator decides them: attachments, draft visibility, then the model and
|
||||
// its reasoning. This viewport is narrow enough that
|
||||
// the row wraps, so the comparison is reading order — which line a control
|
||||
// is on first, then where it sits on that line.
|
||||
const followsInReadingOrder = (
|
||||
previous: { x: number; y: number; height: number } | null,
|
||||
next: { x: number; y: number; height: number } | null,
|
||||
) => {
|
||||
if (!previous || !next) {
|
||||
return false;
|
||||
}
|
||||
const previousCenter = previous.y + previous.height / 2;
|
||||
const nextCenter = next.y + next.height / 2;
|
||||
const sameLine = Math.abs(nextCenter - previousCenter) <= previous.height / 2;
|
||||
return sameLine ? next.x > previous.x : nextCenter > previousCenter;
|
||||
};
|
||||
const sequence = [attachBox, draftBox, modelBox];
|
||||
for (let index = 1; index < sequence.length; index += 1) {
|
||||
expect(followsInReadingOrder(sequence[index - 1] ?? null, sequence[index] ?? null)).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (const control of [attachBox, draftBox, modelBox]) {
|
||||
expect(control?.x ?? 0).toBeGreaterThanOrEqual(footerBox?.x ?? 0);
|
||||
expect((control?.x ?? 0) + (control?.width ?? 0)).toBeLessThanOrEqual(
|
||||
(footerBox?.x ?? 0) + (footerBox?.width ?? 0),
|
||||
@@ -161,14 +197,23 @@ suite.define(() => {
|
||||
|
||||
await attach.click();
|
||||
await expect.poll(() => takePhoto.isVisible()).toBe(true);
|
||||
await expect.poll(() => page.getByRole("menuitem", { name: "Draft" }).isVisible()).toBe(true);
|
||||
// The plus becomes a close mark while its menu is up: one glyph rotating,
|
||||
// so the button that opened the menu visibly is the one that dismisses it.
|
||||
// A CSS rotation matrix is [cos, sin, -sin, cos], so the sine term carries
|
||||
// the direction: negative is counter-clockwise, turning back against the
|
||||
// upward travel of the menu rather than with it.
|
||||
const attachGlyphSine = () =>
|
||||
attach.evaluate((element) => {
|
||||
const { transform } = getComputedStyle(element.querySelector("svg") as SVGElement);
|
||||
return transform === "none"
|
||||
? 0
|
||||
: Number(transform.slice(transform.indexOf("(") + 1).split(",")[1]);
|
||||
});
|
||||
await expect.poll(attachGlyphSine).toBeCloseTo(-Math.SQRT1_2, 3);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect.poll(attachGlyphSine).toBe(0);
|
||||
await incognito.click();
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await page.mouse.move(0, 0);
|
||||
await expect
|
||||
.poll(() => incognito.evaluate((element) => getComputedStyle(element).opacity))
|
||||
.toBe("1");
|
||||
await expect.poll(() => incognito.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,7 +255,7 @@ suite.define(() => {
|
||||
await expect.poll(pickerOpen).toBe(false);
|
||||
await expect
|
||||
.poll(() => modelSelect.evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
.toBe(false);
|
||||
await modelSelect.click();
|
||||
await expect.poll(pickerOpen).toBe(true);
|
||||
await page.mouse.click(8, 8);
|
||||
|
||||
@@ -415,7 +415,7 @@ suite.define(() => {
|
||||
await page.getByRole("heading", { name: "Original agent" }).waitFor();
|
||||
const message = page.locator(".new-session-page__message");
|
||||
const projectTrigger = page.locator("#new-session-project-trigger");
|
||||
const start = page.locator("button.chat-send-btn");
|
||||
const start = page.locator("button.new-session-page__start-submit");
|
||||
await message.fill("retry this draft after reconnect");
|
||||
await gateway.deferNext("sessions.create");
|
||||
await start.click();
|
||||
|
||||
@@ -6,11 +6,7 @@ import { expect as expectBrowser } from "playwright/test";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
import {
|
||||
openNewSessionPlusMenu,
|
||||
replaceGatewayClient,
|
||||
selectNewSessionDraft,
|
||||
} from "./new-session-page.test-support.ts";
|
||||
import { openNewSessionPlusMenu, replaceGatewayClient } from "./new-session-page.test-support.ts";
|
||||
import {
|
||||
avatarLabelCenterDelta,
|
||||
routeAvatarFixtures,
|
||||
@@ -602,12 +598,13 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
await currentPage.goto(`${suite.server?.baseUrl ?? ""}new`);
|
||||
const menu = await openNewSessionPlusMenu(currentPage);
|
||||
await menu.getByRole("menuitem", { name: "Draft" }).waitFor();
|
||||
// Playwright check()/isChecked() support role="switch" buttons via aria-checked.
|
||||
const draftToggle = currentPage.getByRole("switch", { name: "Draft", exact: true });
|
||||
await currentPage.locator(".new-session-page__composer .agent-chat__composer-footer").hover();
|
||||
await draftToggle.waitFor();
|
||||
await captureUiProof(currentPage, "02-create-draft-available.png");
|
||||
await menu.getByRole("menuitem", { name: "Draft" }).click();
|
||||
await currentPage.keyboard.press("Escape");
|
||||
await currentPage.getByRole("button", { name: "Draft", exact: true }).waitFor();
|
||||
await draftToggle.check();
|
||||
await expectBrowser(draftToggle).toBeChecked();
|
||||
await currentPage.locator(".new-session-page__message").fill("work privately first");
|
||||
await captureUiProof(currentPage, "03-create-draft-selected.png");
|
||||
await currentPage.getByRole("button", { name: "Start session" }).click();
|
||||
@@ -1019,7 +1016,9 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
await currentPage.goto(`${suite.server?.baseUrl ?? ""}new`);
|
||||
await selectNewSessionDraft(currentPage);
|
||||
const draftToggle = currentPage.getByRole("switch", { name: "Draft", exact: true });
|
||||
await currentPage.locator(".new-session-page__composer .agent-chat__composer-footer").hover();
|
||||
await draftToggle.check();
|
||||
await gateway.setSessionSharingPolicy({
|
||||
allowedSessionVisibilities: ["shared"],
|
||||
hasMultipleSessionSharingIdentities: false,
|
||||
@@ -1034,9 +1033,9 @@ suite.define(() => {
|
||||
hasMultipleSessionSharingIdentities: true,
|
||||
});
|
||||
await replaceGatewayClient(currentPage);
|
||||
const menu = await openNewSessionPlusMenu(currentPage);
|
||||
await menu.getByRole("menuitem", { name: "Draft" }).waitFor();
|
||||
expect(await currentPage.getByRole("button", { name: "Draft", exact: true }).count()).toBe(0);
|
||||
await currentPage.locator(".new-session-page__composer .agent-chat__composer-footer").hover();
|
||||
await draftToggle.waitFor();
|
||||
expect(await draftToggle.isChecked()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps create-as-draft dormant for one owner", async () => {
|
||||
|
||||
@@ -231,6 +231,8 @@ export const en: TranslationMap = {
|
||||
},
|
||||
sessionProgressCard: {
|
||||
title: "Progress",
|
||||
composerTitle: "Task progress",
|
||||
shortCount: "{completed} of {total}",
|
||||
noteLabel: "Progress note",
|
||||
dismiss: "Dismiss progress card",
|
||||
dismissFailed: "Could not dismiss the progress card. Try again.",
|
||||
@@ -951,6 +953,8 @@ export const en: TranslationMap = {
|
||||
draftDescription: "Keep this session to yourself until you publish it",
|
||||
composerOptionsSelected: "Add attachments or session options ({count} selected)",
|
||||
messagePlaceholder: "What should this session work on?",
|
||||
dictate: "Hold to dictate",
|
||||
dictationHoldToSpeak: "Hold the microphone to dictate, then release to insert what you said.",
|
||||
readingAttachment: "Reading attachment",
|
||||
start: "Start session",
|
||||
startInTerminal: "Start in terminal",
|
||||
@@ -3425,6 +3429,7 @@ export const en: TranslationMap = {
|
||||
title: "Swarm",
|
||||
description: "Let Code Mode orchestrate groups of subagents in parallel.",
|
||||
defaultPhase: "Unphased",
|
||||
progress: "{complete} of {total}",
|
||||
},
|
||||
toolSearch: {
|
||||
title: "Tool Search",
|
||||
@@ -5494,6 +5499,8 @@ export const en: TranslationMap = {
|
||||
titleMany: "{count} cloud workspace conflicts",
|
||||
description:
|
||||
"OpenClaw kept your local versions and applied the other cloud changes. Inspect the staged result or take its version for a conflicted path.",
|
||||
summary: "Local versions kept; inspect or take the cloud version.",
|
||||
showCommands: "Show commands",
|
||||
morePaths: "+{count} more paths",
|
||||
stagedResult: "Staged cloud result",
|
||||
copyStagedResult: "Copy staged result ref",
|
||||
@@ -5767,6 +5774,8 @@ export const en: TranslationMap = {
|
||||
searchModels: "Search models",
|
||||
noMatchingModels: "No models match your search",
|
||||
sessionOverride: "Session override",
|
||||
resetToDefault: "Reset to default ({model})",
|
||||
useDefault: "Use default",
|
||||
defaultWithModel: "Default ({model})",
|
||||
defaultWithLevel: "Default ({level})",
|
||||
fastHelp: "Fast responses finish sooner and can use more of your usage limits.",
|
||||
@@ -6056,6 +6065,7 @@ export const en: TranslationMap = {
|
||||
microphonePermissionBlocked:
|
||||
"Microphone access is blocked. Allow it in browser site settings to list inputs.",
|
||||
holdToRecordSetting: "Hold microphone button to dictate",
|
||||
holdToDictate: "Hold to dictate",
|
||||
holdToRecordSettingDescription:
|
||||
"Hold the composer microphone button, speak, then release to insert text without sending.",
|
||||
dictationAudioUnsupported: "The Gateway returned an unsupported dictation audio format.",
|
||||
@@ -6066,9 +6076,11 @@ export const en: TranslationMap = {
|
||||
dictationFinalizing: "Finishing dictation…",
|
||||
dictationFinalizationTimedOut:
|
||||
"Dictation stopped before the last partial transcript could be finalized.",
|
||||
dictationProviderUnavailable: "No transcription provider is configured for dictation.",
|
||||
dictationProviderUnavailable:
|
||||
"No transcription provider is configured for dictation. Choose one in Settings to dictate.",
|
||||
dictationRecording: "Recording {elapsed}",
|
||||
dictationReleaseToInsert: "Release to insert dictation",
|
||||
dictationStop: "Stop dictation",
|
||||
realtimeTalkCancellationRejected: "Realtime output cancellation was not accepted.",
|
||||
realtimeTalkMissingTurnIdentity: "Realtime output is missing its turn identity.",
|
||||
realtimeTalkRequiresMicrophone: "Realtime voice input requires browser microphone access.",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { SessionToolOverridesSchema } from "@openclaw/gateway-protocol";
|
||||
import {
|
||||
SessionPermissionModeSchema,
|
||||
SessionToolOverridesSchema,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { Value } from "typebox/value";
|
||||
@@ -54,6 +57,7 @@ const PLACEMENT_CREATE_FIELDS = new Set<string>([
|
||||
"worktree",
|
||||
"incognito",
|
||||
"visibility",
|
||||
"permissionMode",
|
||||
"toolOverrides",
|
||||
...PLACEMENT_CREATE_STRING_FIELDS,
|
||||
]);
|
||||
@@ -75,6 +79,8 @@ export function parseSessionPlacementCreateParams(
|
||||
record.worktree !== true ||
|
||||
(record.incognito !== undefined && record.incognito !== true) ||
|
||||
(record.visibility !== undefined && record.visibility !== "draft") ||
|
||||
(record.permissionMode !== undefined &&
|
||||
!Value.Check(SessionPermissionModeSchema, record.permissionMode)) ||
|
||||
(record.toolOverrides !== undefined &&
|
||||
!Value.Check(SessionToolOverridesSchema, record.toolOverrides)) ||
|
||||
(record.projectId !== undefined && record.cwd !== undefined) ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import {
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
renderComposerFixture as renderComposer,
|
||||
resetComposerFixture,
|
||||
} from "./chat-composer.test-support.ts";
|
||||
import { renderChatPrimaryActions } from "./components/chat-composer-controls.ts";
|
||||
import type { ComposerDictationController } from "./composer-dictation.ts";
|
||||
|
||||
afterEach(async () => {
|
||||
await resetComposerFixture();
|
||||
@@ -32,6 +35,46 @@ function pressComposerEnter(
|
||||
}
|
||||
|
||||
describe("renderChatComposer controls", () => {
|
||||
it.each([
|
||||
{ committed: false, sends: 0 },
|
||||
{ committed: true, sends: 1 },
|
||||
])(
|
||||
"sends after dictation only when the current transcript committed: $committed",
|
||||
async ({ committed, sends }) => {
|
||||
const container = document.createElement("div");
|
||||
const finishActive = vi.fn().mockResolvedValue(committed);
|
||||
const onSend = vi.fn();
|
||||
const dictation = {
|
||||
active: true,
|
||||
connecting: false,
|
||||
finalizing: false,
|
||||
locksComposer: true,
|
||||
elapsed: "0:01",
|
||||
finishActive,
|
||||
} as unknown as ComposerDictationController;
|
||||
render(
|
||||
renderChatPrimaryActions({
|
||||
canAbort: false,
|
||||
canSend: true,
|
||||
connected: true,
|
||||
draft: "preexisting draft",
|
||||
isBusy: false,
|
||||
steerNowEnabled: false,
|
||||
sending: false,
|
||||
dictation,
|
||||
onSend,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".chat-send-btn--dictation-send")?.click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(finishActive).toHaveBeenCalledOnce();
|
||||
expect(onSend).toHaveBeenCalledTimes(sends);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "empty idle",
|
||||
|
||||
@@ -151,19 +151,19 @@ describe("renderChatComposer controls", () => {
|
||||
});
|
||||
|
||||
expect(container.querySelector(".agent-chat__input--offline")).not.toBeNull();
|
||||
expect(container.querySelector(".agent-chat__offline-hint")?.textContent?.trim()).toBe(
|
||||
expect(container.querySelector(".agent-chat__composer-status-band")?.textContent?.trim()).toBe(
|
||||
"Offline — 3 queued; messages send when the connection returns.",
|
||||
);
|
||||
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.disabled).toBe(false);
|
||||
expect(button(container, t("chat.runControls.sendMessage")).disabled).toBe(false);
|
||||
|
||||
const empty = renderComposer({ offline: true, queuedOutboxCount: 0 });
|
||||
expect(empty.container.querySelector(".agent-chat__offline-hint")?.textContent?.trim()).toBe(
|
||||
"Offline — messages will be queued and sent when the connection returns.",
|
||||
);
|
||||
expect(
|
||||
empty.container.querySelector(".agent-chat__composer-status-band")?.textContent?.trim(),
|
||||
).toBe("Offline — messages will be queued and sent when the connection returns.");
|
||||
|
||||
const online = renderComposer({ queuedOutboxCount: 3 });
|
||||
expect(online.container.querySelector(".agent-chat__offline-hint")).toBeNull();
|
||||
expect(online.container.querySelector(".agent-chat__composer-status-band")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces the composer with the archived-session notice", () => {
|
||||
@@ -188,7 +188,11 @@ describe("renderChatComposer controls", () => {
|
||||
expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull();
|
||||
banner?.querySelector<HTMLButtonElement>("button")?.click();
|
||||
expect(onAction).toHaveBeenCalledOnce();
|
||||
button(container, t("chat.runControls.stopGenerating")).click();
|
||||
const stop = container.querySelector<HTMLButtonElement>(
|
||||
`[aria-label="${t("chat.runControls.stopGenerating")}"]`,
|
||||
);
|
||||
expect(stop).not.toBeNull();
|
||||
stop?.click();
|
||||
expect(onAbort).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -211,13 +215,12 @@ describe("renderChatComposer controls", () => {
|
||||
draft: "a draft that hides the placeholder",
|
||||
});
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
const reasonRow = container.querySelector<HTMLElement>(".agent-chat__disabled-reason");
|
||||
expect(reasonRow?.textContent).toContain(reason);
|
||||
expect(container.textContent?.split(reason)).toHaveLength(2);
|
||||
expect(textarea?.placeholder).toBe(t("chat.composer.placeholder", { name: "OpenClaw" }));
|
||||
expect(textarea?.disabled).toBe(true);
|
||||
expect(textarea?.getAttribute("aria-describedby")?.split(" ")).toContain(reasonRow?.id);
|
||||
// The placeholder carries the reason only for an empty composer; the
|
||||
// dedicated reason row must keep the explanation visible alongside a draft.
|
||||
expect(container.querySelector(".agent-chat__composer-status-band")?.textContent).toContain(
|
||||
reason,
|
||||
);
|
||||
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("opens the microphone picker, marks the selected input, and persists a selection", async () => {
|
||||
@@ -266,9 +269,11 @@ describe("renderChatComposer controls", () => {
|
||||
expect(items.find((item) => item.value === "studio-mic")?.getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(items.find((item) => item.value === "studio-mic")?.querySelector("svg")?.innerHTML).toBe(
|
||||
iconMarkup(icons.check),
|
||||
);
|
||||
expect(
|
||||
items
|
||||
.find((item) => item.value === "studio-mic")
|
||||
?.querySelector(".chat-talk-input-picker__check"),
|
||||
).not.toBeNull();
|
||||
|
||||
items.find((item) => item.value === "headset")?.click();
|
||||
await dropdown?.updateComplete;
|
||||
@@ -391,7 +396,6 @@ describe("renderChatComposer controls", () => {
|
||||
// type="checkbox" would make wa-dropdown-item paint its own leading check
|
||||
// and toggle it on click, so the row would show two disagreeing marks.
|
||||
expect(items.map((item) => item.getAttribute("type"))).toEqual(["normal", "normal"]);
|
||||
expect(items.map((item) => item.querySelectorAll("svg").length)).toEqual([1, 0]);
|
||||
expect(items[0]?.querySelector(".chat-talk-input-picker__check")?.getAttribute("slot")).toBe(
|
||||
"details",
|
||||
);
|
||||
@@ -649,6 +653,7 @@ describe("renderChatComposer status", () => {
|
||||
draw();
|
||||
let panel = container.querySelector("openclaw-chat-question-panel") as HTMLElement & {
|
||||
updateComplete: Promise<unknown>;
|
||||
props: { onCollapsedChange: (collapsed: boolean) => void };
|
||||
};
|
||||
await panel.updateComplete;
|
||||
expect(container.querySelector(".agent-chat__input")).toBeNull();
|
||||
@@ -659,7 +664,7 @@ describe("renderChatComposer status", () => {
|
||||
|
||||
composerProps.draft = "Host updated this draft while the question was open";
|
||||
|
||||
panel.querySelector<HTMLButtonElement>(".chat-question-panel__collapse")?.click();
|
||||
panel.props.onCollapsedChange(true);
|
||||
draw();
|
||||
await Promise.resolve();
|
||||
let textarea = container.querySelector<HTMLTextAreaElement>("textarea")!;
|
||||
@@ -667,7 +672,7 @@ describe("renderChatComposer status", () => {
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
|
||||
panel = container.querySelector("openclaw-chat-question-panel") as typeof panel;
|
||||
panel.querySelector<HTMLButtonElement>(".chat-question-panel__collapsed-button")?.click();
|
||||
panel.props.onCollapsedChange(false);
|
||||
draw();
|
||||
await panel.updateComplete;
|
||||
expect(container.querySelector(".agent-chat__input")).toBeNull();
|
||||
@@ -727,7 +732,7 @@ describe("renderChatComposer status", () => {
|
||||
|
||||
expect(view.container.querySelector("openclaw-chat-question-panel")).toBeNull();
|
||||
});
|
||||
it("renders only a fresh interrupted run as visible status chrome", () => {
|
||||
it("keeps terminal status out of the composer chrome", () => {
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
let view = renderComposer({
|
||||
runStatus: { phase: "done", runId: "run-0", sessionKey: "main", occurredAt: 900 },
|
||||
@@ -738,8 +743,9 @@ describe("renderChatComposer status", () => {
|
||||
runStatus: { phase: "interrupted", runId: "run-1", sessionKey: "main", occurredAt: 900 },
|
||||
composerControls: html`<button type="button">Settings</button>`,
|
||||
});
|
||||
expect(view.container.querySelector(".agent-chat__run-status--interrupted")).toBeNull();
|
||||
expect(
|
||||
view.container.querySelector(".agent-chat__run-status--interrupted")?.textContent,
|
||||
view.container.querySelector(".agent-chat__run-status-announcement")?.textContent,
|
||||
).toContain("Interrupted");
|
||||
|
||||
now.mockReturnValue(7_000);
|
||||
|
||||
@@ -70,18 +70,16 @@ export class ChatPane extends ChatPaneLayoutRender {
|
||||
}
|
||||
void this.ensureTaskSuggestionCloudProfiles();
|
||||
const selectedSession = selectedChatSessionRow(state);
|
||||
const selectedSessionId = selectedSession?.sessionId?.trim() || undefined;
|
||||
const mutationAccess = readChatPaneMutationAccess(
|
||||
this.context.gateway.snapshot,
|
||||
state.sessionKey,
|
||||
);
|
||||
const projectedObserverDigest = projectSessionObserverDigest(
|
||||
selectedSession?.key ?? state.sessionKey,
|
||||
selectedSession?.observerDigest,
|
||||
);
|
||||
const observerDigest = pickFreshestObserverDigest(
|
||||
state.observerDigest,
|
||||
projectedObserverDigest,
|
||||
projectSessionObserverDigest(
|
||||
selectedSession?.key ?? state.sessionKey,
|
||||
selectedSession?.observerDigest,
|
||||
),
|
||||
);
|
||||
const observerRunId = resolveChatPaneObserverRunId({
|
||||
localRunId: state.chatRunId,
|
||||
@@ -387,12 +385,16 @@ export class ChatPane extends ChatPaneLayoutRender {
|
||||
(!sessionParticipationBlocked || suggestionViewer) &&
|
||||
!placementStartupPending,
|
||||
disabledReason: catalogDisabledReason ?? disabledReason,
|
||||
disabledReasonTone:
|
||||
sessionParticipationBlocked && !suggestionViewer && !catalogDisabledReason
|
||||
? "info"
|
||||
: "danger",
|
||||
disabledBanner: this.sessionDisabledBanner({
|
||||
catalogDisabledReason,
|
||||
modelSetupRequired,
|
||||
restartRecoveryTombstoned,
|
||||
selectedSessionArchived,
|
||||
selectedSessionId,
|
||||
selectedSessionId: selectedSession?.sessionId?.trim() || undefined,
|
||||
sessionKey: state.sessionKey,
|
||||
unarchiveAccess: mutationAccess.unarchive,
|
||||
}),
|
||||
|
||||
@@ -48,6 +48,7 @@ export abstract class ChatPaneSessionCreation extends ChatPaneRetainedPresentati
|
||||
return {
|
||||
kind: "composer-replacement" as const,
|
||||
text: t("chat.archivedSessionDisabled"),
|
||||
icon: "archive" as const,
|
||||
actionLabel: t("common.unarchive"),
|
||||
disabledReason: !params.selectedSessionId
|
||||
? "Session lifecycle action requires a durable session identity."
|
||||
|
||||
@@ -98,8 +98,7 @@ describe("chat pane typing presence", () => {
|
||||
"Alice is typing…",
|
||||
);
|
||||
expect(container.querySelectorAll(".agent-chat__typing-bubble > span")).toHaveLength(3);
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toBe("Alice, Bob are typing…");
|
||||
expect(container.querySelector('[role="status"]')?.textContent).not.toContain("Hello");
|
||||
expect(container.querySelector(".sr-only")?.textContent).toBe("Alice, Bob are typing…");
|
||||
});
|
||||
|
||||
it("sends only the last 300 draft code points and omits previews when typing stops", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Control UI tests cover chat responsive behavior.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js";
|
||||
@@ -549,11 +551,19 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
<div class="chat-session-rail__hint">The companion is already answering a question.</div>
|
||||
</article>
|
||||
</div>
|
||||
<footer class="chat-session-rail__composer">
|
||||
<label class="chat-session-rail__prompt">
|
||||
<input class="chat-session-rail__input" type="text" placeholder="What should I know?" />
|
||||
</label>
|
||||
<button class="chat-send-btn">${iconSvg()}</button>
|
||||
<footer class="agent-chat__input chat-session-rail__composer" data-composer-layout="multiline">
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<label class="agent-chat__composer-combobox chat-session-rail__prompt">
|
||||
<input class="chat-session-rail__input" type="text" placeholder="What should I know?" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
<div class="agent-chat__composer-trail">
|
||||
<div class="agent-chat__composer-actions">
|
||||
<button class="chat-send-btn">${iconSvg()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</openclaw-chat-session-rail>`
|
||||
@@ -572,7 +582,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
: ""
|
||||
}
|
||||
<div class="agent-chat__composer-shell">
|
||||
<div class="agent-chat__input">
|
||||
<div class="agent-chat__input" data-composer-layout="multiline">
|
||||
${
|
||||
opts.slashMenu
|
||||
? `<div class="slash-menu" role="listbox" aria-label="Command suggestions">
|
||||
@@ -597,6 +607,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="agent-chat__composer-lede">
|
||||
${
|
||||
opts.composerAttachment
|
||||
? `<div class="chat-attachments-preview">
|
||||
@@ -611,33 +622,25 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
: ""
|
||||
}
|
||||
<div class="agent-chat__composer-status-stack"> </div>
|
||||
</div>
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<details class="agent-chat__attach-menu">
|
||||
<summary class="agent-chat__input-btn agent-chat__input-btn--attach" aria-label="Add attachment">${iconSvg()}</summary>
|
||||
<div class="agent-chat__attach-menu-popover" role="menu">
|
||||
<button class="agent-chat__attach-menu-option" role="menuitem">${iconSvg()}<span>Camera</span></button>
|
||||
<button class="agent-chat__attach-menu-option" role="menuitem">${iconSvg()}<span>Photo</span></button>
|
||||
<button class="agent-chat__attach-menu-option" role="menuitem">${iconSvg()}<span>File</span></button>
|
||||
</div>
|
||||
</details>
|
||||
<div class="agent-chat__composer-combobox">
|
||||
<textarea rows="1">Queued follow-up for the active operator session</textarea>
|
||||
</div>
|
||||
<div class="agent-chat__composer-actions">
|
||||
<button class="chat-send-btn chat-send-btn--voice" aria-label="Start voice input">${iconSvg()}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
<div class="agent-chat__composer-meta">
|
||||
<details class="chat-controls__inline-select chat-controls__permission-picker">
|
||||
<summary class="chat-controls__inline-select-trigger chat-controls__permission-trigger" data-chat-permission-select="true" aria-label="Permissions: Workspace">
|
||||
<span class="chat-controls__permission-icon" aria-hidden="true">${iconSvg()}</span>
|
||||
<span class="chat-controls__inline-select-label">Workspace</span>
|
||||
</summary>
|
||||
<div class="agent-chat__composer-lead">
|
||||
<details class="agent-chat__attach-menu">
|
||||
<summary class="agent-chat__input-btn agent-chat__input-btn--attach" aria-label="Add attachment">${iconSvg()}</summary>
|
||||
<div class="agent-chat__attach-menu-popover" role="menu">
|
||||
<button class="agent-chat__attach-menu-option" role="menuitem">${iconSvg()}<span>Camera</span></button>
|
||||
<button class="agent-chat__attach-menu-option" role="menuitem">${iconSvg()}<span>Photo</span></button>
|
||||
<button class="agent-chat__attach-menu-option" role="menuitem">${iconSvg()}<span>File</span></button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
${composerControlsHtml(opts.crowdedComposerFooter)}
|
||||
<div class="agent-chat__composer-context">
|
||||
<div class="agent-chat__composer-trail">
|
||||
<div class="agent-chat__composer-meta agent-chat__composer-context">
|
||||
<div class="context-usage">
|
||||
<details>
|
||||
<summary class="context-ring" role="status" aria-label="Session context usage: 46k/200k (23%)">
|
||||
@@ -666,6 +669,11 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
${composerControlsHtml(opts.crowdedComposerFooter)}
|
||||
<div class="agent-chat__composer-actions">
|
||||
<button class="chat-send-btn chat-send-btn--voice" aria-label="Start voice input">${iconSvg()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,7 +686,9 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
}
|
||||
|
||||
async function syncFixtureComposerPopoverAnchor(page: Page) {
|
||||
await page.locator(".agent-chat__input").evaluate((node) => {
|
||||
// The session companion runs the same composer surface, so the pane's own
|
||||
// composer is named by its shell rather than by the shared surface class.
|
||||
await page.locator(".agent-chat__composer-shell > .agent-chat__input").evaluate((node) => {
|
||||
const viewport = window.visualViewport;
|
||||
const viewportTop = viewport?.offsetTop ?? 0;
|
||||
const layoutViewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
||||
@@ -1589,6 +1599,42 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("centers a standalone interrupted status on the composer axis", async () => {
|
||||
const page = await openBrowserPage(1200, 800);
|
||||
try {
|
||||
await page.setContent(`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
|
||||
<section class="card chat">
|
||||
<div class="chat-thread" role="log">
|
||||
<div class="chat-thread-inner">
|
||||
<div class="chat-turn-terminal-status chat-turn-terminal-status--interrupted">
|
||||
${iconSvg()}<span>Interrupted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-chat__composer-shell"><div class="agent-chat__input">Composer</div></div>
|
||||
</section>
|
||||
</body></html>`);
|
||||
|
||||
const [composer, status] = await Promise.all([
|
||||
getRect(page, ".agent-chat__composer-shell"),
|
||||
getRect(page, ".chat-turn-terminal-status--interrupted"),
|
||||
]);
|
||||
expect(
|
||||
Math.abs(status.left + status.width / 2 - (composer.left + composer.width / 2)),
|
||||
).toBeLessThan(1);
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
if (artifactDir) {
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(artifactDir, "interrupted-status-centered.png"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives inline MCP Apps the full assistant message column", async () => {
|
||||
const page = await openBrowserPage(1366, 900);
|
||||
try {
|
||||
@@ -2355,7 +2401,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
return {
|
||||
assistantBubble: styleFor(".chat-group.assistant .chat-bubble:first-child"),
|
||||
bubble: styleFor(".chat-group.user .chat-bubble:first-child"),
|
||||
composer: styleFor(".agent-chat__input"),
|
||||
composer: styleFor(".agent-chat__composer-shell > .agent-chat__input"),
|
||||
footer: styleFor(".agent-chat__composer-footer"),
|
||||
textarea: styleFor(".agent-chat__composer-combobox > textarea"),
|
||||
};
|
||||
@@ -2381,23 +2427,37 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
// keeps the text on the tool-row left edge.
|
||||
expect(geometry.assistantBubble?.paddingLeft).toBe(0);
|
||||
expect(geometry.assistantBubble?.paddingRight).toBe(0);
|
||||
expect(geometry.composer?.borderRadius).toBe(mediumRadius);
|
||||
// The composer rests one radius step above the bubble: it is the surface
|
||||
// the thread sits on, not another card in the same stack.
|
||||
expect(geometry.composer?.borderRadius).toBe(20 * (await readCornerScale(page)));
|
||||
|
||||
const usesCompactComposer = width <= 768 || (width <= 932 && height <= 500 && width > height);
|
||||
const composerInset = usesCompactComposer ? 4 : 8;
|
||||
const textareaBlockInset = usesCompactComposer ? 10 : composerInset;
|
||||
// The editor's horizontal inset belongs to its row, not to the control,
|
||||
// so the text keeps one origin while the surface changes shape.
|
||||
const textareaBlockInset = width <= 768 || (width <= 932 && height <= 500) ? 10 : 6;
|
||||
expect(geometry.textarea?.paddingTop).toBe(textareaBlockInset);
|
||||
expect(geometry.textarea?.paddingRight).toBe(composerInset);
|
||||
expect(geometry.textarea?.paddingRight).toBe(0);
|
||||
expect(geometry.textarea?.paddingBottom).toBe(textareaBlockInset);
|
||||
expect(geometry.textarea?.paddingLeft).toBe(
|
||||
usesCompactComposer ? composerInset : composerInset - 4,
|
||||
);
|
||||
expect(geometry.footer?.paddingLeft).toBe(8);
|
||||
expect(geometry.footer?.paddingRight).toBe(8);
|
||||
// #105866 splits the block inset evenly around the footer so the
|
||||
// settings chip centers between the divider and the card edge.
|
||||
expect(geometry.footer?.paddingTop).toBe(composerInset / 2);
|
||||
expect(geometry.footer?.paddingBottom).toBe(composerInset / 2);
|
||||
expect(geometry.textarea?.paddingLeft).toBe(0);
|
||||
const shortLandscape = width <= 932 && height <= 500;
|
||||
const footerInset = width <= 768 || shortLandscape ? 4 : 8;
|
||||
expect(geometry.footer?.paddingLeft).toBe(footerInset);
|
||||
expect(geometry.footer?.paddingRight).toBe(footerInset);
|
||||
// Multiline keeps optical breathing room inside the footer on both edges;
|
||||
// the outer margin only docks the complete row above the surface edge.
|
||||
expect(geometry.footer?.paddingTop).toBe(width <= 768 ? 4 : shortLandscape ? 2 : 6);
|
||||
expect(geometry.footer?.paddingBottom).toBe(width <= 768 ? 4 : shortLandscape ? 0 : 6);
|
||||
|
||||
// The resting shape is two stacked regions, not one line that may grow
|
||||
// into two: a draft that fits on a single line still leaves the surface at
|
||||
// its multiline floor, with the whole action row below the editor.
|
||||
const [surface, editor, actionRow] = await Promise.all([
|
||||
getRect(page, ".agent-chat__composer-shell > .agent-chat__input"),
|
||||
getRect(page, ".agent-chat__composer-combobox > textarea"),
|
||||
getRect(page, ".agent-chat__composer-footer"),
|
||||
]);
|
||||
expect(surface.height).toBeGreaterThanOrEqual(98);
|
||||
expect(actionRow.top).toBeGreaterThanOrEqual(editor.bottom - 1);
|
||||
expect(surface.bottom - actionRow.bottom).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
@@ -2774,8 +2834,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
const page = await openFixture(393, 852);
|
||||
try {
|
||||
const textarea = page.locator(".agent-chat__composer-combobox > textarea");
|
||||
// Comfortably past a quarter of the tallest viewport this case runs at,
|
||||
// so the assertion below proves the cap and the scroll, not the draft.
|
||||
await textarea.fill(
|
||||
Array.from({ length: 8 }, (_value, index) => `Mobile composer line ${index + 1}`).join(
|
||||
Array.from({ length: 16 }, (_value, index) => `Mobile composer line ${index + 1}`).join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
@@ -2807,7 +2869,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
return {
|
||||
attach: rectFor('.agent-chat__input-btn[aria-label="Add attachment"]'),
|
||||
attachIcon: rectFor('.agent-chat__input-btn[aria-label="Add attachment"] svg'),
|
||||
input: rectFor(".agent-chat__input"),
|
||||
input: rectFor(".agent-chat__composer-shell > .agent-chat__input"),
|
||||
meta: rectFor(".agent-chat__composer-meta"),
|
||||
model: rectFor(".chat-composer-model-control"),
|
||||
context: rectFor(".context-ring"),
|
||||
@@ -2825,6 +2887,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
}
|
||||
: null,
|
||||
thread: rectFor(".chat-thread"),
|
||||
viewportHeight: window.innerHeight,
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
@@ -2850,15 +2913,16 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
throw new Error("Expected textarea sizing metrics");
|
||||
}
|
||||
|
||||
const fiveLineHeight =
|
||||
textareaMetrics.lineHeight * 5 + textareaMetrics.paddingTop + textareaMetrics.paddingBottom;
|
||||
expect(textareaRect.height).toBeLessThanOrEqual(fiveLineHeight + 1);
|
||||
// The editor grows against the viewport, not against a line count: past a
|
||||
// quarter of the screen the surface stops moving and the draft scrolls
|
||||
// inside it, so a long draft can never push the thread off the page.
|
||||
expect(textareaRect.height).toBeLessThanOrEqual(layout.viewportHeight * 0.25 + 1);
|
||||
expect(textareaMetrics.scrollHeight).toBeGreaterThan(textareaMetrics.clientHeight);
|
||||
expect(input.y - (thread.y + thread.height)).toBeGreaterThanOrEqual(5.5);
|
||||
expect(shell.x).toBeLessThanOrEqual(8);
|
||||
expect(layout.viewportWidth - (shell.x + shell.width)).toBeLessThanOrEqual(8);
|
||||
expect(attach.x - input.x).toBeLessThanOrEqual(10);
|
||||
expect(context.x).toBeGreaterThanOrEqual(model.x + model.width - 1);
|
||||
expect(model.x).toBeGreaterThanOrEqual(context.x + context.width - 1);
|
||||
expect(input.x + input.width - (send.x + send.width)).toBeLessThanOrEqual(8);
|
||||
for (const control of [model, context]) {
|
||||
expect(
|
||||
@@ -2866,8 +2930,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
).toBeLessThanOrEqual(2);
|
||||
}
|
||||
expect(meta.y).toBeGreaterThanOrEqual(model.y - 1);
|
||||
expect(attachIcon.width).toBeGreaterThanOrEqual(18);
|
||||
expect(attachIcon.height).toBeGreaterThanOrEqual(18);
|
||||
expect(attachIcon.width).toBeGreaterThanOrEqual(16);
|
||||
expect(attachIcon.height).toBeGreaterThanOrEqual(16);
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
@@ -2885,7 +2949,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
async (width, height, composerAttachment) => {
|
||||
const page = await openFixture(width, height, { composerAttachment });
|
||||
try {
|
||||
const composer = await getBoundingBox(page, ".agent-chat__input");
|
||||
const composer = await getBoundingBox(
|
||||
page,
|
||||
".agent-chat__composer-shell > .agent-chat__input",
|
||||
);
|
||||
const menuSelector = ".context-usage__popover";
|
||||
const triggerSelector = ".context-ring";
|
||||
await page.locator(triggerSelector).evaluate((node) => {
|
||||
@@ -2933,12 +3000,17 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
await waitForLayoutSettled(page, ".context-usage__popover, .agent-chat__input");
|
||||
await syncFixtureComposerPopoverAnchor(page);
|
||||
await waitForLayoutSettled(page, ".context-usage__popover, .agent-chat__input");
|
||||
const composer = await getBoundingBox(page, ".agent-chat__input");
|
||||
const composer = await getBoundingBox(
|
||||
page,
|
||||
".agent-chat__composer-shell > .agent-chat__input",
|
||||
);
|
||||
const menu = await getBoundingBox(page, ".context-usage__popover");
|
||||
const anchorEvidence = await page.locator(".agent-chat__input").evaluate((node) => ({
|
||||
anchorBottom: getComputedStyle(node).getPropertyValue("--chat-composer-popover-bottom"),
|
||||
layoutHeight: document.documentElement.clientHeight,
|
||||
}));
|
||||
const anchorEvidence = await page
|
||||
.locator(".agent-chat__composer-shell > .agent-chat__input")
|
||||
.evaluate((node) => ({
|
||||
anchorBottom: getComputedStyle(node).getPropertyValue("--chat-composer-popover-bottom"),
|
||||
layoutHeight: document.documentElement.clientHeight,
|
||||
}));
|
||||
|
||||
expect(menu.y).toBeGreaterThanOrEqual(300);
|
||||
expect(
|
||||
@@ -2968,7 +3040,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
};
|
||||
};
|
||||
return {
|
||||
context: rectFor(".context-ring"),
|
||||
controls: rectFor(".agent-chat__composer-controls"),
|
||||
effort: rectFor(".chat-controls__effort-trigger"),
|
||||
footer: rectFor(".agent-chat__composer-footer"),
|
||||
@@ -2976,38 +3047,30 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
model: rectFor(".chat-controls__model-trigger"),
|
||||
modelLabel: rectFor(".chat-controls__model-trigger .chat-controls__inline-select-label"),
|
||||
overrides: rectFor(".agent-chat__session-overrides-pill"),
|
||||
permission: rectFor(".chat-controls__permission-trigger"),
|
||||
status: rectFor(".agent-chat__composer-run-status"),
|
||||
typing: rectFor(".agent-chat__typing-indicator--outside"),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.controls.scrollWidth).toBeLessThanOrEqual(layout.controls.clientWidth + 1);
|
||||
for (const control of [
|
||||
layout.status,
|
||||
layout.overrides,
|
||||
layout.model,
|
||||
layout.effort,
|
||||
layout.permission,
|
||||
layout.context,
|
||||
layout.typing,
|
||||
]) {
|
||||
for (const control of [layout.status, layout.overrides, layout.model, layout.effort]) {
|
||||
expect(control.x).toBeGreaterThanOrEqual(layout.footer.x - 1);
|
||||
expect(control.x + control.width).toBeLessThanOrEqual(
|
||||
layout.footer.x + layout.footer.width + 1,
|
||||
);
|
||||
}
|
||||
for (const trigger of [layout.permission, layout.model, layout.effort]) {
|
||||
expect(layout.typing.x).toBeGreaterThanOrEqual(0);
|
||||
expect(layout.typing.x + layout.typing.width).toBeLessThanOrEqual(320);
|
||||
for (const trigger of [layout.model, layout.effort]) {
|
||||
expect(trigger.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(trigger.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
}
|
||||
expect(layout.modelLabel.scrollWidth).toBeLessThanOrEqual(layout.modelLabel.clientWidth + 1);
|
||||
for (const [left, right] of [
|
||||
[layout.meta, layout.status],
|
||||
[layout.status, layout.overrides],
|
||||
[layout.overrides, layout.model],
|
||||
[layout.model, layout.effort],
|
||||
[layout.effort, layout.context],
|
||||
[layout.effort, layout.meta],
|
||||
] as const) {
|
||||
expect(rectsOverlap(left, right)).toBe(false);
|
||||
}
|
||||
@@ -3021,7 +3084,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
[320, 568],
|
||||
[393, 852],
|
||||
[568, 320],
|
||||
[1024, 768],
|
||||
[1366, 900],
|
||||
[1920, 1080],
|
||||
] as const)(
|
||||
@@ -3063,7 +3125,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
return {
|
||||
chat: rectFor(".card.chat"),
|
||||
shell: rectFor(".agent-chat__composer-shell"),
|
||||
input: rectFor(".agent-chat__input"),
|
||||
input: rectFor(".agent-chat__composer-shell > .agent-chat__input"),
|
||||
thread: rectFor(".chat-thread"),
|
||||
footer: rectFor(".agent-chat__composer-footer"),
|
||||
textarea: rectFor(".agent-chat__composer-combobox > textarea"),
|
||||
@@ -3079,10 +3141,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
effortLabel: rectFor(
|
||||
".chat-controls__effort-trigger .chat-controls__inline-select-label",
|
||||
),
|
||||
permission: rectFor(".chat-controls__permission-trigger"),
|
||||
permissionLabel: rectFor(
|
||||
".chat-controls__permission-trigger .chat-controls__inline-select-label",
|
||||
),
|
||||
context: rectFor(".context-ring"),
|
||||
attach: rectFor('.agent-chat__input-btn[aria-label="Add attachment"]'),
|
||||
send: rectFor(".chat-send-btn"),
|
||||
@@ -3106,11 +3164,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(controls.modelTriggerPadding).not.toBeNull();
|
||||
expect(controls.effortTriggerPadding).not.toBeNull();
|
||||
const effortLabel = expectControlRect(controls.effortLabel, "composer thinking label");
|
||||
const permission = expectControlRect(controls.permission, "composer permission trigger");
|
||||
const permissionLabel = expectControlRect(
|
||||
controls.permissionLabel,
|
||||
"composer permission label",
|
||||
);
|
||||
const context = expectControlRect(controls.context, "composer context control");
|
||||
const attach = expectControlRect(controls.attach, "composer attach control");
|
||||
const send = expectControlRect(controls.send, "composer send control");
|
||||
@@ -3122,7 +3175,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
model,
|
||||
modelTrigger,
|
||||
effortTrigger,
|
||||
permission,
|
||||
context,
|
||||
attach,
|
||||
send,
|
||||
@@ -3138,53 +3190,58 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(model.y + model.height).toBeLessThanOrEqual(footer.y + footer.height + 1);
|
||||
expect(model.y).toBeGreaterThanOrEqual(textarea.y);
|
||||
expect(context.y).toBeGreaterThanOrEqual(textarea.y);
|
||||
expect(
|
||||
Math.abs(attach.y + attach.height / 2 - (send.y + send.height / 2)),
|
||||
).toBeLessThanOrEqual(2);
|
||||
expect(attach.x + attach.width).toBeLessThanOrEqual(textarea.x + 1);
|
||||
expect(send.x).toBeGreaterThanOrEqual(textarea.x + textarea.width - 1);
|
||||
// The multiline footer is one row: the leading attachment control and
|
||||
// trailing action group share its vertical bounds without overlapping.
|
||||
expect(attach.y).toBeGreaterThanOrEqual(footer.y - 1);
|
||||
expect(attach.y + attach.height).toBeLessThanOrEqual(footer.y + footer.height + 1);
|
||||
expect(send.y).toBeGreaterThanOrEqual(footer.y - 1);
|
||||
expect(send.y + send.height).toBeLessThanOrEqual(footer.y + footer.height + 1);
|
||||
// Footer controls stay below the editor, pinned to opposite edges of the
|
||||
// surface, and neither may drift back up into the text.
|
||||
expect(attach.y).toBeGreaterThanOrEqual(textarea.y + textarea.height - 1);
|
||||
expect(send.y).toBeGreaterThanOrEqual(textarea.y + textarea.height - 1);
|
||||
expect(attach.x).toBeLessThan(send.x);
|
||||
expect(send.x + send.width).toBeLessThanOrEqual(input.x + input.width + 1);
|
||||
expect(rectsOverlap(model, send)).toBe(false);
|
||||
expect(permission.x).toBeLessThan(model.x);
|
||||
expect(rectsOverlap(permission, model)).toBe(false);
|
||||
const effortContextGap = context.x - (effortTrigger.x + effortTrigger.width);
|
||||
expect(effortContextGap).toBeGreaterThanOrEqual(-1);
|
||||
expect(effortContextGap).toBeLessThanOrEqual(9);
|
||||
const contextModelGap = model.x - (context.x + context.width);
|
||||
expect(contextModelGap).toBeGreaterThanOrEqual(-1);
|
||||
const composerFontSize = await page
|
||||
.locator(".agent-chat__composer-combobox > textarea")
|
||||
.evaluate((textareaNode) => Number.parseFloat(getComputedStyle(textareaNode).fontSize));
|
||||
if (width <= 768) {
|
||||
expect(controls.modelTriggerPadding).toEqual({ end: 10, start: 10 });
|
||||
expect(controls.effortTriggerPadding).toEqual({ end: 10, start: 10 });
|
||||
const modelPadding = width <= 480 ? 0 : 4;
|
||||
expect(controls.modelTriggerPadding).toEqual({
|
||||
end: modelPadding,
|
||||
start: modelPadding,
|
||||
});
|
||||
expect(controls.effortTriggerPadding).toEqual({ end: 4, start: 4 });
|
||||
expect(composerFontSize).toBe(16);
|
||||
expect(model.width).toBeGreaterThanOrEqual(40);
|
||||
expect(model.width).toBeLessThanOrEqual(footer.width);
|
||||
for (const trigger of [permission, modelTrigger, effortTrigger]) {
|
||||
for (const trigger of [modelTrigger, effortTrigger]) {
|
||||
expect(trigger.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(trigger.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
}
|
||||
for (const label of [modelLabel, effortLabel, permissionLabel]) {
|
||||
for (const label of [modelLabel, effortLabel]) {
|
||||
expect(label.clientWidth).toBeDefined();
|
||||
expect(label.scrollWidth).toBeDefined();
|
||||
expect(label.scrollWidth ?? 0).toBeLessThanOrEqual((label.clientWidth ?? 0) + 1);
|
||||
}
|
||||
expect(send.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
expect(send.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
|
||||
for (const control of [permission, model, context]) {
|
||||
for (const control of [model, context]) {
|
||||
expect(
|
||||
Math.abs(control.y + control.height / 2 - (model.y + model.height / 2)),
|
||||
).toBeLessThanOrEqual(2);
|
||||
}
|
||||
expect(footer.height).toBeLessThanOrEqual(49.1);
|
||||
expect(footer.height).toBeLessThanOrEqual(53);
|
||||
} else {
|
||||
expect(controls.modelTriggerPadding).toEqual({ end: 6, start: 8 });
|
||||
expect(controls.effortTriggerPadding).toEqual({ end: 6, start: 8 });
|
||||
expect(composerFontSize).toBe(14);
|
||||
for (const label of [permissionLabel, modelLabel, effortLabel]) {
|
||||
expect(label.scrollWidth).toBeLessThanOrEqual((label.clientWidth ?? 0) + 1);
|
||||
}
|
||||
expect(send.width).toBeCloseTo(36, 2);
|
||||
expect(send.height).toBeCloseTo(36, 2);
|
||||
// The editor reads at input size, while the controls around it stay
|
||||
// chrome-sized — that difference is what marks the text as the
|
||||
// subject of the surface.
|
||||
expect(composerFontSize).toBe(16);
|
||||
expect(send.width).toBeCloseTo(32, 2);
|
||||
expect(send.height).toBeCloseTo(32, 2);
|
||||
}
|
||||
|
||||
if (width >= 1600) {
|
||||
@@ -3193,7 +3250,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(
|
||||
Math.abs(shell.x + shell.width / 2 - (chat.x + chat.width / 2)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(input.height).toBeLessThanOrEqual(112);
|
||||
expect(input.height).toBeLessThanOrEqual(119);
|
||||
}
|
||||
|
||||
if (width > height && height <= 500) {
|
||||
@@ -3216,7 +3273,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
const page = await openFixture(width, height, { composerAttachment: true });
|
||||
try {
|
||||
await expectNoHorizontalOverflow(page);
|
||||
const input = await getBoundingBox(page, ".agent-chat__input");
|
||||
const input = await getBoundingBox(
|
||||
page,
|
||||
".agent-chat__composer-shell > .agent-chat__input",
|
||||
);
|
||||
const preview = await getBoundingBox(page, ".chat-attachments-preview");
|
||||
const attachment = await getBoundingBox(page, ".chat-attachment-thumb");
|
||||
const previewPaddingTop = await page
|
||||
@@ -3372,7 +3432,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
};
|
||||
};
|
||||
return {
|
||||
input: rectFor(".agent-chat__input"),
|
||||
input: rectFor(".agent-chat__composer-shell > .agent-chat__input"),
|
||||
thread: rectFor(".chat-thread"),
|
||||
textarea: rectFor(".agent-chat__composer-combobox > textarea"),
|
||||
};
|
||||
@@ -3395,7 +3455,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(textarea.scrollHeight).toBeGreaterThan(textarea.clientHeight);
|
||||
|
||||
const scrolled = await page.evaluate(() => {
|
||||
const composer = document.querySelector(".agent-chat__input") as HTMLElement | null;
|
||||
const composer = document.querySelector(
|
||||
".agent-chat__composer-shell > .agent-chat__input",
|
||||
) as HTMLElement | null;
|
||||
if (composer) {
|
||||
composer.scrollTop = composer.scrollHeight;
|
||||
}
|
||||
@@ -3409,7 +3471,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
};
|
||||
return {
|
||||
shell: rectFor(".agent-chat__composer-shell"),
|
||||
input: rectFor(".agent-chat__input"),
|
||||
input: rectFor(".agent-chat__composer-shell > .agent-chat__input"),
|
||||
meta: rectFor(".agent-chat__composer-meta"),
|
||||
model: rectFor(".chat-composer-model-control"),
|
||||
send: rectFor(".chat-send-btn"),
|
||||
@@ -3462,7 +3524,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
};
|
||||
};
|
||||
return {
|
||||
input: rectFor(".agent-chat__input"),
|
||||
input: rectFor(".agent-chat__composer-shell > .agent-chat__input"),
|
||||
menu: rectFor(".slash-menu"),
|
||||
textarea: rectFor(".agent-chat__composer-combobox > textarea"),
|
||||
footer: rectFor(".agent-chat__composer-footer"),
|
||||
@@ -3484,7 +3546,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
expect(textarea.y).toBeGreaterThan(menu.y);
|
||||
|
||||
const scrolled = await page.evaluate(() => {
|
||||
const composer = document.querySelector(".agent-chat__input") as HTMLElement | null;
|
||||
const composer = document.querySelector(
|
||||
".agent-chat__composer-shell > .agent-chat__input",
|
||||
) as HTMLElement | null;
|
||||
if (composer) {
|
||||
composer.scrollTop = composer.scrollHeight;
|
||||
}
|
||||
@@ -3497,7 +3561,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
return {
|
||||
input: rectFor(".agent-chat__input"),
|
||||
input: rectFor(".agent-chat__composer-shell > .agent-chat__input"),
|
||||
footer: rectFor(".agent-chat__composer-footer"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -34,21 +34,25 @@ function renderDiskSpaceNotice(diskSpace: SessionPlacementDiskSpace | undefined)
|
||||
const critical = diskSpace.status === "critical";
|
||||
return html`
|
||||
<div
|
||||
class="callout ${critical ? "danger" : "warn"} chat-cloud-disk-space-notice"
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--${critical
|
||||
? "danger"
|
||||
: "warn"} chat-cloud-disk-space-notice"
|
||||
role=${critical ? "alert" : "status"}
|
||||
>
|
||||
<div class="chat-cloud-disk-space-notice__title">
|
||||
<span aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<span class="chat-composer-neighbor-card__icon" aria-hidden="true"
|
||||
>${icons.alertTriangle}</span
|
||||
>
|
||||
<div class="chat-composer-neighbor-card__copy">
|
||||
<strong
|
||||
>${t(critical ? "chat.diskSpace.criticalTitle" : "chat.diskSpace.warningTitle")}</strong
|
||||
>
|
||||
<span>
|
||||
${t(critical ? "chat.diskSpace.criticalBody" : "chat.diskSpace.warningBody", {
|
||||
percent: String(usedPercent),
|
||||
free: formatBytes(diskSpace.availableBytes),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
${t(critical ? "chat.diskSpace.criticalBody" : "chat.diskSpace.warningBody", {
|
||||
percent: String(usedPercent),
|
||||
free: formatBytes(diskSpace.availableBytes),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -58,9 +62,16 @@ export function renderChatViewNotices(props: ChatViewNoticesProps) {
|
||||
${renderDiskSpaceNotice(props.diskSpace)}
|
||||
${props.error
|
||||
? html`
|
||||
<div class="chat-error" role="alert">
|
||||
<span class="chat-error__dot" aria-hidden="true"></span>
|
||||
<span class="chat-error__content">${props.error}</span>
|
||||
<div
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--danger chat-error"
|
||||
role="alert"
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__icon" aria-hidden="true"
|
||||
>${icons.alertTriangle}</span
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__copy chat-error__content"
|
||||
><strong>${props.error}</strong></span
|
||||
>
|
||||
${props.onDismissError
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.actions.dismissError")}>
|
||||
|
||||
@@ -750,11 +750,11 @@ describe("chat typing status", () => {
|
||||
expectedText: "Ayaan, Liam, Maya, Zoe are typing…",
|
||||
expectedAvatars: 3,
|
||||
},
|
||||
])("renders $expectedText as a transcript row", ({ actors, expectedText, expectedAvatars }) => {
|
||||
])("renders $expectedText in the transcript", ({ actors, expectedText, expectedAvatars }) => {
|
||||
const container = renderChatView({ typingActors: actors });
|
||||
const row = container.querySelector('[data-virtual-row-key="presence:typing"]');
|
||||
const indicator = row?.querySelector(".agent-chat__typing-indicator");
|
||||
const indicator = container.querySelector(".agent-chat__typing-indicator--outside");
|
||||
|
||||
expect(indicator?.closest('[data-virtual-row-key="presence:typing"]')).not.toBeNull();
|
||||
expect(indicator?.closest(".agent-chat__composer-shell")).toBeNull();
|
||||
expect(indicator?.querySelectorAll(".chat-avatar")).toHaveLength(expectedAvatars);
|
||||
expect(
|
||||
@@ -763,66 +763,34 @@ describe("chat typing status", () => {
|
||||
expect(indicator?.textContent).toContain(expectedText);
|
||||
});
|
||||
|
||||
it("keeps typing after every other transient transcript row", () => {
|
||||
vi.mocked(chatThread.buildCachedChatItems).mockReturnValueOnce([
|
||||
{
|
||||
kind: "group",
|
||||
key: "group:assistant:typing-order",
|
||||
role: "assistant",
|
||||
messages: [
|
||||
{
|
||||
key: "message:assistant:typing-order",
|
||||
message: { role: "assistant", content: "Done", timestamp: 1 },
|
||||
},
|
||||
],
|
||||
timestamp: 1,
|
||||
isStreaming: false,
|
||||
},
|
||||
{
|
||||
kind: "group",
|
||||
key: "group:tool:typing-order",
|
||||
role: "tool",
|
||||
messages: [
|
||||
{
|
||||
key: "message:tool:typing-order",
|
||||
message: { role: "tool", content: "Later tool result", timestamp: 2 },
|
||||
},
|
||||
],
|
||||
timestamp: 2,
|
||||
isStreaming: false,
|
||||
},
|
||||
]);
|
||||
vi.spyOn(chatProgress, "resolveTurnRecap").mockReturnValue({
|
||||
runtimeMs: 5_000,
|
||||
outputTokens: 42,
|
||||
});
|
||||
it("keeps queue and error state outside the transcript typing row", () => {
|
||||
const container = renderChatView({
|
||||
messages: [{ role: "user", content: "Done?", timestamp: 1 }],
|
||||
typingActors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
realtimeTalkConversation: [
|
||||
{ id: "voice", role: "assistant", text: "Listening", isStreaming: true },
|
||||
],
|
||||
backgroundTasks: createBackgroundTasks({
|
||||
collapsed: true,
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
taskId: "task-1",
|
||||
status: "running",
|
||||
agentId: "main",
|
||||
createdAt: 1,
|
||||
startedAt: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
runError: { summary: "Gateway unavailable" },
|
||||
queue: [{ id: "queued", text: "Try again", createdAt: 1 }],
|
||||
});
|
||||
const keys = [...container.querySelectorAll<HTMLElement>("[data-virtual-row-key]")].map(
|
||||
(row) => row.dataset.virtualRowKey,
|
||||
const indicator = requireElement(
|
||||
container,
|
||||
".agent-chat__typing-indicator--outside",
|
||||
"typing status",
|
||||
);
|
||||
expect(keys).toContain("realtime-talk");
|
||||
expect(keys).toContain("turn-recap");
|
||||
expect(keys).toContain("background-tasks");
|
||||
expect(keys.at(-1)).toBe("presence:typing");
|
||||
|
||||
expect(indicator.closest('[data-virtual-row-key="presence:typing"]')).not.toBeNull();
|
||||
expect(container.querySelector(".chat-queue")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-error__content")?.textContent).toContain(
|
||||
"Gateway unavailable",
|
||||
);
|
||||
expect(container.querySelector(".agent-chat__composer-shell")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps transcript typing status with the model setup composer", () => {
|
||||
const container = renderChatView({
|
||||
canSend: false,
|
||||
modelSetupRequired: true,
|
||||
typingActors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
});
|
||||
|
||||
expect(container.querySelector(".agent-chat__typing-indicator--outside")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -885,9 +853,7 @@ describe("chat Swarm progress", () => {
|
||||
expect(scrollAnchor?.classList.contains("chat-scroll-to-bottom-wrap")).toBe(true);
|
||||
expect(scrollAnchor?.previousElementSibling?.classList.contains("chat-thread")).toBe(true);
|
||||
expect(widget?.nextElementSibling?.classList.contains("agent-chat__composer-shell")).toBe(true);
|
||||
expect(container.querySelector(".chat-swarm__dot--running")?.getAttribute("title")).toBe(
|
||||
"Worker A: Running",
|
||||
);
|
||||
expect(container.querySelector(".chat-swarm__task-name")?.textContent).toBe("Worker A");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -960,18 +926,27 @@ describe("inline approval card", () => {
|
||||
});
|
||||
|
||||
describe("chat run error", () => {
|
||||
it("renders a non-interactive alert immediately above the composer", () => {
|
||||
it("renders a non-interactive alert in the conversation notices", () => {
|
||||
const container = renderChatView({
|
||||
runError: { summary: "Error: gateway disconnected" },
|
||||
});
|
||||
|
||||
const alert = requireElement(container, ".chat-run-error", "chat run error");
|
||||
const summary = requireElement(alert, ".chat-run-error__summary", "chat run error summary");
|
||||
const alert = requireElement(container, ".chat-error", "chat run error");
|
||||
const summary = requireElement(alert, ".chat-error__content", "chat run error summary");
|
||||
expect(alert.getAttribute("role")).toBe("alert");
|
||||
expect(summary.textContent?.trim()).toBe("Error: gateway disconnected");
|
||||
expect(alert.querySelector(".chat-run-error__icon svg")).not.toBeNull();
|
||||
expect(alert.querySelector("button")).toBeNull();
|
||||
expect(alert.nextElementSibling?.classList.contains("agent-chat__composer-shell")).toBe(true);
|
||||
expect(alert.querySelector<HTMLButtonElement>('[aria-label="Dismiss error"]')).toBeNull();
|
||||
expect(alert.closest(".chat-main__conversation-column")).not.toBeNull();
|
||||
expect(alert.closest(".agent-chat__composer-shell")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps dismiss on the error state owned by its callback", () => {
|
||||
const onDismissError = vi.fn();
|
||||
const container = renderChatView({ error: "Request failed", onDismissError });
|
||||
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Dismiss error"]')?.click();
|
||||
|
||||
expect(onDismissError).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1110,6 +1085,28 @@ describe("cloud workspace conflict notice", () => {
|
||||
"git checkout 'refs/openclaw/worker-results/claim-mixed' -- ':(top,literal)src/safe.ts'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("copies the recovery command for the selected conflicted path", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
const normalizedConflict = workspaceResultConflictFromTranscript({
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
details: {
|
||||
paths: ["src/first.ts", "src/second.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-per-row",
|
||||
},
|
||||
});
|
||||
const container = renderChatView({ workspaceConflict: normalizedConflict });
|
||||
const rows = container.querySelectorAll(".chat-workspace-conflict-paths li");
|
||||
|
||||
rows[1]?.querySelectorAll<HTMLButtonElement>("button")[1]?.click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"git checkout 'refs/openclaw/worker-results/claim-per-row' -- ':(top,literal)src/second.ts'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloud worker disk-space notice", () => {
|
||||
@@ -1809,7 +1806,8 @@ describe("chat goal status", () => {
|
||||
);
|
||||
expect(goal?.querySelector(".agent-chat__goal-elapsed")?.textContent).toBe("15s");
|
||||
expect(goal?.getAttribute("aria-label")).toBe("Pursuing goal (12k/50k): Land the web goal UI");
|
||||
expect(goal?.closest(".agent-chat__composer-status-stack")).not.toBeNull();
|
||||
expect(goal?.closest(".agent-chat__goal-float")).not.toBeNull();
|
||||
expect(goal?.closest(".agent-chat__composer-status-stack")).toBeNull();
|
||||
});
|
||||
|
||||
it("dispatches goal commands from the pill controls", () => {
|
||||
@@ -1857,7 +1855,9 @@ describe("chat goal status", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderChat(props), container);
|
||||
|
||||
expect(container.querySelector(".agent-chat__goal-detail")).toBeNull();
|
||||
expect(container.querySelector(".agent-chat__goal-detail")?.getAttribute("aria-hidden")).toBe(
|
||||
"true",
|
||||
);
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Show goal details"]',
|
||||
);
|
||||
@@ -1866,6 +1866,7 @@ describe("chat goal status", () => {
|
||||
render(renderChat(props), container);
|
||||
|
||||
const detail = container.querySelector(".agent-chat__goal-detail");
|
||||
expect(detail?.getAttribute("aria-hidden")).toBe("false");
|
||||
expect(detail?.querySelector(".agent-chat__goal-detail-objective")?.textContent).toBe(
|
||||
"Land the web goal UI",
|
||||
);
|
||||
@@ -2079,7 +2080,7 @@ describe("chat composer workbench", () => {
|
||||
|
||||
const voiceButton = container.querySelector('button[aria-label="Start voice input"]');
|
||||
expect(voiceButton).not.toBeNull();
|
||||
expect(voiceButton?.closest(".agent-chat__composer-input-row")).not.toBeNull();
|
||||
expect(voiceButton?.closest(".agent-chat__composer-trail")).not.toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Talk settings"]')).toBeNull();
|
||||
// The mic device picker is a caret on the voice button, not a separate settings button.
|
||||
const picker = container.querySelector('button[aria-label="Microphone input"]');
|
||||
@@ -3132,10 +3133,11 @@ describe("chat loading skeleton", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the interrupted toast in the composer footer", () => {
|
||||
it("keeps interrupted chrome out of the composer", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
try {
|
||||
const container = renderChatView({
|
||||
messages: [{ role: "assistant", content: "Partial response" }],
|
||||
composerControls: html`<button class="chat-composer-model-control" type="button">
|
||||
Model
|
||||
</button>`,
|
||||
@@ -3147,10 +3149,7 @@ describe("chat loading skeleton", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const toast = container.querySelector(
|
||||
".agent-chat__composer-run-status .agent-chat__run-status--interrupted",
|
||||
);
|
||||
expect(toast?.textContent).toContain("Interrupted");
|
||||
expect(container.querySelector(".agent-chat__composer-run-status")).toBeNull();
|
||||
expect(
|
||||
container.querySelector(".agent-chat__run-status-announcement")?.textContent?.trim(),
|
||||
).toBe("Interrupted");
|
||||
@@ -5224,7 +5223,7 @@ describe("chat attachment picker", () => {
|
||||
expect(cards[0]?.querySelector(".chat-browser-annotation-card__meta")?.textContent).toContain(
|
||||
"2 marked regions",
|
||||
);
|
||||
expect(cards[0]?.textContent).toContain("Element inspected");
|
||||
expect(cards[0]?.textContent).not.toContain("Element inspected");
|
||||
expect(cards[1]?.querySelector(".chat-browser-annotation-card__identity")?.textContent).toBe(
|
||||
"docs.example.test/narrow-layout",
|
||||
);
|
||||
@@ -5904,22 +5903,30 @@ describe("chat model controls", () => {
|
||||
expect(onThinkingSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows override provenance without a separate reset action", () => {
|
||||
it("shows override provenance with a reset action", () => {
|
||||
const { state } = createChatHeaderState({
|
||||
model: null,
|
||||
models: createOpenAiModelCatalog(),
|
||||
});
|
||||
const onModelSelect = vi.fn(async () => true);
|
||||
const container = renderModelControls(state);
|
||||
|
||||
expect(container.querySelector(".chat-controls__model-provenance")).toBeNull();
|
||||
expect(container.querySelector("[data-chat-model-reset]")).toBeNull();
|
||||
|
||||
renderModelControls(state, { modelOverrides: { main: "openai/gpt-5.4" } }, container);
|
||||
renderModelControls(
|
||||
state,
|
||||
{ modelOverrides: { main: "openai/gpt-5.4" }, onModelSelect },
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-controls__model-provenance")?.textContent?.trim()).toBe(
|
||||
expect(container.querySelector(".chat-controls__model-provenance")?.textContent).toContain(
|
||||
"Session override",
|
||||
);
|
||||
expect(container.querySelector("[data-chat-model-reset]")).toBeNull();
|
||||
const reset = container.querySelector<HTMLButtonElement>("[data-chat-model-reset]");
|
||||
expect(reset).toBeInstanceOf(HTMLButtonElement);
|
||||
reset?.click();
|
||||
expect(onModelSelect).toHaveBeenCalledWith("", "main");
|
||||
});
|
||||
|
||||
it("hides model choices for locked sessions while preserving reasoning and speed", () => {
|
||||
|
||||
@@ -155,6 +155,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
|
||||
onTypingChange?: (typing: boolean, preview?: string) => void;
|
||||
canSend: boolean;
|
||||
disabledReason: string | null;
|
||||
disabledReasonTone?: "info" | "danger";
|
||||
disabledBanner?: ChatComposerDisabledBanner;
|
||||
modelSetupRequired?: boolean;
|
||||
onModelSetup?: () => void;
|
||||
@@ -330,6 +331,7 @@ export function renderChat(props: ChatProps) {
|
||||
streamStartedAt: props.streamStartedAt,
|
||||
runId: props.runId,
|
||||
runOutputTokens: props.runOutputTokens,
|
||||
runStatus: props.runStatus,
|
||||
queue: props.queue,
|
||||
showThinking: props.showThinking,
|
||||
showToolCalls: props.showToolCalls,
|
||||
@@ -404,6 +406,7 @@ export function renderChat(props: ChatProps) {
|
||||
queuedOutboxCount: props.queuedOutboxCount,
|
||||
canSend: props.canSend,
|
||||
disabledReason: props.disabledReason,
|
||||
disabledReasonTone: props.disabledReasonTone,
|
||||
disabledBanner: props.disabledBanner,
|
||||
runError: props.runError,
|
||||
sending: props.sending,
|
||||
@@ -449,6 +452,7 @@ export function renderChat(props: ChatProps) {
|
||||
gatewayClient: props.gatewayClient,
|
||||
composerHoldToRecord: props.composerHoldToRecord,
|
||||
suggestionComposer: props.suggestionComposer,
|
||||
typingActors: props.typingActors,
|
||||
onTypingChange: props.onTypingChange,
|
||||
composerControls: props.composerControls,
|
||||
permissionPicker: props.permissionPicker,
|
||||
@@ -573,7 +577,12 @@ export function renderChat(props: ChatProps) {
|
||||
<div class="chat-split-container">
|
||||
<div class="chat-main">
|
||||
<div class="chat-main__conversation-column">
|
||||
${props.header ?? nothing} ${renderChatViewNotices(props)}
|
||||
${props.header ?? nothing}
|
||||
${renderChatViewNotices({
|
||||
...props,
|
||||
error: props.error ?? props.runError?.summary ?? null,
|
||||
onDismissError: props.error != null ? props.onDismissError : undefined,
|
||||
})}
|
||||
${renderTranscriptSearch(props.paneId, requestUpdate)}
|
||||
<div class="chat-main__conversation">
|
||||
${thread} ${earlierHistoryButton} ${scrollToBottomButton}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { html } from "lit";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import type { ChatAttachment } from "../../../lib/chat/chat-types.ts";
|
||||
|
||||
type AttachmentFileKind =
|
||||
| "archive"
|
||||
| "audio"
|
||||
| "code"
|
||||
| "generic"
|
||||
| "pdf"
|
||||
| "presentation"
|
||||
| "spreadsheet"
|
||||
| "text"
|
||||
| "video"
|
||||
| "word";
|
||||
|
||||
function attachmentFilePresentation(attachment: ChatAttachment): {
|
||||
icon: (typeof icons)[keyof typeof icons];
|
||||
kind: AttachmentFileKind;
|
||||
typeLabel: string;
|
||||
} {
|
||||
const mimeType = attachment.mimeType.toLowerCase();
|
||||
const extension = attachment.fileName?.toLowerCase().split(".").pop() ?? "";
|
||||
const typeLabel = extension.toUpperCase() || t("chat.attachments.attachedFile");
|
||||
if (mimeType === "application/pdf" || extension === "pdf") {
|
||||
return { icon: icons.fileText, kind: "pdf", typeLabel };
|
||||
}
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return { icon: icons.music, kind: "audio", typeLabel };
|
||||
}
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return { icon: icons.play, kind: "video", typeLabel };
|
||||
}
|
||||
if (
|
||||
[
|
||||
"application/gzip",
|
||||
"application/vnd.rar",
|
||||
"application/x-7z-compressed",
|
||||
"application/zip",
|
||||
].includes(mimeType) ||
|
||||
["zip", "tar", "gz", "tgz", "rar", "7z"].includes(extension)
|
||||
) {
|
||||
return { icon: icons.archive, kind: "archive", typeLabel };
|
||||
}
|
||||
if (
|
||||
[
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
].includes(mimeType) ||
|
||||
["doc", "docx"].includes(extension)
|
||||
) {
|
||||
return { icon: icons.fileText, kind: "word", typeLabel };
|
||||
}
|
||||
if (
|
||||
[
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/csv",
|
||||
].includes(mimeType) ||
|
||||
["csv", "xls", "xlsx"].includes(extension)
|
||||
) {
|
||||
return { icon: icons.file, kind: "spreadsheet", typeLabel };
|
||||
}
|
||||
if (
|
||||
[
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
].includes(mimeType) ||
|
||||
["ppt", "pptx"].includes(extension)
|
||||
) {
|
||||
return { icon: icons.file, kind: "presentation", typeLabel };
|
||||
}
|
||||
if (
|
||||
["application/json", "application/toml", "application/yaml"].includes(mimeType) ||
|
||||
["js", "jsx", "ts", "tsx", "json", "yaml", "yml", "toml", "sh"].includes(extension)
|
||||
) {
|
||||
return { icon: icons.braces, kind: "code", typeLabel };
|
||||
}
|
||||
if (mimeType.startsWith("text/") || ["md", "txt", "rtf"].includes(extension)) {
|
||||
return { icon: icons.fileText, kind: "text", typeLabel };
|
||||
}
|
||||
return { icon: icons.file, kind: "generic", typeLabel };
|
||||
}
|
||||
|
||||
export function renderStandardFileAttachment(attachment: ChatAttachment) {
|
||||
const presentation = attachmentFilePresentation(attachment);
|
||||
const label = attachment.fileName ?? t("chat.attachments.attachedFile");
|
||||
return html`
|
||||
<openclaw-tooltip .content=${label}>
|
||||
<div
|
||||
class="chat-attachment-file chat-attachment-file--${presentation.kind}"
|
||||
role="img"
|
||||
aria-label=${label}
|
||||
>
|
||||
<span class="chat-attachment-file__icon">${presentation.icon}</span>
|
||||
<span class="chat-attachment-file__body">
|
||||
<span class="chat-attachment-file__name">${label}</span>
|
||||
<span class="chat-attachment-file__type">${presentation.typeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</openclaw-tooltip>
|
||||
`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared attachment controls for chat and new-session composers.
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { html, nothing } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import type { ImageLightboxItem } from "../../../components/image-lightbox.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
releaseChatAttachmentPayload,
|
||||
} from "../attachment-payload-store.ts";
|
||||
import { admitAttachmentFiles } from "./chat-attachment-admission.ts";
|
||||
import { renderStandardFileAttachment } from "./chat-attachment-file.ts";
|
||||
|
||||
const CHAT_ATTACHMENT_ACCEPT =
|
||||
"image/*,audio/*,video/*,application/pdf,text/*,.csv,.json,.md,.txt,.zip," +
|
||||
@@ -396,7 +398,10 @@ function showPastedTextInComposer(att: ChatAttachment, props: ChatAttachmentCont
|
||||
}
|
||||
|
||||
function handleChatAttachmentFileSelect(e: Event, props: ChatAttachmentControlsProps) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const input = e.target;
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const files = [...(input.files ?? [])];
|
||||
input.value = "";
|
||||
void appendAttachmentFiles(files, props);
|
||||
@@ -500,7 +505,10 @@ export function handleChatAttachmentMenuSelection(
|
||||
if (value !== "camera" && value !== "photo" && value !== "file") {
|
||||
return false;
|
||||
}
|
||||
clickComposerInput(event.currentTarget as HTMLElement, `.agent-chat__${value}-input`);
|
||||
const target = event.currentTarget;
|
||||
if (target instanceof HTMLElement) {
|
||||
clickComposerInput(target, `.agent-chat__${value}-input`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -614,9 +622,6 @@ function renderBrowserAnnotationAttachment(
|
||||
)}
|
||||
</div>
|
||||
<div class="chat-attachment-file__body chat-browser-annotation-card__body">
|
||||
<span class="chat-browser-annotation-card__label"
|
||||
>${t("chat.composer.browserAnnotation")}</span
|
||||
>
|
||||
<span
|
||||
class="chat-attachment-file__name chat-browser-annotation-card__identity"
|
||||
title=${identity}
|
||||
@@ -624,9 +629,6 @@ function renderBrowserAnnotationAttachment(
|
||||
>
|
||||
<span class="chat-attachment-file__meta chat-browser-annotation-card__meta">
|
||||
<span>${regionLabel}</span>
|
||||
${annotation.inspectedElement
|
||||
? html`<span>${t("chat.composer.browserAnnotationInspectedElement")}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
</div>
|
||||
<openclaw-tooltip .content=${removeLabel}>
|
||||
@@ -644,13 +646,37 @@ function renderBrowserAnnotationAttachment(
|
||||
`;
|
||||
}
|
||||
|
||||
function syncAttachmentRailScroll(element: Element | undefined): void {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const sync = () => {
|
||||
const scrollable = element.scrollWidth > element.clientWidth + 1;
|
||||
element.dataset.scrollable = String(scrollable);
|
||||
element.dataset.atStart = String(!scrollable || element.scrollLeft <= 1);
|
||||
element.dataset.atEnd = String(
|
||||
!scrollable || element.scrollLeft + element.clientWidth >= element.scrollWidth - 1,
|
||||
);
|
||||
};
|
||||
sync();
|
||||
requestAnimationFrame(sync);
|
||||
}
|
||||
|
||||
export function renderAttachmentPreview(props: ChatAttachmentControlsProps) {
|
||||
const attachments = props.attachments ?? [];
|
||||
if (attachments.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-attachments-preview">
|
||||
<div
|
||||
class="chat-attachments-preview"
|
||||
${ref(syncAttachmentRailScroll)}
|
||||
@scroll=${(event: Event) => {
|
||||
if (event.currentTarget instanceof Element) {
|
||||
syncAttachmentRailScroll(event.currentTarget);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${attachments.map((att) =>
|
||||
att.browserAnnotation
|
||||
? renderBrowserAnnotationAttachment(att, att.browserAnnotation, props)
|
||||
@@ -665,12 +691,16 @@ export function renderAttachmentPreview(props: ChatAttachmentControlsProps) {
|
||||
.join(" ")}
|
||||
>
|
||||
${att.mimeType.startsWith("image/") && getChatAttachmentPreviewUrl(att)
|
||||
? renderAttachmentImage(
|
||||
att,
|
||||
t("chat.composer.attachmentPreview"),
|
||||
att.fileName?.trim() || t("chat.imageLightbox.untitled"),
|
||||
props,
|
||||
)
|
||||
? html`<openclaw-tooltip
|
||||
.content=${att.fileName?.trim() || t("chat.imageLightbox.untitled")}
|
||||
>
|
||||
${renderAttachmentImage(
|
||||
att,
|
||||
att.fileName?.trim() || t("chat.composer.attachmentPreview"),
|
||||
att.fileName?.trim() || t("chat.imageLightbox.untitled"),
|
||||
props,
|
||||
)}
|
||||
</openclaw-tooltip>`
|
||||
: isLargePastedTextAttachment(att)
|
||||
? html`
|
||||
<div class="chat-attachment-file chat-attachment-file--pasted-text">
|
||||
@@ -692,18 +722,7 @@ export function renderAttachmentPreview(props: ChatAttachmentControlsProps) {
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<openclaw-tooltip
|
||||
.content=${att.fileName ?? t("chat.attachments.attachedFile")}
|
||||
>
|
||||
<div class="chat-attachment-file">
|
||||
<span class="chat-attachment-file__icon">${icons.paperclip}</span>
|
||||
<span class="chat-attachment-file__name"
|
||||
>${att.fileName ?? t("chat.attachments.attachedFile")}</span
|
||||
>
|
||||
</div>
|
||||
</openclaw-tooltip>
|
||||
`}
|
||||
: renderStandardFileAttachment(att)}
|
||||
<openclaw-tooltip .content=${t("chat.composer.removeAttachment")}>
|
||||
<button
|
||||
class="chat-attachment-remove"
|
||||
|
||||
@@ -78,6 +78,10 @@ function renderStatusPreview(remainingTasks: readonly TaskSummary[]): TemplateRe
|
||||
const overflow = tasks.length - preview.length;
|
||||
return html`
|
||||
<div slot="content" class="chat-tasks-preview">
|
||||
<div class="chat-tasks-preview__heading">
|
||||
<span>${t("chat.backgroundTasks.title")}</span>
|
||||
<span>${t("chat.backgroundTasks.running", { count: String(active.length) })}</span>
|
||||
</div>
|
||||
${preview.map((task) => renderStatusPreviewRow(task))}
|
||||
${overflow > 0
|
||||
? html`<div class="chat-tasks-preview__more">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type QuotaBudgetSummary,
|
||||
type QuotaLimitSummary,
|
||||
} from "../../../lib/provider-quota-summary.ts";
|
||||
import { handleChatComposerDetailsToggle } from "./chat-picker-overlay.ts";
|
||||
|
||||
const CONTEXT_NOTICE_RATIO = 0.85;
|
||||
const CONTEXT_COMPACT_RATIO = 0.9;
|
||||
@@ -384,7 +385,7 @@ export function renderContextNotice(
|
||||
class="context-usage"
|
||||
style=${model ? `--ctx-color:${model.color};--ctx-bg:${model.bg}` : ""}
|
||||
>
|
||||
<details>
|
||||
<details @toggle=${handleChatComposerDetailsToggle}>
|
||||
<summary
|
||||
class="context-ring ${model?.warning ? "context-ring--warning" : ""}"
|
||||
aria-label=${summary}
|
||||
|
||||
@@ -49,11 +49,35 @@ type MicrophonePickerProps = {
|
||||
selectedDeviceId: string;
|
||||
voiceActive: boolean;
|
||||
issue: RealtimeTalkDeviceIssue | null;
|
||||
holdToDictate?: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onSelect: (deviceId: string) => void;
|
||||
onHoldToDictateChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drops focus from the device picker's trigger once a device has been chosen.
|
||||
* The dropdown restores focus there on close, which is right for a normal
|
||||
* trigger and wrong for this one: it is revealed by hover, so a focused trigger
|
||||
* keeps the microphone expanded after the pointer has moved on. Deferred to the
|
||||
* next task because the dropdown restores focus as part of closing.
|
||||
*/
|
||||
function releaseMicrophonePickerFocus(dropdown: EventTarget | null, item: HTMLElement): void {
|
||||
if (item.matches(":focus-visible")) {
|
||||
return;
|
||||
}
|
||||
if (!(dropdown instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const trigger = dropdown.querySelector<HTMLElement>(".chat-talk-input-picker__trigger");
|
||||
if (trigger && document.activeElement === trigger) {
|
||||
trigger.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function renderMicrophonePicker(props: MicrophonePickerProps) {
|
||||
// Discovery reporting an issue with nothing enumerated is the browser stating
|
||||
// there is no capture route at all: a "System default" row would claim a
|
||||
@@ -81,8 +105,10 @@ export function renderMicrophonePicker(props: MicrophonePickerProps) {
|
||||
.open=${props.open}
|
||||
@wa-show=${props.onOpen}
|
||||
@wa-hide=${props.onClose}
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) =>
|
||||
props.onSelect(event.detail.item.value ?? "")}
|
||||
@wa-select=${(event: CustomEvent<{ item: HTMLElement & { value?: string } }>) => {
|
||||
props.onSelect(event.detail.item.value ?? "");
|
||||
releaseMicrophonePickerFocus(event.currentTarget, event.detail.item);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
@@ -119,6 +145,9 @@ export function renderMicrophonePicker(props: MicrophonePickerProps) {
|
||||
aria-checked=${String(selected)}
|
||||
${ref((element) => syncDropdownItemRadio(element, selected))}
|
||||
>
|
||||
<span slot="icon" class="chat-talk-input-picker__option-icon" aria-hidden="true"
|
||||
>${icons.mic}</span
|
||||
>
|
||||
<span class="chat-talk-input-picker__label">${option.label}</span>
|
||||
<span slot="details" class="chat-talk-input-picker__check" aria-hidden="true"
|
||||
>${selected ? icons.check : nothing}</span
|
||||
@@ -142,19 +171,62 @@ export function renderMicrophonePicker(props: MicrophonePickerProps) {
|
||||
</div>`
|
||||
: nothing}
|
||||
`}
|
||||
${props.onHoldToDictateChange
|
||||
? html`
|
||||
<div class="chat-talk-input-picker__preference">
|
||||
<span>${t("chat.composer.holdToDictate")}</span>
|
||||
<button
|
||||
class="chat-controls__speed-toggle ${props.holdToDictate !== false
|
||||
? "chat-controls__speed-toggle--active"
|
||||
: ""}"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked=${props.holdToDictate !== false ? "true" : "false"}
|
||||
aria-label=${t("chat.composer.holdToDictate")}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
props.onHoldToDictateChange?.(props.holdToDictate === false);
|
||||
}}
|
||||
>
|
||||
<span class="chat-controls__speed-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</wa-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderComposerVoiceButton(props: ChatRunControlsProps) {
|
||||
/**
|
||||
* What the microphone control itself reads. Narrower than the chat run controls
|
||||
* on purpose: the new-session composer offers the same control without a run to
|
||||
* abort, a send action, or a Talk session to toggle.
|
||||
*/
|
||||
type ComposerVoiceButtonProps = {
|
||||
connected: boolean;
|
||||
sending: boolean;
|
||||
isBusy: boolean;
|
||||
dictation?: ComposerDictationController;
|
||||
microphonePicker?: TemplateResult | typeof nothing;
|
||||
/**
|
||||
* What the control offers at rest. The chat composer's microphone also starts
|
||||
* Talk, so it promises voice input; a surface that only dictates says so
|
||||
* rather than offering something it cannot start.
|
||||
*/
|
||||
idleLabel?: string;
|
||||
onDictationPointerDown?: (event: PointerEvent) => void;
|
||||
onToggleVoice?: () => void;
|
||||
};
|
||||
|
||||
export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) {
|
||||
const active = props.dictation?.active === true;
|
||||
const finalizing = props.dictation?.finalizing === true;
|
||||
const holding = props.dictation?.locksComposer === true;
|
||||
const label = finalizing
|
||||
? t("chat.composer.dictationFinalizing")
|
||||
: active
|
||||
? t("chat.composer.dictationReleaseToInsert")
|
||||
: t("chat.composer.startVoiceInput");
|
||||
? t("chat.composer.dictationStop")
|
||||
: (props.idleLabel ?? t("chat.composer.startVoiceInput"));
|
||||
const tooltip =
|
||||
props.dictation && !(active || finalizing) ? t("chat.composer.voiceGestureHint") : label;
|
||||
// This shape owns pointer capture. Keep it stable while dictation rerenders,
|
||||
@@ -168,8 +240,18 @@ function renderComposerVoiceButton(props: ChatRunControlsProps) {
|
||||
: `chat-send-btn chat-send-btn--voice${props.dictation ? " chat-send-btn--hold-enabled" : ""}`}
|
||||
type="button"
|
||||
@pointerdown=${(event: PointerEvent) => props.onDictationPointerDown?.(event)}
|
||||
@click=${(event: MouseEvent) =>
|
||||
props.dictation ? props.dictation.handleClick(event) : props.onToggleVoice?.()}
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (active) {
|
||||
// Stop keeps the words. Escape remains the explicit discard path.
|
||||
void props.dictation?.finishActive();
|
||||
return;
|
||||
}
|
||||
if (props.dictation) {
|
||||
props.dictation.handleClick(event);
|
||||
} else {
|
||||
props.onToggleVoice?.();
|
||||
}
|
||||
}}
|
||||
@contextmenu=${(event: MouseEvent) => props.dictation?.handleContextMenu(event)}
|
||||
?disabled=${finalizing ||
|
||||
(!active && (!props.connected || props.sending || props.isBusy))}
|
||||
@@ -178,13 +260,7 @@ function renderComposerVoiceButton(props: ChatRunControlsProps) {
|
||||
${finalizing
|
||||
? icons.loader
|
||||
: active
|
||||
? html`
|
||||
${renderMicrophoneActivity({
|
||||
status: props.dictation?.connecting ? "connecting" : "listening",
|
||||
inputLevel: props.dictation?.inputLevel,
|
||||
})}
|
||||
<span class="chat-send-btn__dictation-time">${props.dictation?.elapsed}</span>
|
||||
`
|
||||
? icons.stop
|
||||
: html`
|
||||
${icons.mic}
|
||||
<span class="agent-chat__control-label">${label}</span>
|
||||
@@ -246,50 +322,73 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
|
||||
// duplicate announcement.
|
||||
const voiceErrored = props.voiceStatus === "error";
|
||||
const voiceButton = renderComposerVoiceButton(props);
|
||||
// Dictation and Talk are one affordance to the operator — a microphone — so
|
||||
// the control shows whenever either route exists, and it always sits ahead of
|
||||
// the primary action rather than standing in for it.
|
||||
const voiceControl = props.dictation || props.onToggleVoice ? voiceButton : nothing;
|
||||
// Send holds the trailing edge whatever the draft is. During an active run the
|
||||
// same slot shows stop while empty, then becomes the follow-up action as soon
|
||||
// as the operator composes content; two competing primary buttons never render.
|
||||
const sendAction = html`
|
||||
<openclaw-tooltip .content=${activeRunActionTooltip}>
|
||||
<openclaw-tooltip
|
||||
.content=${props.sending
|
||||
? t("chat.composer.sendingMessage")
|
||||
: hasComposedContent
|
||||
? activeRunActionTooltip
|
||||
: t("chat.composer.emptyHint")}
|
||||
>
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
class="chat-send-btn chat-send-btn--send${props.sending ? " chat-send-btn--sending" : ""}"
|
||||
@pointerdown=${props.onPrimaryActionPointerDown}
|
||||
@click=${send}
|
||||
?disabled=${!props.canSend || props.sending}
|
||||
aria-label=${activeRunActionDescription}
|
||||
?disabled=${!props.canSend || props.sending || !hasComposedContent}
|
||||
aria-label=${props.sending
|
||||
? t("chat.composer.sendingMessage")
|
||||
: hasComposedContent
|
||||
? activeRunActionDescription
|
||||
: t("chat.composer.emptyHint")}
|
||||
aria-busy=${props.sending ? "true" : "false"}
|
||||
>
|
||||
${icons.arrowUp}
|
||||
${props.sending
|
||||
? html`<span class="btn__spinner" aria-hidden="true"></span>`
|
||||
: icons.arrowUp}
|
||||
<span class="agent-chat__control-label">${activeRunActionLabel}</span>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`;
|
||||
const emptySendDescription = props.canSend
|
||||
? t("chat.composer.emptyHint")
|
||||
: t("chat.runControls.sendMessage");
|
||||
const idleAction = html`
|
||||
<openclaw-tooltip .content=${props.canSend ? emptySendDescription : t("chat.runControls.send")}>
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
@pointerdown=${props.onPrimaryActionPointerDown}
|
||||
@click=${send}
|
||||
disabled
|
||||
aria-label=${emptySendDescription}
|
||||
>
|
||||
${icons.arrowUp}
|
||||
<span class="agent-chat__control-label">${t("chat.runControls.send")}</span>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`;
|
||||
const stopAction = html`
|
||||
<openclaw-tooltip .content=${t("chat.runControls.stopWithShortcut")}>
|
||||
<button
|
||||
class="chat-send-btn chat-send-btn--stop"
|
||||
@pointerdown=${props.onPrimaryActionPointerDown}
|
||||
@click=${props.onAbort}
|
||||
aria-label=${t("chat.runControls.stopGenerating")}
|
||||
>
|
||||
${icons.stop}
|
||||
<span class="agent-chat__control-label">${t("chat.runControls.stop")}</span>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`;
|
||||
const dictationSendAction = props.dictation?.active
|
||||
? html`
|
||||
<span class="sr-only" role="status" aria-live="polite" aria-atomic="true"
|
||||
>${props.dictation.finalizing
|
||||
? t("chat.composer.dictationFinalizing")
|
||||
: props.dictation.connecting
|
||||
? t("chat.composer.dictationConnecting")
|
||||
: t("chat.composer.dictationRecording", {
|
||||
elapsed: props.dictation.elapsed,
|
||||
})}</span
|
||||
>
|
||||
<openclaw-tooltip .content=${t("chat.runControls.sendMessage")}>
|
||||
<button
|
||||
class="chat-send-btn chat-send-btn--send chat-send-btn--dictation-send"
|
||||
type="button"
|
||||
@pointerdown=${props.onPrimaryActionPointerDown}
|
||||
@click=${() => {
|
||||
// Only the dictation session that actually committed text owns
|
||||
// this send; an empty or stale finalization leaves the draft alone.
|
||||
void props.dictation?.finishActive().then((committed) => {
|
||||
if (committed) {
|
||||
props.onSend();
|
||||
}
|
||||
});
|
||||
}}
|
||||
?disabled=${props.dictation.finalizing}
|
||||
aria-label=${t("chat.runControls.sendMessage")}
|
||||
>
|
||||
${icons.arrowUp}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing;
|
||||
return html`
|
||||
${props.voiceActive && props.onToggleVoice
|
||||
? html`
|
||||
@@ -355,14 +454,14 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
|
||||
${abortAction}
|
||||
`
|
||||
: html`
|
||||
${props.dictation || props.onToggleVoice ? voiceButton : nothing}
|
||||
${voiceControl}
|
||||
${props.dictation?.active
|
||||
? nothing
|
||||
: hasComposedContent
|
||||
? sendAction
|
||||
: props.canAbort
|
||||
? stopAction
|
||||
: idleAction}
|
||||
? dictationSendAction
|
||||
: props.canAbort
|
||||
? hasComposedContent
|
||||
? sendAction
|
||||
: abortAction
|
||||
: sendAction}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [
|
||||
].join(",");
|
||||
|
||||
type ComposerTextareaResizeObserverState = {
|
||||
observer: ResizeObserver;
|
||||
observer: ResizeObserver | null;
|
||||
adjustmentFrame: number | null;
|
||||
onScroll: () => void;
|
||||
};
|
||||
|
||||
type ComposerPopoverAnchorObserverState = {
|
||||
@@ -124,7 +125,15 @@ export function replaceComposerPopoverAnchor(
|
||||
}
|
||||
|
||||
function updateTextareaOverflow(el: HTMLTextAreaElement) {
|
||||
el.style.overflowY = el.scrollHeight > el.clientHeight ? "auto" : "hidden";
|
||||
const scrollable = el.scrollHeight > el.clientHeight + 1;
|
||||
// Two 16px fades need enough vertical runway not to overlap into a narrow
|
||||
// opaque strip on short drafts. Small overflows still scroll, just unfaded.
|
||||
const canFade = scrollable && el.clientHeight >= 64;
|
||||
const fadeTop = canFade && el.scrollTop > 1;
|
||||
const fadeBottom = canFade && el.scrollTop + el.clientHeight < el.scrollHeight - 1;
|
||||
el.style.overflowY = scrollable ? "auto" : "hidden";
|
||||
el.toggleAttribute("data-scroll-fade-top", fadeTop);
|
||||
el.toggleAttribute("data-scroll-fade-bottom", fadeBottom);
|
||||
}
|
||||
|
||||
export function adjustTextareaHeight(el: HTMLTextAreaElement) {
|
||||
@@ -151,29 +160,35 @@ export function adjustTextareaHeight(el: HTMLTextAreaElement) {
|
||||
}
|
||||
|
||||
export function observeTextareaOverflow(el: HTMLTextAreaElement) {
|
||||
if (typeof ResizeObserver !== "function" || composerTextareaResizeObservers.has(el)) {
|
||||
if (composerTextareaResizeObservers.has(el)) {
|
||||
return;
|
||||
}
|
||||
let width = el.getBoundingClientRect().width;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = el.getBoundingClientRect().width;
|
||||
if (nextWidth !== width) {
|
||||
width = nextWidth;
|
||||
const state = composerTextareaResizeObservers.get(el);
|
||||
if (state && state.adjustmentFrame === null) {
|
||||
state.adjustmentFrame = requestAnimationFrame(() => {
|
||||
state.adjustmentFrame = null;
|
||||
if (composerTextareaResizeObservers.get(el) === state) {
|
||||
adjustTextareaHeight(el);
|
||||
const onScroll = () => updateTextareaOverflow(el);
|
||||
const observer =
|
||||
typeof ResizeObserver === "function"
|
||||
? new ResizeObserver(() => {
|
||||
const nextWidth = el.getBoundingClientRect().width;
|
||||
if (nextWidth !== width) {
|
||||
width = nextWidth;
|
||||
const state = composerTextareaResizeObservers.get(el);
|
||||
if (state && state.adjustmentFrame === null) {
|
||||
state.adjustmentFrame = requestAnimationFrame(() => {
|
||||
state.adjustmentFrame = null;
|
||||
if (composerTextareaResizeObservers.get(el) === state) {
|
||||
adjustTextareaHeight(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateTextareaOverflow(el);
|
||||
});
|
||||
observer.observe(el);
|
||||
composerTextareaResizeObservers.set(el, { observer, adjustmentFrame: null });
|
||||
updateTextareaOverflow(el);
|
||||
})
|
||||
: null;
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
observer?.observe(el);
|
||||
composerTextareaResizeObservers.set(el, { observer, adjustmentFrame: null, onScroll });
|
||||
updateTextareaOverflow(el);
|
||||
}
|
||||
|
||||
export function disconnectTextareaOverflowObserver(el: HTMLTextAreaElement) {
|
||||
@@ -182,7 +197,8 @@ export function disconnectTextareaOverflowObserver(el: HTMLTextAreaElement) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.observer.disconnect();
|
||||
state.observer?.disconnect();
|
||||
el.removeEventListener("scroll", state.onScroll);
|
||||
if (state.adjustmentFrame !== null) {
|
||||
cancelAnimationFrame(state.adjustmentFrame);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { html, nothing, svg, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { SessionGoal } from "../../../api/types.ts";
|
||||
import { strokeIcon } from "../../../components/icons-tools.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import {
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
import type { ChatComposerState } from "./chat-composer-types.ts";
|
||||
|
||||
const goalElapsedTimers = new Map<HTMLElement, ReturnType<typeof setInterval>>();
|
||||
const goalIcon = strokeIcon(svg` <path d="M12 13V2l8 4-8 4" />
|
||||
<path d="M20.561 10.222a9 9 0 1 1-12.55-5.29" />
|
||||
<path d="M8.002 9.997a5 5 0 1 0 8.9 2.02" />`);
|
||||
|
||||
function clearGoalElapsedTimer(el: HTMLElement) {
|
||||
const timer = goalElapsedTimers.get(el);
|
||||
@@ -35,6 +39,7 @@ function createGoalElapsedRef(goal: SessionGoal) {
|
||||
return;
|
||||
}
|
||||
bound = element;
|
||||
element.textContent = formatGoalElapsed(goalElapsedMs(goal, Date.now()));
|
||||
const timer = setInterval(() => {
|
||||
// Tests and detached renders can drop the pill without a final ref call.
|
||||
if (!element.isConnected) {
|
||||
@@ -98,14 +103,17 @@ export function renderChatGoal(
|
||||
return html`
|
||||
<div
|
||||
class="agent-chat__goal agent-chat__goal--${goal.status}"
|
||||
data-expanded=${String(expanded)}
|
||||
role="group"
|
||||
aria-label=${formatGoalDetail(goal)}
|
||||
>
|
||||
<div class="agent-chat__goal-row">
|
||||
<span class="agent-chat__goal-icon">${icons.target}</span>
|
||||
<span class="agent-chat__goal-label">${formatGoalStatusLabel(goal.status)}</span>
|
||||
<span class="agent-chat__goal-objective">${goal.objective}</span>
|
||||
<span class="agent-chat__goal-elapsed" ${ref(createGoalElapsedRef(goal))}>${elapsed}</span>
|
||||
<span class="agent-chat__goal-icon">${goalIcon}</span>
|
||||
<span class="agent-chat__goal-copy">
|
||||
<span class="agent-chat__goal-label">${formatGoalStatusLabel(goal.status)}</span>
|
||||
<span class="agent-chat__goal-objective">${goal.objective}</span>
|
||||
</span>
|
||||
<span class="agent-chat__goal-elapsed" ${ref(createGoalElapsedRef(goal))}></span>
|
||||
<span class="agent-chat__goal-actions">
|
||||
${showActions && actions.onGoalEdit && goal.status !== "complete"
|
||||
? renderChatGoalActionButton({
|
||||
@@ -150,19 +158,22 @@ export function renderChatGoal(
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
${expanded
|
||||
? html`
|
||||
<div class="agent-chat__goal-detail">
|
||||
<div class="agent-chat__goal-detail-objective">${goal.objective}</div>
|
||||
${goal.lastStatusNote
|
||||
? html`<div class="agent-chat__goal-detail-note">${goal.lastStatusNote}</div>`
|
||||
: nothing}
|
||||
<div class="agent-chat__goal-detail-meta">
|
||||
${usage ? `${usage} · ${elapsed}` : elapsed}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div
|
||||
class="agent-chat__goal-detail"
|
||||
data-expanded=${String(expanded)}
|
||||
aria-hidden=${String(!expanded)}
|
||||
?inert=${!expanded}
|
||||
>
|
||||
<div class="agent-chat__goal-detail-content">
|
||||
<div class="agent-chat__goal-detail-objective">${goal.objective}</div>
|
||||
${goal.lastStatusNote
|
||||
? html`<div class="agent-chat__goal-detail-note">${goal.lastStatusNote}</div>`
|
||||
: nothing}
|
||||
<div class="agent-chat__goal-detail-meta">
|
||||
${usage ? `${usage} · ${elapsed}` : elapsed}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ChatComposerMenuSkill = {
|
||||
type ChatComposerRootToggle = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: unknown;
|
||||
icon?: TemplateResult;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
title?: string;
|
||||
@@ -124,7 +124,7 @@ function renderCapabilityToggleRow(options: {
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
title: string | null | undefined;
|
||||
icon?: unknown;
|
||||
icon?: TemplateResult;
|
||||
note?: TemplateResult | typeof nothing;
|
||||
}) {
|
||||
return html`
|
||||
|
||||
@@ -24,12 +24,7 @@ function createChatComposerState(): ChatComposerState {
|
||||
restoreComposerFocus: false,
|
||||
composerInput: null,
|
||||
composerTextarea: null,
|
||||
microphonePickerOpen: false,
|
||||
microphonePickerLoading: false,
|
||||
microphoneDevices: [],
|
||||
microphoneIssue: null,
|
||||
microphoneDeviceWatch: null,
|
||||
microphoneDiscoveryRequest: 0,
|
||||
microphonePicker: null,
|
||||
capabilityMenuOpen: false,
|
||||
capabilityMenuView: "root",
|
||||
textareaRef: null,
|
||||
@@ -139,10 +134,12 @@ export function suppressStaleSubmittedDraftReplay(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drops the devicechange subscription so a closed picker stops refreshing. */
|
||||
export function releaseMicrophoneDeviceWatch(state: ChatComposerState) {
|
||||
state.microphoneDeviceWatch?.();
|
||||
state.microphoneDeviceWatch = null;
|
||||
function disposeChatComposerState(state: ChatComposerState) {
|
||||
state.dictation?.dispose();
|
||||
state.microphonePicker?.dispose();
|
||||
if (state.composerInput) {
|
||||
disconnectComposerPopoverAnchorObserver(state.composerInput);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetChatComposerState(paneId?: string) {
|
||||
@@ -150,22 +147,14 @@ export function resetChatComposerState(paneId?: string) {
|
||||
// Goal elapsed timers are keyed by element and cleaned up when their
|
||||
// element leaves the DOM, so a per-pane reset does not need to touch them.
|
||||
const paneState = composerStates.get(paneId);
|
||||
paneState?.dictation?.dispose();
|
||||
if (paneState) {
|
||||
if (paneState.composerInput) {
|
||||
disconnectComposerPopoverAnchorObserver(paneState.composerInput);
|
||||
}
|
||||
releaseMicrophoneDeviceWatch(paneState);
|
||||
disposeChatComposerState(paneState);
|
||||
}
|
||||
composerStates.delete(paneId);
|
||||
return;
|
||||
}
|
||||
for (const state of composerStates.values()) {
|
||||
state.dictation?.dispose();
|
||||
if (state.composerInput) {
|
||||
disconnectComposerPopoverAnchorObserver(state.composerInput);
|
||||
}
|
||||
releaseMicrophoneDeviceWatch(state);
|
||||
disposeChatComposerState(state);
|
||||
}
|
||||
composerStates.clear();
|
||||
clearGoalElapsedTimers();
|
||||
|
||||
@@ -1,44 +1,11 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import type { ChatRunUiStatus } from "../run-lifecycle.ts";
|
||||
import { CHAT_RUN_STATUS_TOAST_DURATION_MS } from "../run-lifecycle.ts";
|
||||
import type { CompactionStatus, FallbackStatus } from "../tool-stream.ts";
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
const FALLBACK_TOAST_DURATION_MS = 8000;
|
||||
|
||||
export type ComposerRunStatus =
|
||||
| ChatRunUiStatus
|
||||
| {
|
||||
phase: "in-progress";
|
||||
occurredAt?: number | null;
|
||||
};
|
||||
|
||||
// Working and Done need no composer chrome: the thread's working spark,
|
||||
// content arriving, and Stop reverting to Send already show them (screen
|
||||
// readers get the composer's persistent sr-only run-status region).
|
||||
// Interrupted keeps a visible toast: the transcript shows nothing when a run
|
||||
// is killed, so silence would read as "finished".
|
||||
export function renderChatRunStatusIndicator(status: ComposerRunStatus | null | undefined) {
|
||||
if (status?.phase !== "interrupted") {
|
||||
return nothing;
|
||||
}
|
||||
const elapsed = Date.now() - status.occurredAt;
|
||||
if (elapsed >= CHAT_RUN_STATUS_TOAST_DURATION_MS) {
|
||||
return nothing;
|
||||
}
|
||||
const interrupted = t("chat.composer.runInterrupted");
|
||||
return html`
|
||||
<span
|
||||
class="agent-chat__run-status agent-chat__run-status--interrupted"
|
||||
aria-label=${t("chat.composer.runStatus", { status: interrupted })}
|
||||
>
|
||||
${icons.stop}<span class="agent-chat__run-status-label">${interrupted}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderCompactionIndicator(status: CompactionStatus | null | undefined) {
|
||||
if (!status) {
|
||||
return nothing;
|
||||
|
||||
@@ -9,13 +9,10 @@ import type { ControlUiFollowUpMode } from "../../../lib/chat/follow-up-mode.ts"
|
||||
import type { ProviderUsageDisplayProps } from "../../../lib/provider-quota-summary.ts";
|
||||
import type { SessionToolOverrides } from "../../../lib/sessions/patch.ts";
|
||||
import type { ComposerDictationController } from "../composer-dictation.ts";
|
||||
import type { ComposerMicrophonePicker } from "../composer-microphone-picker.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
|
||||
import type {
|
||||
RealtimeTalkCameraDevice,
|
||||
RealtimeTalkDeviceIssue,
|
||||
RealtimeTalkInputDevice,
|
||||
} from "../realtime-talk-input.ts";
|
||||
import type { RealtimeTalkCameraDevice } from "../realtime-talk-input.ts";
|
||||
import type { RealtimeTalkLevelSignal } from "../realtime-talk-level.ts";
|
||||
import type { RealtimeTalkStatus } from "../realtime-talk.ts";
|
||||
import type { ChatRunUiStatus } from "../run-lifecycle.ts";
|
||||
@@ -46,7 +43,7 @@ type ChatComposerDisabledBannerContent = {
|
||||
title?: string;
|
||||
text: string;
|
||||
tone?: "info" | "neutral";
|
||||
icon?: "warning";
|
||||
icon?: "warning" | "archive";
|
||||
actionLabel: string;
|
||||
actionStyle?: "primary";
|
||||
busy?: boolean;
|
||||
@@ -67,6 +64,7 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
queuedOutboxCount?: number;
|
||||
canSend: boolean;
|
||||
disabledReason: string | null;
|
||||
disabledReasonTone?: "info" | "danger";
|
||||
disabledBanner?: ChatComposerDisabledBanner;
|
||||
runError?: { summary: string } | null;
|
||||
sending: boolean;
|
||||
@@ -111,7 +109,9 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
realtimeTalkCameraError?: boolean;
|
||||
gatewayClient?: GatewayBrowserClient | null;
|
||||
composerHoldToRecord?: boolean;
|
||||
onComposerHoldToRecordChange?: (enabled: boolean) => void;
|
||||
suggestionComposer?: boolean;
|
||||
typingActors?: readonly { id: string; label: string; preview?: string }[];
|
||||
onTypingChange?: (typing: boolean, preview?: string) => void;
|
||||
composerControls?: TemplateResult | typeof nothing;
|
||||
permissionPicker?: ChatPermissionPickerProps;
|
||||
@@ -161,13 +161,7 @@ export type ChatComposerState = SkillMenuState &
|
||||
restoreComposerFocus: boolean;
|
||||
composerInput: HTMLElement | null;
|
||||
composerTextarea: HTMLTextAreaElement | null;
|
||||
microphonePickerOpen: boolean;
|
||||
microphonePickerLoading: boolean;
|
||||
microphoneDevices: RealtimeTalkInputDevice[];
|
||||
microphoneIssue: RealtimeTalkDeviceIssue | null;
|
||||
/** Unsubscribe for the devicechange watch; non-null only while the picker is open. */
|
||||
microphoneDeviceWatch: (() => void) | null;
|
||||
microphoneDiscoveryRequest: number;
|
||||
microphonePicker: ComposerMicrophonePicker | null;
|
||||
capabilityMenuOpen: boolean;
|
||||
capabilityMenuView: ChatComposerPlusMenuView;
|
||||
// Stable Lit refs: inline arrows would change identity per render and force
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { html, nothing, svg, type TemplateResult } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { GatewaySessionRow } from "../../../api/types.ts";
|
||||
import { strokeIcon } from "../../../components/icons-tools.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { renderSessionProgressCard } from "../../../components/session-progress-card.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "../../../lib/sessions/tool-overrides.ts";
|
||||
import { detectTextDirection } from "../../../lib/text-direction.ts";
|
||||
import type { ComposerDictationController } from "../composer-dictation.ts";
|
||||
import { insertComposerDictation } from "../composer-dictation.ts";
|
||||
import {
|
||||
handleChatAttachmentPaste,
|
||||
renderAttachmentPreview,
|
||||
@@ -26,16 +28,15 @@ import { renderChatQueue } from "./chat-composer-queue.ts";
|
||||
import { renderSkillMenu, type SkillMenuHost } from "./chat-composer-skill-menu.ts";
|
||||
import { renderSlashMenu, type SlashMenuHost } from "./chat-composer-slash-menu.ts";
|
||||
import { commitComposerDraft } from "./chat-composer-state.ts";
|
||||
import {
|
||||
renderChatRunStatusIndicator,
|
||||
renderCompactionIndicator,
|
||||
renderFallbackIndicator,
|
||||
type ComposerRunStatus,
|
||||
} from "./chat-composer-status.ts";
|
||||
import { renderCompactionIndicator, renderFallbackIndicator } from "./chat-composer-status.ts";
|
||||
import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts";
|
||||
import { renderChatPermissionPicker } from "./chat-permission-picker.ts";
|
||||
import {
|
||||
handleChatComposerDropdownShow,
|
||||
markPointerOpenedChatComposerDropdown,
|
||||
restorePointerOpenedChatComposerTrigger,
|
||||
} from "./chat-picker-overlay.ts";
|
||||
import type { createGatewayQuestionPanelProps } from "./chat-question-card.ts";
|
||||
import { renderChatVoiceError } from "./chat-voice-activity.ts";
|
||||
import { renderChatVoiceError, renderMicrophoneActivity } from "./chat-voice-activity.ts";
|
||||
|
||||
type ChatComposerViewContext = {
|
||||
props: ChatComposerProps;
|
||||
@@ -46,6 +47,7 @@ type ChatComposerViewContext = {
|
||||
visibleDraft: string;
|
||||
contextNotice: TemplateResult | typeof nothing;
|
||||
composerControls: TemplateResult | typeof nothing;
|
||||
composerLeadControl: TemplateResult | typeof nothing;
|
||||
runStatusAnnouncement: string;
|
||||
requestUpdate: () => void;
|
||||
sendShortcut: "enter" | "modifier-enter";
|
||||
@@ -70,9 +72,18 @@ type ChatComposerViewContext = {
|
||||
activeSlashMenuOptionLabel: string;
|
||||
slashMenuListboxId: string;
|
||||
slashMenuAnnouncementId: string;
|
||||
composerRunStatus: ComposerRunStatus | null | undefined;
|
||||
};
|
||||
|
||||
const globeOffIcon = strokeIcon(svg` <path
|
||||
d="M10.114 4.462A14.5 14.5 0 0 1 12 2a10 10 0 0 1 9.313 13.643"
|
||||
/>
|
||||
<path d="M15.557 15.556A14.5 14.5 0 0 1 12 22 10 10 0 0 1 4.929 4.929" />
|
||||
<path d="M15.892 10.234A14.5 14.5 0 0 0 12 2a10 10 0 0 0-3.643.687" />
|
||||
<path d="M17.656 12H22" />
|
||||
<path d="M19.071 19.071A10 10 0 0 1 12 22 14.5 14.5 0 0 1 8.44 8.45" />
|
||||
<path d="M2 12h10" />
|
||||
<path d="m2 2 20 20" />`);
|
||||
|
||||
export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
const {
|
||||
props,
|
||||
@@ -83,6 +94,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
visibleDraft,
|
||||
contextNotice,
|
||||
composerControls,
|
||||
composerLeadControl,
|
||||
runStatusAnnouncement,
|
||||
requestUpdate,
|
||||
sendShortcut,
|
||||
@@ -107,19 +119,25 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
activeSlashMenuOptionLabel,
|
||||
slashMenuListboxId,
|
||||
slashMenuAnnouncementId,
|
||||
composerRunStatus,
|
||||
} = context;
|
||||
const disabledBanner = props.disabledBanner
|
||||
? html`
|
||||
<div
|
||||
class="agent-chat__disabled-banner callout ${props.disabledBanner.tone === "neutral"
|
||||
class="agent-chat__disabled-banner ${props.disabledBanner.kind === "composer-replacement"
|
||||
? "agent-chat__disabled-banner--replacement"
|
||||
: ""} callout ${props.disabledBanner.tone === "neutral"
|
||||
? "agent-chat__disabled-banner--neutral"
|
||||
: "info"} callout--action"
|
||||
role="status"
|
||||
>
|
||||
${props.disabledBanner.icon === "warning"
|
||||
? html`<span class="agent-chat__disabled-banner-icon" aria-hidden="true"
|
||||
>${icons.alertTriangle}</span
|
||||
${props.disabledBanner.icon
|
||||
? html`<span
|
||||
class="agent-chat__disabled-banner-icon agent-chat__disabled-banner-icon--${props
|
||||
.disabledBanner.icon}"
|
||||
aria-hidden="true"
|
||||
>${props.disabledBanner.icon === "archive"
|
||||
? icons.archive
|
||||
: icons.alertTriangle}</span
|
||||
>`
|
||||
: nothing}
|
||||
<div class="callout__content">
|
||||
@@ -153,13 +171,90 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
if (!props.capabilityMenu) {
|
||||
state.capabilityMenuView = "root";
|
||||
}
|
||||
const disabledReasonId = paneDomId(props.paneId, "disabled-reason");
|
||||
const overrideCount = countSessionToolOverrides(props.toolOverrides);
|
||||
const overrideTooltip = sessionToolOverrideNames(
|
||||
props.toolOverrides,
|
||||
t("chat.composer.menu.webSearch"),
|
||||
).join(", ");
|
||||
const disabledReasonId = paneDomId(props.paneId, "disabled-reason");
|
||||
|
||||
const voiceError = showComposerInput
|
||||
? renderChatVoiceError({
|
||||
status: props.realtimeTalkCameraError ? "error" : props.realtimeTalkStatus,
|
||||
detail: props.realtimeTalkDetail,
|
||||
onDismissError: props.realtimeTalkCameraError
|
||||
? undefined
|
||||
: props.onDismissRealtimeTalkError,
|
||||
})
|
||||
: nothing;
|
||||
const composerAlerts =
|
||||
voiceError !== nothing
|
||||
? html`<div class="agent-chat__composer-errors agent-chat__composer-errors--standalone">
|
||||
${voiceError}
|
||||
</div>`
|
||||
: nothing;
|
||||
const offlineText = props.offline
|
||||
? props.queuedOutboxCount
|
||||
? t("chat.composer.offlineQueuedHint", { count: String(props.queuedOutboxCount) })
|
||||
: t("chat.composer.offlineHint")
|
||||
: null;
|
||||
const primaryComposerStatus = props.disabledReason
|
||||
? {
|
||||
text: props.disabledReason,
|
||||
tone: props.disabledReasonTone ?? ("danger" as const),
|
||||
icon:
|
||||
(props.disabledReasonTone ?? "danger") === "danger"
|
||||
? icons.alertTriangle
|
||||
: icons.shieldQuestion,
|
||||
}
|
||||
: offlineText
|
||||
? { text: offlineText, tone: "warn" as const, icon: globeOffIcon }
|
||||
: null;
|
||||
const composerUnderlaps =
|
||||
showComposerInput && primaryComposerStatus
|
||||
? html`<div class="agent-chat__composer-underlaps" data-tone=${primaryComposerStatus.tone}>
|
||||
<div
|
||||
id=${props.disabledReason ? disabledReasonId : nothing}
|
||||
class="agent-chat__composer-status-band"
|
||||
role=${primaryComposerStatus.tone === "danger" ? "alert" : "status"}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="agent-chat__composer-status-icon" aria-hidden="true"
|
||||
>${primaryComposerStatus.icon}</span
|
||||
>
|
||||
<span class="agent-chat__composer-status-text">${primaryComposerStatus.text}</span>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing;
|
||||
// Dictation previews at the captured selection. The textarea remains
|
||||
// read-only until stop commits the same insertion into the real draft.
|
||||
const dictationPreviewDraft =
|
||||
dictation?.active && dictation.partial
|
||||
? insertComposerDictation(
|
||||
visibleDraft,
|
||||
dictation.partial,
|
||||
state.dictationSelection?.start ?? visibleDraft.length,
|
||||
state.dictationSelection?.end ?? visibleDraft.length,
|
||||
).value
|
||||
: visibleDraft;
|
||||
const progressCard = props.progressCard
|
||||
? html`<div class="agent-chat__progress-float">
|
||||
${renderSessionProgressCard(props.progressCard, "composer", props.onDismissProgressCard)}
|
||||
</div>`
|
||||
: nothing;
|
||||
const goalCard = activeSession?.goal
|
||||
? html`<div class="agent-chat__goal-float">
|
||||
${renderChatGoal(state, activeSession.goal, {
|
||||
canAct: props.connected && canCompose,
|
||||
onGoalCommand: props.onGoalCommand,
|
||||
onGoalEdit: (goal) => {
|
||||
commitComposerDraft(props, `/goal edit ${goal.objective}`);
|
||||
requestUpdate();
|
||||
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
|
||||
},
|
||||
requestUpdate,
|
||||
})}
|
||||
</div>`
|
||||
: nothing;
|
||||
return html`
|
||||
${renderChatQueue({
|
||||
queue: props.queue,
|
||||
@@ -177,14 +272,6 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
editingText: props.queuedEdit?.editingText,
|
||||
onQueueRemove: props.onQueueRemove,
|
||||
})}
|
||||
${props.runError
|
||||
? html`
|
||||
<div class="chat-run-error" role="alert">
|
||||
<span class="chat-run-error__icon" aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<span class="chat-run-error__summary">${props.runError.summary}</span>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="agent-chat__composer-shell">
|
||||
${questionPanelProps
|
||||
? html`
|
||||
@@ -195,300 +282,282 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${disabledBanner}
|
||||
${disabledBanner} ${progressCard} ${goalCard} ${composerAlerts}
|
||||
${showComposerInput
|
||||
? html`<div
|
||||
class="agent-chat__input ${props.offline ? "agent-chat__input--offline" : ""}"
|
||||
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
|
||||
@pointerdown=${(event: PointerEvent) => focusComposerFromChrome(event, canCompose)}
|
||||
${ref(state.composerInputRef ?? undefined)}
|
||||
>
|
||||
${props.offline
|
||||
? html`<div class="agent-chat__offline-hint" role="status" aria-live="polite">
|
||||
${props.queuedOutboxCount
|
||||
? t("chat.composer.offlineQueuedHint", {
|
||||
count: String(props.queuedOutboxCount),
|
||||
})
|
||||
: t("chat.composer.offlineHint")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${slashMenuVisible
|
||||
? renderSlashMenu(state, slashMenuHost, visibleDraft, requestUpdate)
|
||||
: nothing}
|
||||
${skillMenuVisible ? renderSkillMenu(state, skillMenuHost, requestUpdate) : nothing}
|
||||
${renderAttachmentPreview(props)}
|
||||
${props.replyTarget
|
||||
? html`
|
||||
<div class="chat-reply-preview">
|
||||
<span class="chat-reply-preview__icon">${icons.messageSquare}</span>
|
||||
<span class="chat-reply-preview__label"
|
||||
>${t("chat.messages.replyingTo", {
|
||||
name: props.replyTarget.senderLabel ?? t("chat.messages.message"),
|
||||
})}</span
|
||||
>
|
||||
<span class="chat-reply-preview__text"
|
||||
>${truncateUtf16Safe(props.replyTarget.text, 120)}${props.replyTarget.text
|
||||
.length > 120
|
||||
? "..."
|
||||
: ""}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-reply-preview__dismiss"
|
||||
@click=${() => props.onClearReply?.()}
|
||||
aria-label=${t("chat.composer.cancelReply")}
|
||||
title=${t("chat.composer.cancelReply")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="agent-chat__composer-status-stack">
|
||||
${dictation?.active
|
||||
? html`
|
||||
<div
|
||||
class=${`agent-chat__dictation-status${dictation.finalizing ? " agent-chat__dictation-status--finalizing" : ""}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<span class="agent-chat__dictation-label"
|
||||
>${dictation.finalizing
|
||||
? t("chat.composer.dictationFinalizing")
|
||||
: dictation.connecting
|
||||
? t("chat.composer.dictationConnecting")
|
||||
: t("chat.composer.dictationRecording", {
|
||||
elapsed: dictation.elapsed,
|
||||
})}</span
|
||||
>
|
||||
${dictation.partial
|
||||
? html`<span class="agent-chat__dictation-partial"
|
||||
>${dictation.partial}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
class="agent-chat__input agent-chat__input--chat ${props.offline
|
||||
? "agent-chat__input--offline"
|
||||
: ""}${dictation?.active ? " agent-chat__input--dictating" : ""}"
|
||||
@wa-show=${handleChatComposerDropdownShow}
|
||||
@wa-after-show=${restorePointerOpenedChatComposerTrigger}
|
||||
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
|
||||
@pointerdown=${(event: PointerEvent) => {
|
||||
markPointerOpenedChatComposerDropdown(event);
|
||||
focusComposerFromChrome(event, canCompose);
|
||||
}}
|
||||
${ref(state.composerInputRef ?? undefined)}
|
||||
>
|
||||
${slashMenuVisible
|
||||
? renderSlashMenu(state, slashMenuHost, visibleDraft, requestUpdate)
|
||||
: nothing}
|
||||
${renderSessionProgressCard(
|
||||
props.progressCard,
|
||||
"composer",
|
||||
props.onDismissProgressCard,
|
||||
)}
|
||||
${renderFallbackIndicator(props.fallbackStatus)}
|
||||
${renderCompactionIndicator(props.compactionStatus)}
|
||||
${renderChatGoal(state, activeSession?.goal, {
|
||||
canAct: props.connected && canCompose,
|
||||
onGoalCommand: props.onGoalCommand,
|
||||
onGoalEdit: (goal) => {
|
||||
commitComposerDraft(props, `/goal edit ${goal.objective}`);
|
||||
requestUpdate();
|
||||
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
|
||||
},
|
||||
requestUpdate,
|
||||
})}
|
||||
</div>
|
||||
|
||||
${renderChatAttachmentInputs({ ...props, disabled: !canCompose })}
|
||||
${renderChatVoiceError({
|
||||
status: props.realtimeTalkCameraError ? "error" : props.realtimeTalkStatus,
|
||||
detail: props.realtimeTalkDetail,
|
||||
onDismissError: props.realtimeTalkCameraError
|
||||
? undefined
|
||||
: props.onDismissRealtimeTalkError,
|
||||
})}
|
||||
${props.realtimeTalkVideoStream
|
||||
? html`
|
||||
<div class="agent-chat__video-preview">
|
||||
<video
|
||||
class=${mirrorCameraPreview ? "agent-chat__video-preview-mirrored" : nothing}
|
||||
autoplay
|
||||
.muted=${true}
|
||||
playsinline
|
||||
aria-label=${t("chat.composer.cameraPreview")}
|
||||
${ref((element) => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
element.srcObject = props.realtimeTalkVideoStream ?? null;
|
||||
}
|
||||
})}
|
||||
></video>
|
||||
${props.realtimeTalkCameraDevices &&
|
||||
props.realtimeTalkCameraDevices.length >= 2 &&
|
||||
props.onSwitchRealtimeCamera
|
||||
? html`
|
||||
<openclaw-tooltip
|
||||
class="agent-chat__video-preview-switch-tooltip"
|
||||
.content=${t("chat.composer.switchCamera")}
|
||||
${skillMenuVisible ? renderSkillMenu(state, skillMenuHost, requestUpdate) : nothing}
|
||||
<div class="agent-chat__composer-lede">
|
||||
${renderAttachmentPreview(props)}
|
||||
${props.replyTarget
|
||||
? html`
|
||||
<div class="chat-reply-preview">
|
||||
<span class="chat-reply-preview__icon">${icons.messageSquare}</span>
|
||||
<span class="chat-reply-preview__label"
|
||||
>${t("chat.messages.replyingTo", {
|
||||
name: props.replyTarget.senderLabel ?? t("chat.messages.message"),
|
||||
})}</span
|
||||
>
|
||||
<span class="chat-reply-preview__text"
|
||||
>${truncateUtf16Safe(props.replyTarget.text, 120)}${props.replyTarget.text
|
||||
.length > 120
|
||||
? "..."
|
||||
: ""}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-reply-preview__dismiss"
|
||||
@click=${() => props.onClearReply?.()}
|
||||
aria-label=${t("chat.composer.cancelReply")}
|
||||
title=${t("chat.composer.cancelReply")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="agent-chat__composer-status-stack">
|
||||
${dictation?.active
|
||||
? html`
|
||||
<div
|
||||
class=${`agent-chat__dictation-status${dictation.finalizing ? " agent-chat__dictation-status--finalizing" : ""}`}
|
||||
>
|
||||
<span class="agent-chat__dictation-wave">
|
||||
${renderMicrophoneActivity({
|
||||
status: dictation.connecting ? "connecting" : "listening",
|
||||
inputLevel: dictation.inputLevel,
|
||||
bars: 48,
|
||||
mode: "scroll",
|
||||
})}
|
||||
</span>
|
||||
<span class="agent-chat__dictation-elapsed" aria-hidden="true"
|
||||
>${dictation.elapsed}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-chat__video-preview-switch"
|
||||
aria-label=${t("chat.composer.switchCamera")}
|
||||
?disabled=${props.realtimeTalkVideoPending}
|
||||
@click=${props.onSwitchRealtimeCamera}
|
||||
>
|
||||
${icons.switchCamera}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${props.disabledReason
|
||||
? html`
|
||||
<div id=${disabledReasonId} class="agent-chat__disabled-reason">
|
||||
<span>${props.disabledReason}</span>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${renderFallbackIndicator(props.fallbackStatus)}
|
||||
${renderCompactionIndicator(props.compactionStatus)}
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__composer-input-row">
|
||||
${renderChatComposerPlusMenu({
|
||||
attachments: props,
|
||||
capabilityMenu: props.capabilityMenu,
|
||||
disabled: !canCompose || props.suggestionComposer === true,
|
||||
open: state.capabilityMenuOpen,
|
||||
view: state.capabilityMenuView,
|
||||
toolOverrides: props.toolOverrides,
|
||||
onOpenChange: (open) => {
|
||||
state.capabilityMenuOpen = open;
|
||||
if (!open) {
|
||||
state.capabilityMenuView = "root";
|
||||
}
|
||||
requestUpdate();
|
||||
},
|
||||
onViewChange: (view) => {
|
||||
state.capabilityMenuView = view;
|
||||
requestUpdate();
|
||||
},
|
||||
})}
|
||||
<div class="agent-chat__composer-combobox">
|
||||
<textarea
|
||||
${ref(state.textareaRef ?? undefined)}
|
||||
.value=${visibleDraft}
|
||||
dir=${detectTextDirection(visibleDraft)}
|
||||
?disabled=${!canCompose}
|
||||
?readonly=${dictation?.locksComposer === true}
|
||||
aria-autocomplete="list"
|
||||
aria-controls=${ifDefined(
|
||||
slashMenuVisible || skillMenuVisible ? slashMenuListboxId : undefined,
|
||||
)}
|
||||
aria-expanded=${ifDefined(
|
||||
slashMenuVisible || skillMenuVisible ? "true" : undefined,
|
||||
)}
|
||||
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
|
||||
aria-describedby=${`${slashMenuAnnouncementId}${
|
||||
props.disabledReason ? ` ${disabledReasonId}` : ""
|
||||
}`}
|
||||
aria-keyshortcuts=${sendShortcut === "enter"
|
||||
? "Enter"
|
||||
: "Control+Enter Meta+Enter"}
|
||||
@keydown=${handleKeyDown}
|
||||
@beforeinput=${handleBeforeInput}
|
||||
@input=${handleInput}
|
||||
@select=${handleSelect}
|
||||
@compositionstart=${(event: CompositionEvent) => {
|
||||
state.composerComposing = true;
|
||||
state.composingDraft = {
|
||||
key: draftKey,
|
||||
value: (event.target as HTMLTextAreaElement).value,
|
||||
};
|
||||
}}
|
||||
@compositionend=${handleCompositionEnd}
|
||||
@blur=${handleBlur}
|
||||
@paste=${(event: ClipboardEvent) => {
|
||||
if (canCompose && !props.suggestionComposer) {
|
||||
handleChatAttachmentPaste(event, props);
|
||||
}
|
||||
}}
|
||||
aria-label=${placeholder}
|
||||
placeholder=${placeholder}
|
||||
rows="1"
|
||||
></textarea>
|
||||
<span
|
||||
id=${slashMenuAnnouncementId}
|
||||
class="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>${activeSlashMenuOptionLabel}</span
|
||||
>
|
||||
<span
|
||||
class="agent-chat__run-status-announcement sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>${runStatusAnnouncement}</span
|
||||
>
|
||||
</div>
|
||||
<div class="agent-chat__composer-actions">
|
||||
${renderChatPrimaryActions(runControlsProps)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__composer-footer">
|
||||
${props.permissionPicker
|
||||
? html`<div class="agent-chat__composer-meta">
|
||||
${renderChatPermissionPicker(props.permissionPicker)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${composerControls !== nothing
|
||||
? html`
|
||||
<div class="agent-chat__composer-controls">
|
||||
${composerRunStatus?.phase === "interrupted"
|
||||
? html`
|
||||
<div class="agent-chat__composer-run-status">
|
||||
${renderChatRunStatusIndicator(composerRunStatus)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${overrideCount > 0 && props.capabilityMenu
|
||||
? html`
|
||||
<openclaw-tooltip .content=${overrideTooltip}>
|
||||
<span class="agent-chat__session-overrides-pill">
|
||||
${renderChatAttachmentInputs({ ...props, disabled: !canCompose })}
|
||||
${props.realtimeTalkVideoStream
|
||||
? html`
|
||||
<div class="agent-chat__video-preview">
|
||||
<video
|
||||
class=${mirrorCameraPreview
|
||||
? "agent-chat__video-preview-mirrored"
|
||||
: nothing}
|
||||
autoplay
|
||||
.muted=${true}
|
||||
playsinline
|
||||
aria-label=${t("chat.composer.cameraPreview")}
|
||||
${ref((element) => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
element.srcObject = props.realtimeTalkVideoStream ?? null;
|
||||
}
|
||||
})}
|
||||
></video>
|
||||
${props.realtimeTalkCameraDevices &&
|
||||
props.realtimeTalkCameraDevices.length >= 2 &&
|
||||
props.onSwitchRealtimeCamera
|
||||
? html`
|
||||
<openclaw-tooltip
|
||||
class="agent-chat__video-preview-switch-tooltip"
|
||||
.content=${t("chat.composer.switchCamera")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-chat__session-overrides-open"
|
||||
@click=${() => {
|
||||
state.capabilityMenuView = "root";
|
||||
state.capabilityMenuOpen = true;
|
||||
requestUpdate();
|
||||
}}
|
||||
class="agent-chat__video-preview-switch"
|
||||
aria-label=${t("chat.composer.switchCamera")}
|
||||
?disabled=${props.realtimeTalkVideoPending}
|
||||
@click=${props.onSwitchRealtimeCamera}
|
||||
>
|
||||
${t(
|
||||
overrideCount === 1
|
||||
? "chat.composer.overrides.countOne"
|
||||
: "chat.composer.overrides.count",
|
||||
{ count: String(overrideCount) },
|
||||
)}
|
||||
${icons.switchCamera}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-chat__session-overrides-clear"
|
||||
aria-label=${t("chat.composer.overrides.clear")}
|
||||
title=${props.capabilityMenu.mutationBlockedReason ?? ""}
|
||||
?disabled=${props.capabilityMenu.mutationBlockedReason !== null}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!props.capabilityMenu?.mutationBlockedReason) {
|
||||
props.capabilityMenu?.onPatchToolOverrides(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</span>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
${composerControls}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="agent-chat__composer-context">${contextNotice}</div>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<div class="agent-chat__composer-combobox">
|
||||
<textarea
|
||||
${ref(state.textareaRef ?? undefined)}
|
||||
.value=${dictationPreviewDraft}
|
||||
dir=${detectTextDirection(dictationPreviewDraft)}
|
||||
?disabled=${!canCompose}
|
||||
?readonly=${dictation?.locksComposer === true}
|
||||
aria-autocomplete="list"
|
||||
aria-controls=${ifDefined(
|
||||
slashMenuVisible || skillMenuVisible ? slashMenuListboxId : undefined,
|
||||
)}
|
||||
aria-expanded=${ifDefined(
|
||||
slashMenuVisible || skillMenuVisible ? "true" : undefined,
|
||||
)}
|
||||
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
|
||||
aria-describedby=${`${slashMenuAnnouncementId}${
|
||||
props.disabledReason ? ` ${disabledReasonId}` : ""
|
||||
}`}
|
||||
aria-keyshortcuts=${sendShortcut === "enter"
|
||||
? "Enter"
|
||||
: "Control+Enter Meta+Enter"}
|
||||
@keydown=${handleKeyDown}
|
||||
@beforeinput=${handleBeforeInput}
|
||||
@input=${handleInput}
|
||||
@select=${handleSelect}
|
||||
@compositionstart=${(event: CompositionEvent) => {
|
||||
state.composerComposing = true;
|
||||
state.composingDraft = {
|
||||
key: draftKey,
|
||||
value: (event.target as HTMLTextAreaElement).value,
|
||||
};
|
||||
}}
|
||||
@compositionend=${handleCompositionEnd}
|
||||
@blur=${handleBlur}
|
||||
@paste=${(event: ClipboardEvent) => {
|
||||
if (canCompose && !props.suggestionComposer) {
|
||||
handleChatAttachmentPaste(event, props);
|
||||
}
|
||||
}}
|
||||
aria-label=${placeholder}
|
||||
placeholder=${dictation?.active ? "" : placeholder}
|
||||
rows="1"
|
||||
></textarea>
|
||||
<span
|
||||
id=${slashMenuAnnouncementId}
|
||||
class="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>${activeSlashMenuOptionLabel}</span
|
||||
>
|
||||
<span
|
||||
class="agent-chat__run-status-announcement sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>${runStatusAnnouncement}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__composer-footer">
|
||||
<div class="agent-chat__composer-lead agent-chat__composer-meta">
|
||||
${renderChatComposerPlusMenu({
|
||||
attachments: props,
|
||||
capabilityMenu: props.capabilityMenu,
|
||||
disabled: !canCompose || props.suggestionComposer === true,
|
||||
open: state.capabilityMenuOpen,
|
||||
view: state.capabilityMenuView,
|
||||
toolOverrides: props.toolOverrides,
|
||||
onOpenChange: (open) => {
|
||||
state.capabilityMenuOpen = open;
|
||||
if (!open) {
|
||||
state.capabilityMenuView = "root";
|
||||
}
|
||||
requestUpdate();
|
||||
},
|
||||
onViewChange: (view) => {
|
||||
state.capabilityMenuView = view;
|
||||
requestUpdate();
|
||||
},
|
||||
})}
|
||||
${composerLeadControl}
|
||||
${props.queuedEdit?.editingId
|
||||
? html`
|
||||
<span class="agent-chat__composer-edit" role="status">
|
||||
<span class="agent-chat__composer-edit-icon" aria-hidden="true"
|
||||
>${icons.pencil}</span
|
||||
>
|
||||
<span class="agent-chat__sr-only">${t("chat.queue.states.editing")}</span>
|
||||
<button
|
||||
class="agent-chat__composer-edit-cancel"
|
||||
type="button"
|
||||
aria-label=${t("chat.queue.cancelEdit")}
|
||||
@click=${() => props.queuedEdit?.onCancel()}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="agent-chat__composer-trail">
|
||||
<div class="agent-chat__composer-meta agent-chat__composer-context">
|
||||
${contextNotice}
|
||||
</div>
|
||||
${composerControls !== nothing
|
||||
? html`
|
||||
<div class="agent-chat__composer-controls">
|
||||
${overrideCount > 0 && props.capabilityMenu
|
||||
? html`
|
||||
<openclaw-tooltip .content=${overrideTooltip}>
|
||||
<span class="agent-chat__session-overrides-pill">
|
||||
<button
|
||||
type="button"
|
||||
class="agent-chat__session-overrides-open"
|
||||
@click=${() => {
|
||||
state.capabilityMenuView = "root";
|
||||
state.capabilityMenuOpen = true;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
${t(
|
||||
overrideCount === 1
|
||||
? "chat.composer.overrides.countOne"
|
||||
: "chat.composer.overrides.count",
|
||||
{ count: String(overrideCount) },
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-chat__session-overrides-clear"
|
||||
aria-label=${t("chat.composer.overrides.clear")}
|
||||
title=${props.capabilityMenu.mutationBlockedReason ?? ""}
|
||||
?disabled=${props.capabilityMenu.mutationBlockedReason !==
|
||||
null}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!props.capabilityMenu?.mutationBlockedReason) {
|
||||
props.capabilityMenu?.onPatchToolOverrides(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</span>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
${composerControls}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="agent-chat__composer-actions">
|
||||
${renderChatPrimaryActions(runControlsProps)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
${composerUnderlaps}`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { SlashCommandDef } from "../../../lib/chat/commands.ts";
|
||||
import { resolveThinkingCommandArgOptionsForSession } from "../../../lib/chat/thinking.ts";
|
||||
import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts";
|
||||
import { ComposerDictationController, insertComposerDictation } from "../composer-dictation.ts";
|
||||
import { discoverRealtimeTalkInputs, observeRealtimeTalkDevices } from "../realtime-talk-input.ts";
|
||||
import { ComposerMicrophonePicker } from "../composer-microphone-picker.ts";
|
||||
import { isLargePastedTextAttachment } from "./chat-attachments.ts";
|
||||
import { renderContextNotice } from "./chat-composer-context.ts";
|
||||
import { renderMicrophonePicker, type ChatRunControlsProps } from "./chat-composer-controls.ts";
|
||||
@@ -46,11 +46,11 @@ import {
|
||||
hasTerminalRunStatus,
|
||||
isCurrentSessionSubmittedProgress,
|
||||
markComposerInputIntent,
|
||||
releaseMicrophoneDeviceWatch,
|
||||
suppressStaleSubmittedDraftReplay,
|
||||
} from "./chat-composer-state.ts";
|
||||
import type { ChatComposerProps } from "./chat-composer-types.ts";
|
||||
import { renderChatComposerView } from "./chat-composer-view.ts";
|
||||
import { renderChatPermissionPicker } from "./chat-permission-picker.ts";
|
||||
import { createGatewayQuestionPanelProps } from "./chat-question-card.ts";
|
||||
|
||||
export { isChatRunWorking, resetChatComposerState } from "./chat-composer-state.ts";
|
||||
@@ -85,13 +85,21 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
const submittedProgress = props.queue.find((item) =>
|
||||
isCurrentSessionSubmittedProgress(item, props.sessionKey, props.runStatus),
|
||||
);
|
||||
const hasSubmittedProgress = props.queue.some(
|
||||
(item) =>
|
||||
!item.pendingRunId && (item.sendState === "sending" || item.sendState === "waiting-model"),
|
||||
);
|
||||
const sendingForCurrentSession =
|
||||
props.sending && (!hasSubmittedProgress || submittedProgress !== undefined);
|
||||
const composerRunStatus =
|
||||
showAbortableUi || Boolean(submittedProgress)
|
||||
sendingForCurrentSession || showAbortableUi || Boolean(submittedProgress)
|
||||
? { phase: "in-progress" as const }
|
||||
: props.runStatus;
|
||||
const compactBusy =
|
||||
props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying";
|
||||
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
|
||||
const activeSession = props.sessions?.sessions?.find((row) =>
|
||||
areUiSessionKeysEquivalent(row.key, props.sessionKey),
|
||||
);
|
||||
const draftKey = composerDraftKey(props);
|
||||
if (state.dictationDraftKey !== null && state.dictationDraftKey !== draftKey) {
|
||||
state.dictation?.dispose();
|
||||
@@ -140,6 +148,9 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
},
|
||||
);
|
||||
const composerControls = props.composerControls ?? nothing;
|
||||
const composerLeadControl = props.permissionPicker
|
||||
? renderChatPermissionPicker(props.permissionPicker)
|
||||
: nothing;
|
||||
const assistantName = props.assistantName || "OpenClaw";
|
||||
const inProgressLabel = props.waitingApproval
|
||||
? t("chat.waitingForApproval")
|
||||
@@ -147,7 +158,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
? t("chat.composer.preparingModel")
|
||||
: props.stream !== null
|
||||
? t("chat.composer.responding", { name: assistantName })
|
||||
: props.sending || submittedProgress
|
||||
: sendingForCurrentSession || submittedProgress
|
||||
? t("chat.composer.sendingMessage")
|
||||
: t("chat.composer.working", { name: assistantName });
|
||||
// Persistent sr-only live region: run phases are otherwise conveyed only
|
||||
@@ -262,6 +273,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
// slash commands are live controls and must not execute against stale state.
|
||||
const canSubmitDraft = (draft: string) =>
|
||||
canCompose &&
|
||||
state.dictation?.locksComposer !== true &&
|
||||
!(state.skillMenuOpen && state.skillCommandRefreshPending) &&
|
||||
(props.getPendingAttachmentReads?.() ?? props.pendingAttachmentReads ?? 0) === 0 &&
|
||||
(props.connected || !draft.trimStart().startsWith("/"));
|
||||
@@ -385,72 +397,32 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
}
|
||||
props.onToggleRealtimeTalk?.();
|
||||
};
|
||||
const discoverMicrophones = () => {
|
||||
state.microphonePickerLoading = true;
|
||||
state.microphoneIssue = null;
|
||||
const request = ++state.microphoneDiscoveryRequest;
|
||||
requestUpdate();
|
||||
// Permission-requesting discovery on every pass, including device changes:
|
||||
// a microphone that just appeared has hidden labels until the probe runs,
|
||||
// and the probe only prompts while the picker is the surface in front of
|
||||
// the user.
|
||||
void discoverRealtimeTalkInputs(true)
|
||||
.then((result) => {
|
||||
if (request !== state.microphoneDiscoveryRequest) {
|
||||
return;
|
||||
}
|
||||
state.microphoneDevices = result.devices;
|
||||
state.microphoneIssue = result.issue;
|
||||
})
|
||||
.catch(() => {
|
||||
if (request !== state.microphoneDiscoveryRequest) {
|
||||
return;
|
||||
}
|
||||
state.microphoneDevices = [];
|
||||
state.microphoneIssue = "failed";
|
||||
})
|
||||
.finally(() => {
|
||||
if (request !== state.microphoneDiscoveryRequest) {
|
||||
return;
|
||||
}
|
||||
state.microphonePickerLoading = false;
|
||||
requestUpdate();
|
||||
});
|
||||
};
|
||||
const openMicrophonePicker = () => {
|
||||
if (state.microphonePickerOpen) {
|
||||
return;
|
||||
}
|
||||
state.microphonePickerOpen = true;
|
||||
state.microphoneDeviceWatch ??= observeRealtimeTalkDevices(discoverMicrophones);
|
||||
discoverMicrophones();
|
||||
};
|
||||
const closeMicrophonePicker = () => {
|
||||
if (!state.microphonePickerOpen) {
|
||||
return;
|
||||
}
|
||||
releaseMicrophoneDeviceWatch(state);
|
||||
state.microphonePickerOpen = false;
|
||||
requestUpdate();
|
||||
};
|
||||
const selectMicrophone = (deviceId: string) => {
|
||||
patchSettings({ realtimeTalkInputDeviceId: deviceId.trim() || undefined });
|
||||
releaseMicrophoneDeviceWatch(state);
|
||||
state.microphonePickerOpen = false;
|
||||
requestUpdate();
|
||||
};
|
||||
state.microphonePicker ??= new ComposerMicrophonePicker(requestUpdate);
|
||||
const devicePicker = state.microphonePicker;
|
||||
const selectedMicrophoneId = loadSettings().realtimeTalkInputDeviceId?.trim() ?? "";
|
||||
const microphonePicker = props.onToggleRealtimeTalk
|
||||
? renderMicrophonePicker({
|
||||
devices: state.microphoneDevices,
|
||||
loading: state.microphonePickerLoading,
|
||||
open: state.microphonePickerOpen,
|
||||
devices: devicePicker.devices,
|
||||
loading: devicePicker.loading,
|
||||
open: devicePicker.open,
|
||||
selectedDeviceId: selectedMicrophoneId,
|
||||
voiceActive: Boolean(props.realtimeTalkActive),
|
||||
issue: state.microphoneIssue,
|
||||
onOpen: openMicrophonePicker,
|
||||
onClose: closeMicrophonePicker,
|
||||
onSelect: selectMicrophone,
|
||||
issue: devicePicker.issue,
|
||||
holdToDictate: props.composerHoldToRecord !== false,
|
||||
onOpen: devicePicker.handleOpen,
|
||||
onClose: devicePicker.handleClose,
|
||||
onSelect: (deviceId: string) => {
|
||||
patchSettings({ realtimeTalkInputDeviceId: deviceId.trim() || undefined });
|
||||
devicePicker.handleClose();
|
||||
},
|
||||
onHoldToDictateChange: (enabled: boolean) => {
|
||||
if (props.onComposerHoldToRecordChange) {
|
||||
props.onComposerHoldToRecordChange(enabled);
|
||||
} else {
|
||||
patchSettings({ composerHoldToRecord: enabled });
|
||||
}
|
||||
requestUpdate();
|
||||
},
|
||||
})
|
||||
: nothing;
|
||||
const dictationOptions = {
|
||||
@@ -575,6 +547,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
visibleDraft,
|
||||
contextNotice,
|
||||
composerControls,
|
||||
composerLeadControl,
|
||||
runStatusAnnouncement,
|
||||
requestUpdate,
|
||||
sendShortcut,
|
||||
@@ -599,6 +572,5 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
activeSlashMenuOptionLabel,
|
||||
slashMenuListboxId,
|
||||
slashMenuAnnouncementId,
|
||||
composerRunStatus,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ChatFastModeSelectValue,
|
||||
} from "../../../lib/chat/model-select-state.ts";
|
||||
import type { ChatThinkingSelectState } from "../../../lib/chat/thinking.ts";
|
||||
import { syncChatPickerOverlay } from "./chat-picker-overlay.ts";
|
||||
import { handleChatComposerDetailsToggle, syncChatPickerOverlay } from "./chat-picker-overlay.ts";
|
||||
|
||||
type ChatEffortPickerParams = {
|
||||
disabled: boolean;
|
||||
@@ -118,13 +118,13 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
|
||||
};
|
||||
const onlyStop = sliderStops.length === 1 ? sliderStops[0] : undefined;
|
||||
const onlyStopSelected = selection.kind === "anchored" && selection.index === 0;
|
||||
const speedTooltip = params.fastMode.supported
|
||||
? t("chat.modelControls.fastHelp")
|
||||
: t("chat.modelControls.speedUnsupported");
|
||||
return html`
|
||||
<details
|
||||
class="chat-controls__inline-select chat-controls__effort-picker"
|
||||
@toggle=${(event: Event) => syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement)}
|
||||
@toggle=${(event: Event) => {
|
||||
handleChatComposerDetailsToggle(event);
|
||||
syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement);
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
class="chat-controls__inline-select-trigger chat-controls__effort-trigger ${params.fastMode
|
||||
@@ -148,6 +148,9 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
|
||||
? html`<span class="chat-controls__effort-zap" aria-hidden="true">${icons.zap}</span>`
|
||||
: nothing}
|
||||
<span class="chat-controls__inline-select-label">${triggerLabel}</span>
|
||||
<span class="chat-controls__inline-select-chevron" aria-hidden="true"
|
||||
>${icons.chevronUp}</span
|
||||
>
|
||||
</summary>
|
||||
<wa-popup data-anchored-overlay>
|
||||
<div
|
||||
@@ -274,31 +277,29 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
|
||||
${t("chat.modelControls.fastHelp")}
|
||||
</span>
|
||||
</span>
|
||||
<openclaw-tooltip .content=${speedTooltip}>
|
||||
<button
|
||||
class="chat-controls__speed-toggle ${params.fastMode.active
|
||||
? "chat-controls__speed-toggle--active"
|
||||
: ""}"
|
||||
data-chat-speed-toggle=${params.fastMode.nextValue}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked=${params.fastMode.active ? "true" : "false"}
|
||||
aria-label=${t("chat.modelControls.fastResponsesAria", {
|
||||
state: params.fastMode.label,
|
||||
})}
|
||||
?disabled=${params.fastMode.disabled}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (params.fastMode.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
commitFastMode(params.fastMode.nextValue);
|
||||
}}
|
||||
>
|
||||
<span class="chat-controls__speed-toggle-thumb"></span>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<button
|
||||
class="chat-controls__speed-toggle ${params.fastMode.active
|
||||
? "chat-controls__speed-toggle--active"
|
||||
: ""}"
|
||||
data-chat-speed-toggle=${params.fastMode.nextValue}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked=${params.fastMode.active ? "true" : "false"}
|
||||
aria-label=${t("chat.modelControls.fastResponsesAria", {
|
||||
state: params.fastMode.label,
|
||||
})}
|
||||
?disabled=${params.fastMode.disabled}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (params.fastMode.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
commitFastMode(params.fastMode.nextValue);
|
||||
}}
|
||||
>
|
||||
<span class="chat-controls__speed-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -131,10 +131,10 @@ export function renderChatModelPickerOption(params: {
|
||||
>${t("chat.modelControls.default")}</span
|
||||
>`
|
||||
: nothing}
|
||||
${modelMeta
|
||||
? html`<span class="chat-controls__model-option-meta">${modelMeta}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
${modelMeta
|
||||
? html`<span class="chat-controls__model-option-meta">${modelMeta}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
<span class="chat-controls__model-option-action">
|
||||
${selected
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { providerDisplayLabel } from "../../../components/provider-icon.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import {
|
||||
hasProviderBrandIcon,
|
||||
providerDisplayLabel,
|
||||
renderProviderBrandIcon,
|
||||
} from "../../../components/provider-icon.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import {
|
||||
type ChatContextWindowControlParams,
|
||||
@@ -15,7 +19,7 @@ import {
|
||||
type ChatModelPickerOption,
|
||||
type ChatModelPickerTargetGroup,
|
||||
} from "./chat-model-picker-options.ts";
|
||||
import { syncChatPickerOverlay } from "./chat-picker-overlay.ts";
|
||||
import { handleChatComposerDetailsToggle, syncChatPickerOverlay } from "./chat-picker-overlay.ts";
|
||||
|
||||
export type ChatModelCatalogState = {
|
||||
hasSnapshot: boolean;
|
||||
@@ -323,6 +327,20 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
// Brand mark ahead of the model name, and only when one actually ships:
|
||||
// hasProviderBrandIcon gates out the lettered fallback badge, so a provider
|
||||
// without a mark renders nothing rather than a placeholder — the trigger's gap
|
||||
// sits between boxes that exist, so nothing reserves space either. A status
|
||||
// label replaces the model name outright, and a provider mark next to
|
||||
// "Loading..." would claim an identity the trigger is not showing.
|
||||
const triggerProviderIcon =
|
||||
!params.triggerStatusLabel &&
|
||||
activeModelOption &&
|
||||
hasProviderBrandIcon(activeModelOption.provider)
|
||||
? renderProviderBrandIcon(activeModelOption.provider, {
|
||||
className: "chat-controls__trigger-provider-icon",
|
||||
})
|
||||
: nothing;
|
||||
const providerGroups = new Map<string, ChatModelPickerOption[]>();
|
||||
for (const option of params.modelOptions) {
|
||||
const existing = providerGroups.get(option.provider);
|
||||
@@ -369,7 +387,9 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
const details = (event.currentTarget as HTMLElement).closest<HTMLDetailsElement>("details");
|
||||
if (details) {
|
||||
details.open = false;
|
||||
details.querySelector<HTMLElement>("summary")?.focus();
|
||||
if (event.detail === 0) {
|
||||
details.querySelector<HTMLElement>("summary")?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
const selectTarget = (groupId: string, value: string, event: MouseEvent) => {
|
||||
@@ -382,7 +402,9 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
const details = (event.currentTarget as HTMLElement).closest<HTMLDetailsElement>("details");
|
||||
if (details) {
|
||||
details.open = false;
|
||||
details.querySelector<HTMLElement>("summary")?.focus();
|
||||
if (event.detail === 0) {
|
||||
details.querySelector<HTMLElement>("summary")?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
const highlightOption = (row: HTMLButtonElement) => {
|
||||
@@ -397,6 +419,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
@keydown=${handleModelPickerKeydown}
|
||||
@toggle=${(event: Event) => {
|
||||
const details = event.currentTarget as HTMLDetailsElement;
|
||||
handleChatComposerDetailsToggle(event);
|
||||
syncChatPickerOverlay(details);
|
||||
if (!details.open) {
|
||||
resetModelSearch(details);
|
||||
@@ -440,6 +463,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
${triggerProviderIcon}
|
||||
<span class="chat-controls__inline-select-label">
|
||||
${params.triggerStatusLabel ?? params.triggerModelLabel}
|
||||
</span>
|
||||
@@ -453,6 +477,9 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
<span class="chat-controls__inline-select-chevron" aria-hidden="true"
|
||||
>${icons.chevronUp}</span
|
||||
>
|
||||
</summary>
|
||||
<wa-popup data-anchored-overlay>
|
||||
<div
|
||||
@@ -605,6 +632,42 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
${params.modelOptions.length > 0 && params.selectedModelValue !== ""
|
||||
? html`<footer class="chat-controls__model-provenance">
|
||||
<span>${t("chat.modelControls.sessionOverride")}</span>
|
||||
<openclaw-tooltip
|
||||
.content=${t("chat.modelControls.resetToDefault", {
|
||||
model: defaultModelOption?.label ?? params.triggerModelLabel,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
class="chat-controls__model-reset"
|
||||
data-chat-model-reset="true"
|
||||
type="button"
|
||||
?disabled=${params.disabled}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (params.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
commitModel("");
|
||||
const resetButton = event.currentTarget;
|
||||
if (!(resetButton instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const details =
|
||||
resetButton.closest<HTMLDetailsElement>("details");
|
||||
if (details) {
|
||||
details.open = false;
|
||||
if (event.detail === 0) {
|
||||
details
|
||||
.querySelector<HTMLElement>("summary")
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
${t("chat.modelControls.useDefault")}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</footer>`
|
||||
: nothing}
|
||||
`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
||||
import type { SessionPermissionMode } from "../../../../../packages/gateway-protocol/src/index.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { restorePointerOpenedChatComposerTrigger } from "./chat-picker-overlay.ts";
|
||||
|
||||
const PERMISSION_MODES = ["read-only", "guarded", "workspace", "full"] as const;
|
||||
const DEFAULT_PERMISSION_VALUE = "default";
|
||||
@@ -47,20 +48,27 @@ function handlePermissionPickerKeydown(
|
||||
dropdown.querySelector<HTMLButtonElement>("[slot=trigger]")?.focus();
|
||||
}
|
||||
|
||||
function ellipsizeMiddle(value: string, maxLength = 54): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
const edgeLength = Math.floor((maxLength - 1) / 2);
|
||||
return `${value.slice(0, edgeLength)}…${value.slice(-edgeLength)}`;
|
||||
}
|
||||
|
||||
function modeLabel(mode: SessionPermissionMode | null | undefined): string {
|
||||
return mode
|
||||
? t(`chat.permissionControls.modes.${mode}.label`)
|
||||
: t("chat.permissionControls.default");
|
||||
}
|
||||
|
||||
function modeIcon(mode: SessionPermissionMode | null): unknown {
|
||||
switch (mode) {
|
||||
case "read-only":
|
||||
return icons.lock;
|
||||
case "guarded":
|
||||
return icons.shieldCheck;
|
||||
case "workspace":
|
||||
return icons.folder;
|
||||
case "full":
|
||||
return icons.shieldX;
|
||||
default:
|
||||
return icons.shieldQuestion;
|
||||
}
|
||||
}
|
||||
|
||||
function isPermissionMode(value: string | undefined): value is SessionPermissionMode {
|
||||
return value !== undefined && PERMISSION_MODES.some((mode) => mode === value);
|
||||
}
|
||||
@@ -85,6 +93,7 @@ export function renderChatPermissionPicker(params: ChatPermissionPickerProps) {
|
||||
<wa-dropdown
|
||||
class="chat-controls__inline-select chat-controls__permission-picker"
|
||||
placement="top-start"
|
||||
@wa-after-show=${restorePointerOpenedChatComposerTrigger}
|
||||
@keydown=${(event: KeyboardEvent) => handlePermissionPickerKeydown(event, selectMode)}
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
const mode = permissionSelection(event.detail.item.value);
|
||||
@@ -98,6 +107,9 @@ export function renderChatPermissionPicker(params: ChatPermissionPickerProps) {
|
||||
type="button"
|
||||
class="chat-controls__inline-select-trigger chat-controls__permission-trigger ${params.disabled
|
||||
? "chat-controls__inline-select-trigger--disabled"
|
||||
: ""} ${params.mode ? "" : "chat-controls__permission-trigger--default"} ${params.mode ===
|
||||
"full"
|
||||
? "chat-controls__permission-trigger--full"
|
||||
: ""}"
|
||||
data-chat-permission-select="true"
|
||||
data-chat-select-value=${params.mode ?? ""}
|
||||
@@ -106,7 +118,9 @@ export function renderChatPermissionPicker(params: ChatPermissionPickerProps) {
|
||||
title=${params.disabledReason ?? t("chat.permissionControls.help")}
|
||||
?disabled=${params.disabled}
|
||||
>
|
||||
<span class="chat-controls__permission-icon" aria-hidden="true">${icons.shieldCheck}</span>
|
||||
<span class="chat-controls__permission-icon" aria-hidden="true"
|
||||
>${modeIcon(params.mode ?? null)}</span
|
||||
>
|
||||
<span
|
||||
class="chat-controls__inline-select-label ${params.mode === "full"
|
||||
? "chat-controls__permission-label--full"
|
||||
@@ -115,6 +129,7 @@ export function renderChatPermissionPicker(params: ChatPermissionPickerProps) {
|
||||
${modeLabel(params.mode)}
|
||||
</span>
|
||||
</button>
|
||||
<div class="chat-controls__popover-title">${t("chat.permissionControls.label")}</div>
|
||||
${PERMISSION_OPTIONS.map((mode, index) => {
|
||||
const value = mode ?? DEFAULT_PERMISSION_VALUE;
|
||||
const selected = (params.mode ?? null) === mode;
|
||||
@@ -135,12 +150,12 @@ export function renderChatPermissionPicker(params: ChatPermissionPickerProps) {
|
||||
title=${locked ? t("chat.permissionControls.fullRequiresAdmin") : nothing}
|
||||
?disabled=${params.disabled || locked}
|
||||
>
|
||||
<span slot="icon" class="chat-controls__permission-option-icon" aria-hidden="true"
|
||||
>${modeIcon(mode)}</span
|
||||
>
|
||||
<span class="chat-controls__permission-option-copy">
|
||||
<span class="chat-controls__permission-option-title">
|
||||
<span>${modeLabel(mode)}</span>
|
||||
<span class="chat-controls__permission-shortcut" aria-hidden="true"
|
||||
>${index + 1}</span
|
||||
>
|
||||
</span>
|
||||
<span class="chat-controls__permission-option-description">
|
||||
${mode
|
||||
@@ -148,39 +163,20 @@ export function renderChatPermissionPicker(params: ChatPermissionPickerProps) {
|
||||
: t("chat.permissionControls.defaultDescription")}
|
||||
</span>
|
||||
</span>
|
||||
${locked || selected
|
||||
? html`
|
||||
<span
|
||||
slot="details"
|
||||
class="chat-controls__permission-option-state"
|
||||
aria-hidden="true"
|
||||
>
|
||||
${locked
|
||||
? html`<span class="chat-controls__permission-lock">${icons.lock}</span>`
|
||||
: nothing}
|
||||
${selected
|
||||
? html`<span class="chat-controls__inline-select-check">${icons.check}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
<span slot="details" class="chat-controls__permission-option-state" aria-hidden="true">
|
||||
${selected
|
||||
? nothing
|
||||
: html`<span class="chat-controls__permission-shortcut">${index + 1}</span>`}
|
||||
${locked
|
||||
? html`<span class="chat-controls__permission-lock">${icons.lock}</span>`
|
||||
: nothing}
|
||||
${selected
|
||||
? html`<span class="chat-controls__inline-select-check">${icons.check}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
</wa-dropdown-item>
|
||||
`;
|
||||
})}
|
||||
${params.sessionRoot
|
||||
? html`
|
||||
<div
|
||||
class="chat-controls__permission-root"
|
||||
title=${params.sessionRoot}
|
||||
aria-label=${t("chat.permissionControls.sessionRoot", {
|
||||
root: params.sessionRoot,
|
||||
})}
|
||||
>
|
||||
<span>${t("chat.permissionControls.rootLabel")}</span>
|
||||
<code>${ellipsizeMiddle(params.sessionRoot)}</code>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</wa-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
handleChatComposerDropdownShow,
|
||||
markPointerOpenedChatComposerDropdown,
|
||||
restorePointerOpenedChatComposerTrigger,
|
||||
} from "./chat-picker-overlay.ts";
|
||||
|
||||
describe("chat picker overlay", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("does not restore pointer focus after keyboard input takes over", () => {
|
||||
const dropdown = document.createElement("wa-dropdown");
|
||||
const trigger = document.createElement("button");
|
||||
trigger.slot = "trigger";
|
||||
dropdown.append(trigger);
|
||||
document.body.append(dropdown);
|
||||
|
||||
dropdown.addEventListener("wa-show", handleChatComposerDropdownShow);
|
||||
dropdown.dispatchEvent(new Event("wa-show"));
|
||||
dropdown.addEventListener("pointerdown", markPointerOpenedChatComposerDropdown);
|
||||
trigger.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
|
||||
|
||||
trigger.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, composed: true, key: "Enter" }),
|
||||
);
|
||||
|
||||
dropdown.addEventListener("wa-after-show", restorePointerOpenedChatComposerTrigger);
|
||||
dropdown.dispatchEvent(new Event("wa-after-show"));
|
||||
|
||||
expect(trigger.hasAttribute("data-chat-pointer-restored-focus")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,165 @@ import { syncAnchoredOverlay } from "../../../components/anchored-overlay.ts";
|
||||
const MOBILE_COMPOSER_OVERLAY_QUERY =
|
||||
"(max-width: 640px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape)";
|
||||
|
||||
const pointerOpenedDropdowns = new WeakSet<HTMLElement>();
|
||||
const POINTER_RESTORED_FOCUS_ATTRIBUTE = "data-chat-pointer-restored-focus";
|
||||
const POINTER_OPENED_PICKER_ATTRIBUTE = "data-chat-pointer-opened-picker";
|
||||
|
||||
let composerPickerDismissalInstalled = false;
|
||||
|
||||
function composerPickerIsOpen(picker: HTMLElement): boolean {
|
||||
if (picker instanceof HTMLDetailsElement) {
|
||||
return picker.open;
|
||||
}
|
||||
return ("open" in picker && picker.open === true) || picker.hasAttribute("open");
|
||||
}
|
||||
|
||||
function openChatComposerPickers(root: ParentNode = document): HTMLElement[] {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
".agent-chat__input details, .agent-chat__input wa-dropdown",
|
||||
),
|
||||
).filter(composerPickerIsOpen);
|
||||
}
|
||||
|
||||
function closeComposerPicker(picker: HTMLElement): void {
|
||||
pointerOpenedDropdowns.delete(picker);
|
||||
picker.removeAttribute(POINTER_OPENED_PICKER_ATTRIBUTE);
|
||||
if (picker instanceof HTMLDetailsElement) {
|
||||
picker.open = false;
|
||||
} else {
|
||||
if ("open" in picker) {
|
||||
picker.open = false;
|
||||
}
|
||||
picker.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
|
||||
function pickerTrigger(picker: HTMLElement): HTMLElement | null {
|
||||
return picker instanceof HTMLDetailsElement
|
||||
? picker.querySelector<HTMLElement>("summary")
|
||||
: picker.querySelector<HTMLElement>("[slot=trigger]");
|
||||
}
|
||||
|
||||
function dismissChatComposerPickersOutside(event: PointerEvent): void {
|
||||
const path = event.composedPath();
|
||||
for (const picker of openChatComposerPickers()) {
|
||||
if (!path.includes(picker)) {
|
||||
closeComposerPicker(picker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dismissChatComposerPickersOnEscape(event: KeyboardEvent): void {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
const pickers = openChatComposerPickers();
|
||||
if (pickers.length === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const lastPicker = pickers.at(-1);
|
||||
if (!lastPicker) {
|
||||
return;
|
||||
}
|
||||
const trigger = pickerTrigger(lastPicker);
|
||||
pickers.forEach(closeComposerPicker);
|
||||
trigger?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function ensureChatComposerPickerDismissal(): void {
|
||||
if (composerPickerDismissalInstalled || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
composerPickerDismissalInstalled = true;
|
||||
document.addEventListener("pointerdown", dismissChatComposerPickersOutside, true);
|
||||
document.addEventListener("keydown", dismissChatComposerPickersOnEscape, true);
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
const dropdown = event
|
||||
.composedPath()
|
||||
.find(
|
||||
(node): node is HTMLElement =>
|
||||
node instanceof HTMLElement && node.localName === "wa-dropdown",
|
||||
);
|
||||
if (dropdown) {
|
||||
pointerOpenedDropdowns.delete(dropdown);
|
||||
dropdown.removeAttribute(POINTER_OPENED_PICKER_ATTRIBUTE);
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function closeOtherChatComposerPickers(source: HTMLElement): void {
|
||||
const composer = source.closest(".agent-chat__input");
|
||||
if (!composer) {
|
||||
return;
|
||||
}
|
||||
for (const picker of openChatComposerPickers(composer)) {
|
||||
if (picker !== source) {
|
||||
closeComposerPicker(picker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleChatComposerDetailsToggle(event: Event): void {
|
||||
const details = event.currentTarget;
|
||||
if (details instanceof HTMLDetailsElement && details.open) {
|
||||
ensureChatComposerPickerDismissal();
|
||||
closeOtherChatComposerPickers(details);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleChatComposerDropdownShow(event: Event): void {
|
||||
const dropdown = event.target;
|
||||
if (dropdown instanceof HTMLElement && dropdown.localName === "wa-dropdown") {
|
||||
if (!pointerOpenedDropdowns.has(dropdown)) {
|
||||
dropdown.removeAttribute(POINTER_OPENED_PICKER_ATTRIBUTE);
|
||||
}
|
||||
ensureChatComposerPickerDismissal();
|
||||
closeOtherChatComposerPickers(dropdown);
|
||||
}
|
||||
}
|
||||
|
||||
export function markPointerOpenedChatComposerDropdown(event: PointerEvent): void {
|
||||
const dropdown = event
|
||||
.composedPath()
|
||||
.find(
|
||||
(node): node is HTMLElement =>
|
||||
node instanceof HTMLElement && node.localName === "wa-dropdown",
|
||||
);
|
||||
if (dropdown) {
|
||||
pointerOpenedDropdowns.add(dropdown);
|
||||
dropdown.setAttribute(POINTER_OPENED_PICKER_ATTRIBUTE, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function restorePointerOpenedChatComposerTrigger(event: Event): void {
|
||||
const dropdown =
|
||||
event.target instanceof HTMLElement && event.target.localName === "wa-dropdown"
|
||||
? event.target
|
||||
: event.currentTarget;
|
||||
if (
|
||||
dropdown instanceof HTMLElement &&
|
||||
dropdown.localName === "wa-dropdown" &&
|
||||
pointerOpenedDropdowns.delete(dropdown)
|
||||
) {
|
||||
const trigger = pickerTrigger(dropdown);
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
trigger.setAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE, "");
|
||||
const clearPointerFocus = () => trigger.removeAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE);
|
||||
trigger.addEventListener("blur", clearPointerFocus, { once: true });
|
||||
trigger.addEventListener("keydown", clearPointerFocus, { once: true });
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function syncChatPickerOverlay(details: HTMLDetailsElement): void {
|
||||
// Mobile panels span the composer, so anchor to that stable box; desktop
|
||||
// panels stay attached to the individual trigger.
|
||||
|
||||
@@ -426,6 +426,13 @@ class ChatQuestionPanel extends LitElement {
|
||||
this.toggleOption(model, question, question.options[optionIndex].label);
|
||||
return;
|
||||
}
|
||||
if (question.isOther && optionIndex === question.options.length) {
|
||||
event.preventDefault();
|
||||
this.querySelector<HTMLInputElement>(".chat-question-panel__other")?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!(event.target instanceof HTMLButtonElement) &&
|
||||
@@ -516,6 +523,7 @@ class ChatQuestionPanel extends LitElement {
|
||||
</button>
|
||||
</div>`
|
||||
: nothing}
|
||||
<span class="chat-question-panel__progress">${progress}</span>
|
||||
<button
|
||||
class="chat-question-panel__collapse"
|
||||
type="button"
|
||||
@@ -527,7 +535,6 @@ class ChatQuestionPanel extends LitElement {
|
||||
</div>
|
||||
|
||||
<div class="chat-question-panel__heading">
|
||||
<span class="chat-question-panel__progress">${progress}</span>
|
||||
<span class="chat-question-panel__prompt">${question.question}</span>
|
||||
</div>
|
||||
|
||||
@@ -572,17 +579,27 @@ class ChatQuestionPanel extends LitElement {
|
||||
|
||||
${question.isOther || question.options.length === 0
|
||||
? html`
|
||||
<input
|
||||
class="chat-question-panel__other"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=${t("chat.questions.other")}
|
||||
aria-label=${t("chat.questions.ownAnswerFor", { header: question.header })}
|
||||
.value=${this.freeTextById.get(question.questionId) ?? ""}
|
||||
?disabled=${disabled}
|
||||
@input=${(event: Event) =>
|
||||
this.setFreeText(model, question, (event.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<label
|
||||
class="chat-question-panel__option chat-question-panel__option--other ${this.freeTextById
|
||||
.get(question.questionId)
|
||||
?.trim()
|
||||
? "chat-question-panel__option--selected"
|
||||
: ""}"
|
||||
>
|
||||
<span class="chat-question-panel__option-marker" aria-hidden="true"></span>
|
||||
<input
|
||||
class="chat-question-panel__other"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=${t("chat.questions.other")}
|
||||
aria-label=${t("chat.questions.ownAnswerFor", { header: question.header })}
|
||||
.value=${this.freeTextById.get(question.questionId) ?? ""}
|
||||
?disabled=${disabled}
|
||||
@input=${(event: Event) =>
|
||||
this.setFreeText(model, question, (event.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<kbd>${question.options.length + 1}</kbd>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
|
||||
@@ -633,39 +633,47 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
||||
? this.renderStarters()
|
||||
: nothing}
|
||||
<form
|
||||
class="chat-session-rail__composer"
|
||||
class="agent-chat__input chat-session-rail__composer"
|
||||
@submit=${(event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
}}
|
||||
>
|
||||
<label class="chat-session-rail__prompt">
|
||||
<input
|
||||
class="chat-session-rail__input"
|
||||
type="text"
|
||||
maxlength="400"
|
||||
autocomplete="off"
|
||||
aria-label=${t("chat.rail.askLabel")}
|
||||
.value=${this.companion.draft}
|
||||
placeholder=${this.companion.pendingQuestion
|
||||
? t("chat.rail.askPending")
|
||||
: t("chat.rail.askPlaceholder")}
|
||||
?disabled=${!this.connected || this.companion.pendingQuestion !== null}
|
||||
@input=${(event: InputEvent) => {
|
||||
this.onDraftChange?.((event.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
type="submit"
|
||||
aria-label=${t("chat.rail.askSubmit")}
|
||||
?disabled=${!this.connected ||
|
||||
this.companion.pendingQuestion !== null ||
|
||||
!this.companion.draft.trim()}
|
||||
>
|
||||
${icons.arrowUp}
|
||||
</button>
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<label class="agent-chat__composer-combobox chat-session-rail__prompt">
|
||||
<input
|
||||
class="chat-session-rail__input"
|
||||
type="text"
|
||||
maxlength="400"
|
||||
autocomplete="off"
|
||||
aria-label=${t("chat.rail.askLabel")}
|
||||
.value=${this.companion.draft}
|
||||
placeholder=${this.companion.pendingQuestion
|
||||
? t("chat.rail.askPending")
|
||||
: t("chat.rail.askPlaceholder")}
|
||||
?disabled=${!this.connected || this.companion.pendingQuestion !== null}
|
||||
@input=${(event: InputEvent) => {
|
||||
this.onDraftChange?.((event.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
<div class="agent-chat__composer-trail">
|
||||
<div class="agent-chat__composer-actions">
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
type="submit"
|
||||
aria-label=${t("chat.rail.askSubmit")}
|
||||
?disabled=${!this.connected ||
|
||||
this.companion.pendingQuestion !== null ||
|
||||
!this.companion.draft.trim()}
|
||||
>
|
||||
${icons.arrowUp}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readStyleSheet } from "../../../../../test/helpers/ui-style-fixtures.js";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
} from "../../../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const describeBrowserLayout = canRunPlaywrightChromium(chromiumExecutablePath)
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
let browser: Browser | null = null;
|
||||
|
||||
describeBrowserLayout("chat swarm progress browser layout", () => {
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it("keeps long task popovers reachable above the in-flow mobile composer", async () => {
|
||||
if (!browser) {
|
||||
throw new Error("expected browser");
|
||||
}
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 568 } });
|
||||
const tasks = Array.from(
|
||||
{ length: 256 },
|
||||
(_, index) => `<div class="chat-swarm__task">Worker ${index + 1}</div>`,
|
||||
).join("");
|
||||
const styles = [
|
||||
"ui/src/styles/base.css",
|
||||
"ui/src/styles/chat/layout.css",
|
||||
"ui/src/styles/chat/sidebar.css",
|
||||
]
|
||||
.map((file) => readStyleSheet(file))
|
||||
.join("\n");
|
||||
await page.setContent(`<!doctype html><html><head><style>${styles}</style></head><body>
|
||||
<section class="card chat">
|
||||
<div class="chat-workbench">
|
||||
<div class="chat-workbench__main">
|
||||
<div class="chat-split-container">
|
||||
<div class="chat-main">
|
||||
<div class="chat-main__conversation-column">
|
||||
<div class="chat-main__conversation">
|
||||
<div class="chat-thread" role="log">Conversation</div>
|
||||
<aside class="chat-swarm">
|
||||
<div class="chat-swarm__group">
|
||||
<div class="chat-swarm__header"><strong>Active swarm</strong></div>
|
||||
<div class="chat-swarm__tasks" style="visibility:visible;opacity:1;transform:none">
|
||||
${tasks}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="agent-chat__composer-shell">
|
||||
<div class="agent-chat__input agent-chat__input--chat"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</body></html>`);
|
||||
|
||||
await page.locator(".chat-swarm__group").evaluate((element) => {
|
||||
const availableHeight = element.getBoundingClientRect().top - 28;
|
||||
element.style.setProperty("--chat-composer-popover-max-height", `${availableHeight}px`);
|
||||
});
|
||||
|
||||
const layout = await page.locator(".chat-swarm__tasks").evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
clientHeight: element.clientHeight,
|
||||
overflowY: style.overflowY,
|
||||
scrollHeight: element.scrollHeight,
|
||||
top: element.getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.top).toBeGreaterThanOrEqual(0);
|
||||
expect(layout.overflowY).toBe("auto");
|
||||
expect(layout.scrollHeight).toBeGreaterThan(layout.clientHeight);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewaySessionRow } from "../../../api/types.ts";
|
||||
import { i18n } from "../../../i18n/index.ts";
|
||||
import { pt_BR } from "../../../i18n/locales/pt-BR.ts";
|
||||
import { renderChatSwarmProgress } from "./chat-swarm-progress.ts";
|
||||
|
||||
const parentSessionKey = "agent:main:parent";
|
||||
@@ -31,12 +33,36 @@ function renderProgress(sessions: readonly GatewaySessionRow[]) {
|
||||
return container;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
i18n.registerTranslation("pt-BR", pt_BR);
|
||||
await i18n.setLocale("en");
|
||||
document.body.replaceChildren();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("chat Swarm progress", () => {
|
||||
it("groups live collector children and maps their dot states", () => {
|
||||
it("renders completion progress from the active locale", async () => {
|
||||
i18n.registerTranslation("pt-BR", {
|
||||
labsPage: {
|
||||
swarm: {
|
||||
title: "Enxame",
|
||||
progress: "{complete} de {total}",
|
||||
},
|
||||
},
|
||||
});
|
||||
await i18n.setLocale("pt-BR");
|
||||
|
||||
const container = renderProgress([
|
||||
session({ key: "running", status: "running" }),
|
||||
session({ key: "done", status: "done" }),
|
||||
]);
|
||||
|
||||
expect(
|
||||
container.querySelector(".chat-swarm__header")?.textContent?.replace(/\s+/g, " "),
|
||||
).toContain("1 de 2");
|
||||
});
|
||||
|
||||
it("groups live collector children and maps their task states", () => {
|
||||
const container = renderProgress([
|
||||
session({
|
||||
key: "queued",
|
||||
@@ -57,15 +83,15 @@ describe("chat Swarm progress", () => {
|
||||
const group = container.querySelector("[data-swarm-group]");
|
||||
expect(group?.getAttribute("data-swarm-group")).toBe("swarm:agent:main:parent:turn-42");
|
||||
expect(group?.textContent).toContain("turn-42");
|
||||
expect(group?.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 1 Done · 1 Failed");
|
||||
expect([...container.querySelectorAll(".chat-swarm__dot")].map((dot) => dot.className)).toEqual(
|
||||
[
|
||||
"chat-swarm__dot chat-swarm__dot--queued",
|
||||
"chat-swarm__dot chat-swarm__dot--running",
|
||||
"chat-swarm__dot chat-swarm__dot--done",
|
||||
"chat-swarm__dot chat-swarm__dot--failed",
|
||||
],
|
||||
);
|
||||
expect(group?.textContent?.replace(/\s+/g, " ")).toContain("2 of 4");
|
||||
expect(
|
||||
[...container.querySelectorAll(".chat-swarm__task-icon")].map((icon) => icon.className),
|
||||
).toEqual([
|
||||
"chat-swarm__task-icon chat-swarm__task-icon--queued",
|
||||
"chat-swarm__task-icon chat-swarm__task-icon--running",
|
||||
"chat-swarm__task-icon chat-swarm__task-icon--done",
|
||||
"chat-swarm__task-icon chat-swarm__task-icon--failed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders every child beyond the ordinary 50-row session page", () => {
|
||||
@@ -75,10 +101,10 @@ describe("chat Swarm progress", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".chat-swarm__dot")).toHaveLength(55);
|
||||
expect(container.querySelectorAll(".chat-swarm__task")).toHaveLength(55);
|
||||
});
|
||||
|
||||
it("caps historical dots while keeping active workers visible", () => {
|
||||
it("caps historical tasks while keeping active workers visible", () => {
|
||||
const container = renderProgress([
|
||||
...Array.from({ length: 300 }, (_, index) =>
|
||||
session({ key: `done-${index}`, status: "done" }),
|
||||
@@ -86,20 +112,22 @@ describe("chat Swarm progress", () => {
|
||||
session({ key: "running", status: "running" }),
|
||||
]);
|
||||
|
||||
expect(container.querySelectorAll(".chat-swarm__dot")).toHaveLength(256);
|
||||
expect(container.querySelector(".chat-swarm__dot--running")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-swarm__more")?.textContent?.trim()).toBe("+45");
|
||||
expect(container.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 300 Done");
|
||||
expect(container.querySelectorAll(".chat-swarm__task")).toHaveLength(256);
|
||||
expect(container.querySelector(".chat-swarm__task-icon--running")).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector(".chat-swarm__header")?.textContent?.replace(/\s+/g, " "),
|
||||
).toContain("300 of 301");
|
||||
});
|
||||
|
||||
it("labels dot states and disappears when no group is active", () => {
|
||||
it("exposes a task list and disappears when no group is active", () => {
|
||||
const container = renderProgress([session({ label: "Worker A", status: "running" })]);
|
||||
|
||||
const widget = container.querySelector("[data-test-id=chat-swarm]");
|
||||
const dot = container.querySelector<HTMLElement>(".chat-swarm__dot--running");
|
||||
const task = container.querySelector<HTMLElement>(".chat-swarm__task");
|
||||
expect(widget?.getAttribute("role")).toBe("status");
|
||||
expect(widget?.getAttribute("aria-live")).toBe("off");
|
||||
expect(dot?.title).toBe("Worker A: Running");
|
||||
expect(task?.getAttribute("role")).toBe("listitem");
|
||||
expect(task?.textContent).toContain("Worker A");
|
||||
|
||||
render(renderChatSwarmProgress({ sessionKey: parentSessionKey, sessions: [] }), container);
|
||||
expect(container.querySelector("[data-test-id=chat-swarm]")).toBeNull();
|
||||
@@ -111,7 +139,9 @@ describe("chat Swarm progress", () => {
|
||||
const failed = session({ key: "failed", status: "failed", hasActiveRun: true });
|
||||
const container = renderProgress([running, completed, failed]);
|
||||
|
||||
expect(container.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 1 Done · 1 Failed");
|
||||
expect(container.querySelectorAll(".chat-swarm__task-icon--running")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".chat-swarm__task-icon--done")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".chat-swarm__task-icon--failed")).toHaveLength(1);
|
||||
|
||||
render(
|
||||
renderChatSwarmProgress({ sessionKey: parentSessionKey, sessions: [completed, failed] }),
|
||||
@@ -120,7 +150,7 @@ describe("chat Swarm progress", () => {
|
||||
expect(container.querySelector("[data-test-id=chat-swarm]")).toBeNull();
|
||||
});
|
||||
|
||||
it("buckets children by phase, labels the default bucket, and shows the latest log", () => {
|
||||
it("keeps tasks from every phase in the compact detail", () => {
|
||||
const container = renderProgress([
|
||||
session({ key: "unphased", label: "Older child", status: "running" }),
|
||||
session({ key: "planning", label: "Planner", status: "done", swarmPhase: "Plan" }),
|
||||
@@ -134,18 +164,10 @@ describe("chat Swarm progress", () => {
|
||||
]);
|
||||
|
||||
expect(
|
||||
[...container.querySelectorAll(".chat-swarm__phase")].map((phase) =>
|
||||
phase.textContent?.trim(),
|
||||
[...container.querySelectorAll(".chat-swarm__task-name")].map((task) =>
|
||||
task.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["Unphased", "Plan", "Build"]);
|
||||
expect(
|
||||
[...container.querySelectorAll(".chat-swarm__phase-row")].map(
|
||||
(row) => row.querySelectorAll(".chat-swarm__dot").length,
|
||||
),
|
||||
).toEqual([1, 1, 1]);
|
||||
expect(container.querySelector(".chat-swarm__narrator")?.textContent).toContain(
|
||||
"Implementing the selected plan.",
|
||||
);
|
||||
).toEqual(["Older child", "Planner", "Builder"]);
|
||||
});
|
||||
|
||||
it("orders phase buckets by observation rank, not canonical row order", () => {
|
||||
@@ -168,9 +190,50 @@ describe("chat Swarm progress", () => {
|
||||
]);
|
||||
|
||||
expect(
|
||||
[...container.querySelectorAll(".chat-swarm__phase")].map((phase) =>
|
||||
phase.textContent?.trim(),
|
||||
[...container.querySelectorAll(".chat-swarm__task-name")].map((task) =>
|
||||
task.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["Plan", "Build", "Unphased"]);
|
||||
).toEqual(["Planner", "Builder", "Late child"]);
|
||||
});
|
||||
|
||||
it("uses session runtime fields instead of the last row update", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(100_000);
|
||||
const container = renderProgress([
|
||||
session({
|
||||
key: "running",
|
||||
label: "Running",
|
||||
status: "running",
|
||||
startedAt: 90_000,
|
||||
updatedAt: 99_000,
|
||||
}),
|
||||
session({
|
||||
key: "sampled",
|
||||
label: "Sampled",
|
||||
status: "running",
|
||||
runtimeMs: 4_000,
|
||||
runtimeSampledAt: 98_000,
|
||||
updatedAt: 50_000,
|
||||
}),
|
||||
session({
|
||||
key: "done",
|
||||
label: "Done",
|
||||
status: "done",
|
||||
startedAt: 10_000,
|
||||
endedAt: 17_000,
|
||||
updatedAt: 99_999,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
[...container.querySelectorAll(".chat-swarm__task")].map((task) => ({
|
||||
label: task.querySelector(".chat-swarm__task-name")?.textContent,
|
||||
duration: task.querySelector(".chat-swarm__task-duration")?.textContent,
|
||||
})),
|
||||
).toEqual([
|
||||
{ label: "Running", duration: "10s" },
|
||||
{ label: "Sampled", duration: "6s" },
|
||||
{ label: "Done", duration: "7s" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { GatewaySessionRow } from "../../../api/types.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { formatDurationCompact } from "../../../lib/format.ts";
|
||||
import { isSessionRunActive } from "../../../lib/session-run-state.ts";
|
||||
import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts";
|
||||
import { replaceComposerPopoverAnchor } from "./chat-composer-dom.ts";
|
||||
|
||||
type SwarmDotStatus = "queued" | "running" | "done" | "failed";
|
||||
|
||||
@@ -13,43 +17,25 @@ type SwarmDot = {
|
||||
key: string;
|
||||
label: string;
|
||||
status: SwarmDotStatus;
|
||||
duration: string;
|
||||
};
|
||||
|
||||
type SwarmPhase = {
|
||||
title?: string;
|
||||
dots: SwarmDot[];
|
||||
hidden: number;
|
||||
};
|
||||
|
||||
type SwarmGroup = {
|
||||
groupId: string;
|
||||
label: string;
|
||||
running: number;
|
||||
done: number;
|
||||
failed: number;
|
||||
narrator?: string;
|
||||
phases: SwarmPhase[];
|
||||
};
|
||||
|
||||
type SwarmPhaseCarrier = {
|
||||
swarmLog?: unknown;
|
||||
swarmPhase?: unknown;
|
||||
swarmPhaseRank?: unknown;
|
||||
};
|
||||
|
||||
function swarmStatusLabel(status: SwarmDotStatus): string {
|
||||
if (status === "queued") {
|
||||
return t("tasksPage.status.queued");
|
||||
}
|
||||
if (status === "running") {
|
||||
return t("tasksPage.status.running");
|
||||
}
|
||||
if (status === "done") {
|
||||
return t("activity.status.done");
|
||||
}
|
||||
return t("tasksPage.status.failed");
|
||||
}
|
||||
|
||||
function swarmDotStatus(row: GatewaySessionRow): SwarmDotStatus | null {
|
||||
if (row.status === "queued") {
|
||||
return "queued";
|
||||
@@ -80,11 +66,6 @@ function swarmPhase(row: GatewaySessionRow): string | undefined {
|
||||
return typeof phase === "string" && phase.trim() ? phase.trim() : undefined;
|
||||
}
|
||||
|
||||
function swarmLog(row: GatewaySessionRow): string | undefined {
|
||||
const log = (row as GatewaySessionRow & SwarmPhaseCarrier).swarmLog;
|
||||
return typeof log === "string" && log.trim() ? log.trim() : undefined;
|
||||
}
|
||||
|
||||
function isSwarmChildForSession(row: GatewaySessionRow, sessionKey: string): boolean {
|
||||
if (
|
||||
(row.parentSessionKey && areUiSessionKeysEquivalent(row.parentSessionKey, sessionKey)) ||
|
||||
@@ -96,14 +77,27 @@ function isSwarmChildForSession(row: GatewaySessionRow, sessionKey: string): boo
|
||||
return Boolean(owner && areUiSessionKeysEquivalent(owner, sessionKey));
|
||||
}
|
||||
|
||||
function swarmDuration(row: GatewaySessionRow, status: SwarmDotStatus): string {
|
||||
if (status === "queued") {
|
||||
return "—";
|
||||
}
|
||||
let durationMs = row.runtimeMs;
|
||||
if (durationMs != null && status === "running" && row.runtimeSampledAt != null) {
|
||||
durationMs += Math.max(0, Date.now() - row.runtimeSampledAt);
|
||||
} else if (durationMs == null && row.startedAt != null) {
|
||||
const endAt = row.endedAt ?? (status === "running" ? Date.now() : undefined);
|
||||
if (endAt != null) {
|
||||
durationMs = Math.max(0, endAt - row.startedAt);
|
||||
}
|
||||
}
|
||||
return formatDurationCompact(durationMs) ?? "—";
|
||||
}
|
||||
|
||||
function collectActiveSwarmGroups(
|
||||
sessions: readonly GatewaySessionRow[],
|
||||
sessionKey: string,
|
||||
): SwarmGroup[] {
|
||||
const byGroup = new Map<
|
||||
string,
|
||||
Array<{ phase?: string; phaseRank: number; log?: string; dot: SwarmDot }>
|
||||
>();
|
||||
const byGroup = new Map<string, Array<{ phase?: string; phaseRank: number; dot: SwarmDot }>>();
|
||||
for (const row of sessions) {
|
||||
const groupId = row.swarmGroupId?.trim();
|
||||
if (!groupId || !isSwarmChildForSession(row, sessionKey)) {
|
||||
@@ -117,11 +111,11 @@ function collectActiveSwarmGroups(
|
||||
entries.push({
|
||||
phase: swarmPhase(row),
|
||||
phaseRank: swarmPhaseRank(row),
|
||||
log: swarmLog(row),
|
||||
dot: {
|
||||
key: row.key,
|
||||
label: row.label?.trim() || row.displayName?.trim() || row.derivedTitle?.trim() || row.key,
|
||||
status,
|
||||
duration: swarmDuration(row, status),
|
||||
},
|
||||
});
|
||||
byGroup.set(groupId, entries);
|
||||
@@ -129,7 +123,6 @@ function collectActiveSwarmGroups(
|
||||
|
||||
return [...byGroup.entries()]
|
||||
.map(([groupId, entries]) => {
|
||||
const dots = entries.map((entry) => entry.dot);
|
||||
const phases = new Map<string | undefined, { rank: number; dots: SwarmDot[] }>();
|
||||
for (const entry of entries) {
|
||||
const bucket = phases.get(entry.phase) ?? { rank: entry.phaseRank, dots: [] };
|
||||
@@ -140,10 +133,6 @@ function collectActiveSwarmGroups(
|
||||
return {
|
||||
groupId,
|
||||
label: groupTail(groupId),
|
||||
running: dots.filter((dot) => dot.status === "running").length,
|
||||
done: dots.filter((dot) => dot.status === "done").length,
|
||||
failed: dots.filter((dot) => dot.status === "failed").length,
|
||||
narrator: entries.map((entry) => entry.log).find(Boolean),
|
||||
phases: [...phases.entries()]
|
||||
.toSorted((left, right) => left[1].rank - right[1].rank)
|
||||
.map(([title, bucket]) => {
|
||||
@@ -156,8 +145,7 @@ function collectActiveSwarmGroups(
|
||||
: bucket.dots;
|
||||
return {
|
||||
title,
|
||||
dots: visibleFirst.slice(0, MAX_RENDERED_DOTS_PER_PHASE),
|
||||
hidden: Math.max(0, visibleFirst.length - MAX_RENDERED_DOTS_PER_PHASE),
|
||||
dots: visibleFirst,
|
||||
};
|
||||
}),
|
||||
} satisfies SwarmGroup;
|
||||
@@ -189,46 +177,77 @@ export function renderChatSwarmProgress({
|
||||
aria-live="off"
|
||||
aria-label=${t("labsPage.swarm.title")}
|
||||
>
|
||||
${groups.map(
|
||||
(group) => html`
|
||||
<div class="chat-swarm__group" data-swarm-group=${group.groupId}>
|
||||
${groups.map((group) => {
|
||||
const allTasks = group.phases.flatMap((phase) => phase.dots);
|
||||
const tasks = group.phases.flatMap((phase) =>
|
||||
phase.dots.slice(0, MAX_RENDERED_DOTS_PER_PHASE),
|
||||
);
|
||||
const complete = allTasks.filter(
|
||||
(task) => task.status === "done" || task.status === "failed",
|
||||
).length;
|
||||
const hasFailure = allTasks.some((task) => task.status === "failed");
|
||||
let popoverAnchor: HTMLElement | null = null;
|
||||
return html`
|
||||
<div
|
||||
class="chat-swarm__group ${hasFailure ? "chat-swarm__group--failed" : ""}"
|
||||
data-swarm-group=${group.groupId}
|
||||
tabindex="0"
|
||||
${ref((element) => {
|
||||
popoverAnchor = replaceComposerPopoverAnchor(popoverAnchor, element);
|
||||
})}
|
||||
>
|
||||
<div class="chat-swarm__header">
|
||||
<strong title=${group.groupId}>${group.label}</strong>
|
||||
<span
|
||||
>${group.running} ${swarmStatusLabel("running")} · ${group.done}
|
||||
${swarmStatusLabel("done")} · ${group.failed} ${swarmStatusLabel("failed")}</span
|
||||
>${t("labsPage.swarm.progress", {
|
||||
complete: String(complete),
|
||||
total: String(allTasks.length),
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
${group.narrator
|
||||
? html`<div class="chat-swarm__narrator">${group.narrator}</div>`
|
||||
: nothing}
|
||||
${group.phases.map(
|
||||
(phase) => html`
|
||||
<div class="chat-swarm__phase-row">
|
||||
<div class="chat-swarm__phase">
|
||||
${phase.title ?? t("labsPage.swarm.defaultPhase")}
|
||||
<div class="chat-swarm__progress" aria-hidden="true">
|
||||
${group.phases.map((phase) => {
|
||||
const phaseProgress =
|
||||
phase.dots.length === 0
|
||||
? 0
|
||||
: Math.round(
|
||||
(phase.dots.filter(
|
||||
(task) => task.status === "done" || task.status === "failed",
|
||||
).length /
|
||||
phase.dots.length) *
|
||||
100,
|
||||
);
|
||||
const phaseFailed = phase.dots.some((task) => task.status === "failed");
|
||||
return html`<span class="chat-swarm__progress-segment">
|
||||
<span
|
||||
class=${phaseFailed ? "chat-swarm__progress-fill--failed" : ""}
|
||||
style=${`width:${phaseProgress}%`}
|
||||
></span>
|
||||
</span>`;
|
||||
})}
|
||||
</div>
|
||||
<div class="chat-swarm__tasks" role="list">
|
||||
${tasks.map(
|
||||
(task) => html`
|
||||
<div class="chat-swarm__task" role="listitem">
|
||||
<span class=${`chat-swarm__task-icon chat-swarm__task-icon--${task.status}`}>
|
||||
${task.status === "done"
|
||||
? icons.check
|
||||
: task.status === "failed"
|
||||
? icons.alertTriangle
|
||||
: task.status === "running"
|
||||
? icons.loader
|
||||
: icons.clock}
|
||||
</span>
|
||||
<span class="chat-swarm__task-name">${task.label}</span>
|
||||
<span class="chat-swarm__task-duration">${task.duration}</span>
|
||||
</div>
|
||||
<div class="chat-swarm__dots" role="list">
|
||||
${phase.dots.map(
|
||||
(dot) => html`
|
||||
<span
|
||||
class=${`chat-swarm__dot chat-swarm__dot--${dot.status}`}
|
||||
role="listitem"
|
||||
title=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
|
||||
aria-label=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
|
||||
></span>
|
||||
`,
|
||||
)}
|
||||
${phase.hidden > 0
|
||||
? html`<span class="chat-swarm__more" role="listitem">+${phase.hidden}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { ChatRunStartupStatus } from "../chat-run-startup.ts";
|
||||
import { resetChatThreadState } from "../chat-thread.ts";
|
||||
import type { LinkFaviconFetcher } from "../link-favicon-loader.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
|
||||
import type { ChatRunUiStatus } from "../run-lifecycle.ts";
|
||||
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
|
||||
import type { ArtifactDownloadResolver } from "./chat-message-media.ts";
|
||||
import {
|
||||
@@ -77,6 +78,7 @@ export type ChatThreadProps = {
|
||||
streamStartedAt: number | null;
|
||||
runId?: string | null;
|
||||
runOutputTokens?: number | null;
|
||||
runStatus?: ChatRunUiStatus | null;
|
||||
queue: ChatQueueItem[];
|
||||
showThinking: boolean;
|
||||
showToolCalls: boolean;
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
} from "./chat-transcript-render-guard.ts";
|
||||
import { renderChatTypingIndicator } from "./chat-typing-indicator.ts";
|
||||
import { resolveAssistantDisplayAvatar } from "./chat-welcome.ts";
|
||||
import { renderTurnRecapRow } from "./chat-working-indicator.ts";
|
||||
import { renderTurnRecapRow, renderTurnTerminalStatusRow } from "./chat-working-indicator.ts";
|
||||
|
||||
type ChatTranscriptProjection = {
|
||||
isDirectThread: boolean;
|
||||
@@ -596,6 +596,13 @@ export function projectChatTranscript(
|
||||
: [],
|
||||
),
|
||||
);
|
||||
if (props.runStatus?.phase === "interrupted") {
|
||||
transcriptRows.push({
|
||||
kind: "content",
|
||||
key: `interrupted:${props.runStatus.occurredAt}`,
|
||||
content: renderTurnTerminalStatusRow("interrupted"),
|
||||
});
|
||||
}
|
||||
const realtimeConversation = renderRealtimeTalkConversation(props);
|
||||
if (realtimeConversation !== nothing) {
|
||||
transcriptRows.push({
|
||||
@@ -624,11 +631,7 @@ export function projectChatTranscript(
|
||||
}
|
||||
const typingIndicator = renderChatTypingIndicator(props.typingActors);
|
||||
if (typingIndicator) {
|
||||
transcriptRows.push({
|
||||
kind: "content",
|
||||
key: "presence:typing",
|
||||
content: typingIndicator,
|
||||
});
|
||||
transcriptRows.push({ kind: "content", key: "presence:typing", content: typingIndicator });
|
||||
}
|
||||
trackTranscriptRenderDependencies(state, [
|
||||
chatItems,
|
||||
@@ -662,7 +665,6 @@ export function projectChatTranscript(
|
||||
props.userId,
|
||||
props.userName,
|
||||
props.userAvatar,
|
||||
props.typingActors,
|
||||
props.resourceBasePath,
|
||||
(props.localMediaPreviewRoots ?? []).join("\u0000"),
|
||||
props.assistantAttachmentAuthToken,
|
||||
@@ -676,6 +678,8 @@ export function projectChatTranscript(
|
||||
props.replyMessageAccess?.revision ?? 0,
|
||||
props.replyMessageAccess?.navigationId ?? "",
|
||||
turnRecap === null ? "" : `${turnRecap.runtimeMs}:${turnRecap.outputTokens ?? ""}`,
|
||||
props.runStatus?.phase ?? "",
|
||||
props.runStatus?.occurredAt ?? 0,
|
||||
]);
|
||||
state.transcriptRenderContext.onSetReply = props.onSetReply;
|
||||
state.transcriptRenderContext.onOpenReply = (replyToId) => {
|
||||
|
||||
@@ -108,6 +108,72 @@ describe("chat transcript rendering", () => {
|
||||
transcript.hostDisconnected();
|
||||
});
|
||||
|
||||
it("renders interrupted status after a partial assistant reply", async () => {
|
||||
const transcript = createTestTranscript();
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
const props = {
|
||||
...threadProps("pane-interrupted", "agent:main:main", [
|
||||
{
|
||||
role: "user",
|
||||
content: "Start the task",
|
||||
timestamp: 1_000,
|
||||
__openclaw: { idempotencyKey: "run-1:user" },
|
||||
},
|
||||
{ role: "assistant", content: "Partial response", timestamp: 2_000 },
|
||||
]),
|
||||
runStatus: {
|
||||
phase: "interrupted" as const,
|
||||
runId: "run-1",
|
||||
sessionKey: "agent:main:main",
|
||||
occurredAt: 3_000,
|
||||
},
|
||||
};
|
||||
|
||||
render(renderChatThread(props, transcript), container);
|
||||
transcript.hostConnected();
|
||||
transcript.hostUpdated();
|
||||
await flushDeferredRowPrune();
|
||||
|
||||
const status = requireElement(container, ".chat-turn-terminal-status--interrupted");
|
||||
expect(status.textContent).toContain("Interrupted");
|
||||
expect(status.closest(".chat-group")).toBeNull();
|
||||
transcript.hostDisconnected();
|
||||
});
|
||||
|
||||
it("renders interrupted status after the current turn when it has no assistant reply", async () => {
|
||||
const transcript = createTestTranscript();
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
const props = {
|
||||
...threadProps("pane-interrupted-empty", "agent:main:main", [
|
||||
{ role: "user", content: "Earlier task", timestamp: 1_000 },
|
||||
{ role: "assistant", content: "Earlier reply", timestamp: 2_000 },
|
||||
{
|
||||
role: "user",
|
||||
content: "Stop this task",
|
||||
timestamp: 3_000,
|
||||
__openclaw: { idempotencyKey: "run-2:user" },
|
||||
},
|
||||
]),
|
||||
runStatus: {
|
||||
phase: "interrupted" as const,
|
||||
runId: "run-2",
|
||||
sessionKey: "agent:main:main",
|
||||
occurredAt: 4_000,
|
||||
},
|
||||
};
|
||||
|
||||
render(renderChatThread(props, transcript), container);
|
||||
transcript.hostConnected();
|
||||
transcript.hostUpdated();
|
||||
await flushDeferredRowPrune();
|
||||
|
||||
const status = requireElement(container, ".chat-turn-terminal-status--interrupted");
|
||||
expect(status.textContent).toContain("Interrupted");
|
||||
expect(status.closest(".chat-group")).toBeNull();
|
||||
expect(container.querySelector(".chat-group.assistant")?.contains(status)).toBe(false);
|
||||
transcript.hostDisconnected();
|
||||
});
|
||||
|
||||
it("reveals touched metadata across stored and live groups within one transcript", async () => {
|
||||
const firstTranscript = createTestTranscript();
|
||||
const secondTranscript = createTestTranscript();
|
||||
|
||||
@@ -8,16 +8,16 @@ export function renderChatTypingIndicator(
|
||||
if (!actors?.length) {
|
||||
return null;
|
||||
}
|
||||
const statusText =
|
||||
const previews = actors.filter((actor) => actor.preview?.trim());
|
||||
const indicators = actors.filter((actor) => !actor.preview?.trim());
|
||||
const status =
|
||||
actors.length === 1
|
||||
? t("chat.sessionSuggestions.typing", { name: actors[0]?.label ?? "" })
|
||||
: t("chat.sessionSuggestions.typingMany", {
|
||||
names: actors.map((actor) => actor.label).join(", "),
|
||||
});
|
||||
const previewActors = actors.filter((actor) => actor.preview?.trim());
|
||||
const indicatorActors = actors.filter((actor) => !actor.preview?.trim());
|
||||
return html`<div class="agent-chat__typing-indicator">
|
||||
${previewActors.map(
|
||||
return html`<div class="agent-chat__typing-indicator agent-chat__typing-indicator--outside">
|
||||
${previews.map(
|
||||
(actor) => html`<div class="agent-chat__typing-preview-row">
|
||||
${renderChatAvatar("user", undefined, undefined, undefined, undefined, {
|
||||
id: actor.id,
|
||||
@@ -31,10 +31,10 @@ export function renderChatTypingIndicator(
|
||||
</div>
|
||||
</div>`,
|
||||
)}
|
||||
${indicatorActors.length
|
||||
${indicators.length
|
||||
? html`<div class="agent-chat__typing-dots-row">
|
||||
<span class="agent-chat__typing-avatars" aria-hidden="true">
|
||||
${indicatorActors.slice(0, 3).map((actor) =>
|
||||
${indicators.slice(0, 3).map((actor) =>
|
||||
renderChatAvatar("user", undefined, undefined, undefined, undefined, {
|
||||
id: actor.id,
|
||||
name: actor.label,
|
||||
@@ -49,6 +49,6 @@ export function renderChatTypingIndicator(
|
||||
</span>
|
||||
</div>`
|
||||
: null}
|
||||
<span class="sr-only" role="status">${statusText}</span>
|
||||
<span class="sr-only" role="status">${status}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
@@ -9,9 +10,24 @@ const BAR_GAINS = [0.38, 0.62, 0.84, 1, 0.84, 0.62, 0.38];
|
||||
const MICROPHONE_ACTIVITY_TAG = "openclaw-microphone-activity";
|
||||
const EMPTY_LEVEL_SIGNAL = new RealtimeTalkLevelSignal();
|
||||
|
||||
// Wider meters ask for more bars via the `bars` attribute; the default 7-bar
|
||||
// profile stays byte-identical for the talk button observed by its e2e suite.
|
||||
function activityBarGains(count: number): number[] {
|
||||
if (count === BAR_GAINS.length) {
|
||||
return BAR_GAINS;
|
||||
}
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => 0.35 + 0.65 * Math.sin(Math.PI * ((index + 0.5) / count)),
|
||||
);
|
||||
}
|
||||
|
||||
class MicrophoneActivityElement extends HTMLElement {
|
||||
private levelSignal: RealtimeTalkLevelSignal | undefined;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private gains: number[] = BAR_GAINS;
|
||||
// Scroll mode renders a right-to-left history, newest on the right.
|
||||
private history: number[] | null = null;
|
||||
|
||||
set signal(signal: RealtimeTalkLevelSignal | undefined) {
|
||||
if (signal === this.levelSignal) {
|
||||
@@ -39,7 +55,12 @@ class MicrophoneActivityElement extends HTMLElement {
|
||||
|
||||
private ensureBars(): void {
|
||||
if (!this.firstElementChild) {
|
||||
for (const [index] of BAR_GAINS.entries()) {
|
||||
const requested = Number(this.getAttribute("bars"));
|
||||
this.gains = activityBarGains(
|
||||
Number.isInteger(requested) && requested > 0 ? requested : BAR_GAINS.length,
|
||||
);
|
||||
this.history = this.getAttribute("mode") === "scroll" ? this.gains.map(() => 0) : null;
|
||||
for (const [index] of this.gains.entries()) {
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "agent-chat__voice-activity-bar";
|
||||
bar.style.setProperty("--talk-bar-delay", `${index * -70}ms`);
|
||||
@@ -55,12 +76,15 @@ class MicrophoneActivityElement extends HTMLElement {
|
||||
|
||||
private renderLevel(level: number): void {
|
||||
this.dataset.level = String(level);
|
||||
if (this.history) {
|
||||
this.history.push(level);
|
||||
this.history.shift();
|
||||
}
|
||||
for (const [index, bar] of [...this.children].entries()) {
|
||||
const gain = BAR_GAINS[index] ?? 1;
|
||||
(bar as HTMLElement).style.setProperty(
|
||||
"--talk-bar-scale",
|
||||
String(0.18 + level * gain * 0.82),
|
||||
);
|
||||
const scale = this.history
|
||||
? 0.12 + (this.history[index] ?? 0) * 0.88
|
||||
: 0.18 + level * (this.gains[index] ?? 1) * 0.82;
|
||||
(bar as HTMLElement).style.setProperty("--talk-bar-scale", String(scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +119,8 @@ export function voiceStatusLabel(
|
||||
type MicrophoneActivityProps = {
|
||||
status?: RealtimeTalkStatus;
|
||||
inputLevel?: RealtimeTalkLevelSignal;
|
||||
bars?: number;
|
||||
mode?: "scroll";
|
||||
};
|
||||
|
||||
// Class names and data attributes are asserted by the talk e2e suite; the
|
||||
@@ -106,6 +132,8 @@ export function renderMicrophoneActivity(props: MicrophoneActivityProps): Templa
|
||||
class="agent-chat__voice-activity"
|
||||
data-status=${activeStatus(props.status)}
|
||||
data-source="microphone"
|
||||
bars=${ifDefined(props.bars)}
|
||||
mode=${ifDefined(props.mode)}
|
||||
aria-hidden="true"
|
||||
.signal=${props.inputLevel ?? EMPTY_LEVEL_SIGNAL}
|
||||
>
|
||||
@@ -124,20 +152,19 @@ export function renderChatVoiceError(props: ChatVoiceErrorProps): TemplateResult
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="agent-chat__stt-interim agent-chat__talk-status" role="alert">
|
||||
<div class="agent-chat__composer-error agent-chat__talk-status" role="alert">
|
||||
<span class="agent-chat__composer-error-icon" aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<span class="agent-chat__talk-status-text">${props.detail}</span>
|
||||
${props.onDismissError
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.composer.dismissVoiceInputError")}>
|
||||
<button
|
||||
class="callout__dismiss"
|
||||
type="button"
|
||||
@click=${props.onDismissError}
|
||||
aria-label=${t("chat.composer.dismissVoiceInputError")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<button
|
||||
class="callout__dismiss"
|
||||
type="button"
|
||||
@click=${props.onDismissError}
|
||||
aria-label=${t("chat.composer.dismissVoiceInputError")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,10 @@ type ChatWelcomeProps = {
|
||||
hint?: unknown;
|
||||
/** Rendered between the hero and the recents (the new-session draft composer). */
|
||||
composer?: unknown;
|
||||
/** Hide recents and suggestions when the surrounding flow must stay ephemeral. */
|
||||
hideSecondaryContent?: boolean;
|
||||
/** Visually retire secondary content while the new-session draft is active. */
|
||||
fadeSecondaryContent?: boolean;
|
||||
sessions?: SessionsListResult | null;
|
||||
sessionKey?: string;
|
||||
sessionHost?: UiSessionDefaultsHost | null;
|
||||
@@ -157,15 +161,19 @@ function renderWelcomeHero(
|
||||
const avatar = resolveAssistantAvatarUrl(props);
|
||||
const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
return html`
|
||||
${avatar
|
||||
? html`<img class="agent-chat__welcome-avatar" src=${avatar} alt=${name} />`
|
||||
: avatarText
|
||||
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
|
||||
${avatarText}
|
||||
</div>`
|
||||
: renderWelcomeClawd()}
|
||||
<h2>${name}</h2>
|
||||
<p class="agent-chat__hint">${props.hint}</p>
|
||||
<div class="agent-chat__welcome-identity">
|
||||
${avatar
|
||||
? html`<img class="agent-chat__welcome-avatar" src=${avatar} alt=${name} />`
|
||||
: avatarText
|
||||
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
|
||||
${avatarText}
|
||||
</div>`
|
||||
: renderWelcomeClawd()}
|
||||
<div class="agent-chat__welcome-identity-copy">
|
||||
<h2>${name}</h2>
|
||||
<p class="agent-chat__hint">${props.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -236,9 +244,21 @@ export function renderWelcomeState(props: ChatWelcomeProps) {
|
||||
)}`,
|
||||
})}
|
||||
${props.composer ?? nothing}
|
||||
${recentSessions.length > 0
|
||||
? renderWelcomeRecentSessions(recentSessions, props.onOpenSession)
|
||||
: renderWelcomeSuggestions(props)}
|
||||
${props.hideSecondaryContent
|
||||
? nothing
|
||||
: html`<div
|
||||
class="agent-chat__welcome-secondary ${props.fadeSecondaryContent
|
||||
? "agent-chat__welcome-secondary--hidden"
|
||||
: ""}"
|
||||
aria-hidden=${props.fadeSecondaryContent ? "true" : "false"}
|
||||
?inert=${props.fadeSecondaryContent}
|
||||
>
|
||||
<div class="agent-chat__welcome-secondary-inner">
|
||||
${recentSessions.length > 0
|
||||
? renderWelcomeRecentSessions(recentSessions, props.onOpenSession)
|
||||
: renderWelcomeSuggestions(props)}
|
||||
</div>
|
||||
</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -46,12 +46,19 @@ export function renderPlacementStartupStatus(
|
||||
}
|
||||
if (status.phase === "failed") {
|
||||
return html`
|
||||
<div class="chat-error chat-cloud-startup-error" role="alert">
|
||||
<span class="chat-error__dot" aria-hidden="true"></span>
|
||||
<span class="chat-error__content"
|
||||
>${t("newSession.placementStartFailed", {
|
||||
error: status.error ?? t("newSession.createFailed"),
|
||||
})}</span
|
||||
<div
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--danger chat-cloud-startup-error"
|
||||
role="alert"
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__icon" aria-hidden="true"
|
||||
>${icons.alertTriangle}</span
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__copy"
|
||||
><strong
|
||||
>${t("newSession.placementStartFailed", {
|
||||
error: status.error ?? t("newSession.createFailed"),
|
||||
})}</strong
|
||||
></span
|
||||
>
|
||||
${status.retryable && onRetry
|
||||
? html`<button class="btn btn--sm" type="button" @click=${onRetry}>
|
||||
@@ -62,10 +69,16 @@ export function renderPlacementStartupStatus(
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-working-indicator chat-cloud-startup" role="status" aria-live="polite">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">${icons.claw}</div>
|
||||
<span class="chat-working-indicator__status">
|
||||
<span>${placementStartupStatusLabel(status)}</span>
|
||||
<div
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--info chat-cloud-startup"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__icon chat-cloud-startup__spinner" aria-hidden="true"
|
||||
>${icons.loader}</span
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__copy">
|
||||
<strong>${placementStartupStatusLabel(status)}</strong>
|
||||
<openclaw-elapsed-time
|
||||
class="chat-working-indicator__elapsed"
|
||||
.startMs=${status.startedAt}
|
||||
@@ -75,6 +88,15 @@ export function renderPlacementStartupStatus(
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderTurnTerminalStatusRow(status: "interrupted") {
|
||||
return html`
|
||||
<div class="chat-turn-terminal-status chat-turn-terminal-status--${status}" role="status">
|
||||
<span aria-hidden="true">${icons.stop}</span>
|
||||
<span>${t("chat.composer.runInterrupted")}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatTurnRecapDuration(ms: number): string {
|
||||
let remainingSeconds = Math.max(1, Math.round(ms / 1_000));
|
||||
const locale = i18n.getLocale();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "../../../i18n/index.ts";
|
||||
import { pt_BR } from "../../../i18n/locales/pt-BR.ts";
|
||||
import { renderWorkspaceConflictNotice } from "./chat-workspace-conflict.ts";
|
||||
|
||||
const conflict = {
|
||||
paths: ["src/local.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-test",
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
i18n.registerTranslation("pt-BR", pt_BR);
|
||||
await i18n.setLocale("en");
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
Reflect.deleteProperty(document, "execCommand");
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("workspace conflict copy actions", () => {
|
||||
it("renders the compact summary and command disclosure from the active locale", async () => {
|
||||
i18n.registerTranslation("pt-BR", {
|
||||
chat: {
|
||||
workspaceConflict: {
|
||||
titleOne: "1 conflito de workspace na nuvem",
|
||||
summary: "Versoes locais mantidas.",
|
||||
showCommands: "Mostrar comandos",
|
||||
inspectCloud: "Inspecionar versao da nuvem",
|
||||
takeCloud: "Usar versao da nuvem",
|
||||
stagedResult: "Resultado preparado",
|
||||
copyStagedResult: "Copiar referencia",
|
||||
copyInspectCommand: "Copiar comando de inspecao",
|
||||
copyTakeCommand: "Copiar comando da nuvem",
|
||||
commandHelp: "Execute estes comandos.",
|
||||
},
|
||||
},
|
||||
});
|
||||
await i18n.setLocale("pt-BR");
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
|
||||
render(renderWorkspaceConflictNotice({ conflict }), container);
|
||||
|
||||
expect(container.querySelector(".chat-composer-neighbor-card__copy")?.textContent).toContain(
|
||||
"Versoes locais mantidas.",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(".chat-workspace-conflict-commands-disclosure summary")?.textContent,
|
||||
).toBe("Mostrar comandos");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ copied: true, expected: "Copied!" },
|
||||
{ copied: false, expected: "Copy failed" },
|
||||
])("shows visible feedback when clipboard success is $copied", async ({ copied, expected }) => {
|
||||
const writeText = copied
|
||||
? vi.fn().mockResolvedValue(undefined)
|
||||
: vi.fn().mockRejectedValue(new DOMException("Clipboard access denied"));
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
render(renderWorkspaceConflictNotice({ conflict }), container);
|
||||
|
||||
const button = [...container.querySelectorAll<HTMLButtonElement>("button")].find((candidate) =>
|
||||
candidate.textContent?.includes("Inspect the first cloud version"),
|
||||
);
|
||||
button?.click();
|
||||
|
||||
await vi.waitFor(() => expect(button?.textContent?.trim()).toBe(expected));
|
||||
expect(button?.getAttribute("aria-label")).toBe(expected);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"git show 'refs/openclaw/worker-results/claim-test:src/local.ts'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { renderCopyButton } from "../../../components/copy-button.ts";
|
||||
import { handleCopyButton, renderCopyButton } from "../../../components/copy-button.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import {
|
||||
@@ -10,6 +10,17 @@ import {
|
||||
type WorkspaceResultConflict,
|
||||
} from "../workspace-conflict.ts";
|
||||
|
||||
function renderConflictCopyAction(text: string, label: string) {
|
||||
return html`<button
|
||||
class="btn btn--sm chat-copy-btn"
|
||||
type="button"
|
||||
aria-label=${label}
|
||||
@click=${(event: Event) => void handleCopyButton(event, text, label)}
|
||||
>
|
||||
<span data-copy-label>${label}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
export function renderWorkspaceConflictNotice(props: {
|
||||
conflict?: WorkspaceResultConflict;
|
||||
onDismiss?: () => void;
|
||||
@@ -26,73 +37,100 @@ export function renderWorkspaceConflictNotice(props: {
|
||||
{ count: String(count) },
|
||||
);
|
||||
return html`
|
||||
<div class="callout warn callout--dismissible chat-workspace-conflict-notice" role="status">
|
||||
<div class="callout__content chat-workspace-conflict-notice__content">
|
||||
<div class="chat-workspace-conflict-notice__title">
|
||||
<span aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<details
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--warn chat-workspace-conflict-notice"
|
||||
role="status"
|
||||
>
|
||||
<summary class="chat-workspace-conflict-notice__summary">
|
||||
<span class="chat-composer-neighbor-card__icon" aria-hidden="true"
|
||||
>${icons.alertTriangle}</span
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__copy">
|
||||
<strong>${title}</strong>
|
||||
</div>
|
||||
<p>${t("chat.workspaceConflict.description")}</p>
|
||||
<span>${t("chat.workspaceConflict.summary")}</span>
|
||||
</span>
|
||||
<span class="chat-workspace-conflict-notice__chevron" aria-hidden="true"
|
||||
>${icons.chevronUp}</span
|
||||
>
|
||||
${props.onDismiss
|
||||
? html`<button
|
||||
class="chat-error__dismiss"
|
||||
type="button"
|
||||
@click=${(event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
props.onDismiss?.();
|
||||
}}
|
||||
aria-label=${t("chat.workspaceConflict.dismiss")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>`
|
||||
: nothing}
|
||||
</summary>
|
||||
<div class="chat-workspace-conflict-notice__content">
|
||||
<ul class="chat-workspace-conflict-paths">
|
||||
${visible.paths.map(
|
||||
(entryPath) =>
|
||||
html`<li><code>${workspaceConflictPathForDisplay(entryPath)}</code></li>`,
|
||||
)}
|
||||
${visible.paths.map((entryPath) => {
|
||||
const entryCommands = workspaceConflictGitCommands(conflict, entryPath);
|
||||
return html`<li>
|
||||
<code>${workspaceConflictPathForDisplay(entryPath)}</code>
|
||||
${entryCommands
|
||||
? html`<span class="chat-workspace-conflict-path-actions">
|
||||
${renderConflictCopyAction(
|
||||
entryCommands.inspect,
|
||||
t("chat.workspaceConflict.inspectCloud"),
|
||||
)}
|
||||
${renderConflictCopyAction(
|
||||
entryCommands.takeCloud,
|
||||
t("chat.workspaceConflict.takeCloud"),
|
||||
)}
|
||||
</span>`
|
||||
: nothing}
|
||||
</li>`;
|
||||
})}
|
||||
</ul>
|
||||
${visible.remaining > 0
|
||||
? html`<div class="chat-workspace-conflict-more">
|
||||
${t("chat.workspaceConflict.morePaths", { count: String(visible.remaining) })}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="chat-workspace-conflict-ref">
|
||||
<span>${t("chat.workspaceConflict.stagedResult")}</span>
|
||||
<code>${conflict.stagedResultRef}</code>
|
||||
${renderCopyButton(
|
||||
conflict.stagedResultRef,
|
||||
t("chat.workspaceConflict.copyStagedResult"),
|
||||
)}
|
||||
</div>
|
||||
${commands
|
||||
? html`<div class="chat-workspace-conflict-commands">
|
||||
<div>
|
||||
<span>${t("chat.workspaceConflict.inspectCloud")}</span>
|
||||
<code>${commands.inspect}</code>
|
||||
${renderCopyButton(
|
||||
commands.inspect,
|
||||
t("chat.workspaceConflict.copyInspectCommand"),
|
||||
)}
|
||||
<details class="chat-workspace-conflict-commands-disclosure">
|
||||
<summary>${t("chat.workspaceConflict.showCommands")}</summary>
|
||||
<div class="chat-workspace-conflict-ref">
|
||||
<span>${t("chat.workspaceConflict.stagedResult")}</span>
|
||||
<code>${conflict.stagedResultRef}</code>
|
||||
${renderCopyButton(
|
||||
conflict.stagedResultRef,
|
||||
t("chat.workspaceConflict.copyStagedResult"),
|
||||
)}
|
||||
</div>
|
||||
${commands
|
||||
? html`<div class="chat-workspace-conflict-commands">
|
||||
<div>
|
||||
<span>${t("chat.workspaceConflict.inspectCloud")}</span>
|
||||
<code>${commands.inspect}</code>
|
||||
${renderCopyButton(
|
||||
commands.inspect,
|
||||
t("chat.workspaceConflict.copyInspectCommand"),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span>${t("chat.workspaceConflict.takeCloud")}</span>
|
||||
<code>${commands.takeCloud}</code>
|
||||
${renderCopyButton(
|
||||
commands.takeCloud,
|
||||
t("chat.workspaceConflict.copyTakeCommand"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>${t("chat.workspaceConflict.takeCloud")}</span>
|
||||
<code>${commands.takeCloud}</code>
|
||||
${renderCopyButton(
|
||||
commands.takeCloud,
|
||||
t("chat.workspaceConflict.copyTakeCommand"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p class="chat-workspace-conflict-command-help">
|
||||
${t("chat.workspaceConflict.commandHelp")}
|
||||
</p>`
|
||||
: html`<p class="chat-workspace-conflict-command-help">
|
||||
${t("chat.workspaceConflict.commandsUnavailable")}
|
||||
</p>`}
|
||||
<p class="chat-workspace-conflict-command-help">
|
||||
${t("chat.workspaceConflict.commandHelp")}
|
||||
</p>`
|
||||
: html`<p class="chat-workspace-conflict-command-help">
|
||||
${t("chat.workspaceConflict.commandsUnavailable")}
|
||||
</p>`}
|
||||
</details>
|
||||
</div>
|
||||
${props.onDismiss
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.workspaceConflict.dismiss")}>
|
||||
<button
|
||||
class="callout__dismiss"
|
||||
type="button"
|
||||
@click=${props.onDismiss}
|
||||
aria-label=${t("chat.workspaceConflict.dismiss")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +399,28 @@ describe("ComposerDictationController", () => {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it("reports whether finalization committed a transcript", async () => {
|
||||
const withTranscript = createHarness();
|
||||
await startHold(withTranscript.target);
|
||||
emit({
|
||||
transcriptionSessionId: "dictation-1",
|
||||
type: "transcript",
|
||||
text: "send these words",
|
||||
final: true,
|
||||
});
|
||||
const committed = withTranscript.controller.finishActive();
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
await expect(committed).resolves.toBe(true);
|
||||
withTranscript.controller.dispose();
|
||||
|
||||
const withoutTranscript = createHarness();
|
||||
await startHold(withoutTranscript.target);
|
||||
const empty = withoutTranscript.controller.finishActive();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(empty).resolves.toBe(false);
|
||||
withoutTranscript.controller.dispose();
|
||||
});
|
||||
|
||||
it("ignores extra clicks while final text is draining", async () => {
|
||||
const { controller, onTap, target } = createHarness();
|
||||
await startHold(target);
|
||||
@@ -598,9 +620,11 @@ describe("ComposerDictationController", () => {
|
||||
target.dispatchEvent(pointer("pointerdown"));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
// Names the missing piece and where to fix it: this failure is a
|
||||
// configuration gap the operator has to close, not something a retry clears.
|
||||
await waitForFast(() =>
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
"No transcription provider is configured for dictation.",
|
||||
"No transcription provider is configured for dictation. Choose one in Settings to dictate.",
|
||||
),
|
||||
);
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
|
||||
@@ -449,6 +449,16 @@ export class ComposerDictationController {
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Returns the stop promise so callers can sequence work (e.g. send) after
|
||||
// the transcript lands in the draft.
|
||||
finishActive(): Promise<boolean> {
|
||||
return this.stop({ commit: true });
|
||||
}
|
||||
|
||||
cancelActive(): void {
|
||||
void this.stop({ commit: false });
|
||||
}
|
||||
|
||||
update(options: ComposerDictationControllerOptions): void {
|
||||
this.options = options;
|
||||
if (this.phase === "stopping") {
|
||||
@@ -635,9 +645,9 @@ export class ComposerDictationController {
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(options: { commit: boolean }): Promise<void> {
|
||||
private async stop(options: { commit: boolean }): Promise<boolean> {
|
||||
if (this.phase === "idle" || this.phase === "stopping") {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const wasActive = this.active;
|
||||
this.clearGesture();
|
||||
@@ -645,17 +655,22 @@ export class ComposerDictationController {
|
||||
const session = this.session;
|
||||
if (!session) {
|
||||
this.reset();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
this.setPhase("stopping");
|
||||
const transcript = options.commit ? await session.finish() : (await session.cancel(), "");
|
||||
if (this.session === session) {
|
||||
const ownsSession = this.session === session;
|
||||
if (ownsSession) {
|
||||
this.session = null;
|
||||
}
|
||||
if (options.commit && transcript && wasActive && !this.disposed) {
|
||||
const committed = Boolean(
|
||||
options.commit && transcript && wasActive && ownsSession && !this.disposed,
|
||||
);
|
||||
if (committed) {
|
||||
this.options.onCommit(transcript);
|
||||
}
|
||||
this.reset();
|
||||
return committed;
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
discoverRealtimeTalkInputs,
|
||||
observeRealtimeTalkDevices,
|
||||
type RealtimeTalkDeviceIssue,
|
||||
type RealtimeTalkInputDevice,
|
||||
} from "./realtime-talk-input.ts";
|
||||
|
||||
/**
|
||||
* Device list behind a composer's microphone control, owned per composer.
|
||||
*
|
||||
* Discovery, the `devicechange` subscription and the in-flight request token
|
||||
* belong together: the subscription only lives while the picker is open, and a
|
||||
* late discovery must not overwrite a newer one. Keeping them in one owner is
|
||||
* what lets a second composer surface offer the same control without repeating
|
||||
* the sequencing, and gives the watch a single release point.
|
||||
*/
|
||||
export class ComposerMicrophonePicker {
|
||||
private devicesValue: RealtimeTalkInputDevice[] = [];
|
||||
private loadingValue = false;
|
||||
private openValue = false;
|
||||
private issueValue: RealtimeTalkDeviceIssue | null = null;
|
||||
private deviceWatch: (() => void) | null = null;
|
||||
private discoveryRequest = 0;
|
||||
|
||||
constructor(private readonly requestUpdate: () => void) {}
|
||||
|
||||
get devices(): RealtimeTalkInputDevice[] {
|
||||
return this.devicesValue;
|
||||
}
|
||||
|
||||
get loading(): boolean {
|
||||
return this.loadingValue;
|
||||
}
|
||||
|
||||
get open(): boolean {
|
||||
return this.openValue;
|
||||
}
|
||||
|
||||
get issue(): RealtimeTalkDeviceIssue | null {
|
||||
return this.issueValue;
|
||||
}
|
||||
|
||||
readonly handleOpen = (): void => {
|
||||
if (this.openValue) {
|
||||
return;
|
||||
}
|
||||
this.openValue = true;
|
||||
this.deviceWatch ??= observeRealtimeTalkDevices(this.discover);
|
||||
this.discover();
|
||||
};
|
||||
|
||||
readonly handleClose = (): void => {
|
||||
if (!this.openValue) {
|
||||
return;
|
||||
}
|
||||
this.release();
|
||||
this.openValue = false;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
/** Drops the devicechange subscription so a closed picker stops refreshing. */
|
||||
release(): void {
|
||||
this.deviceWatch?.();
|
||||
this.deviceWatch = null;
|
||||
}
|
||||
|
||||
/** Ends an in-flight discovery too, so a late result cannot revive the list. */
|
||||
dispose(): void {
|
||||
this.release();
|
||||
this.discoveryRequest++;
|
||||
this.openValue = false;
|
||||
this.loadingValue = false;
|
||||
}
|
||||
|
||||
private readonly discover = (): void => {
|
||||
this.loadingValue = true;
|
||||
this.issueValue = null;
|
||||
const request = ++this.discoveryRequest;
|
||||
this.requestUpdate();
|
||||
// Permission-requesting discovery on every pass, including device changes:
|
||||
// a microphone that just appeared has hidden labels until the probe runs,
|
||||
// and the probe only prompts while the picker is the surface in front of
|
||||
// the user.
|
||||
void discoverRealtimeTalkInputs(true)
|
||||
.then((result) => {
|
||||
if (request !== this.discoveryRequest) {
|
||||
return;
|
||||
}
|
||||
this.devicesValue = result.devices;
|
||||
this.issueValue = result.issue;
|
||||
})
|
||||
.catch(() => {
|
||||
if (request !== this.discoveryRequest) {
|
||||
return;
|
||||
}
|
||||
this.devicesValue = [];
|
||||
this.issueValue = "failed";
|
||||
})
|
||||
.finally(() => {
|
||||
if (request !== this.discoveryRequest) {
|
||||
return;
|
||||
}
|
||||
this.loadingValue = false;
|
||||
this.requestUpdate();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -70,7 +70,10 @@ function sessionPage(face: BoardFace) {
|
||||
return await loadChatRoute(context, location, face, signal);
|
||||
},
|
||||
component: () =>
|
||||
import("./chat-page.ts").then(() => ({
|
||||
Promise.all([
|
||||
import("./chat-page.ts"),
|
||||
import("../../styles/chat/composer-progress.css"),
|
||||
]).then(() => ({
|
||||
header: true,
|
||||
// ChatPage's bounded inner cache owns per-session teardown, so session
|
||||
// routes share the outer owner while their data and URL keep changing.
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { SessionsListResult } from "../../api/types.ts";
|
||||
import { isSessionRunActive } from "../../lib/session-run-state.ts";
|
||||
import { sessionMutationGatewayHello } from "../../test-helpers/gateway-methods.ts";
|
||||
import {
|
||||
CHAT_RUN_STATUS_TOAST_DURATION_MS,
|
||||
handleAbortChat,
|
||||
hasAbortableSessionRun,
|
||||
hasDirectSessionRun,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
} from "./run-lifecycle.ts";
|
||||
import { buildToolStreamIdentity } from "./tool-stream-identity.ts";
|
||||
|
||||
const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000;
|
||||
|
||||
type ReconcileHost = Parameters<typeof reconcileChatRunFromCurrentSessionRow>[0];
|
||||
type TestRow = {
|
||||
key: string;
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type WaitingApprovalStatus,
|
||||
} from "./tool-stream.ts";
|
||||
|
||||
export const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000;
|
||||
const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000;
|
||||
|
||||
export type ChatRunUiStatus = {
|
||||
phase: "done" | "interrupted";
|
||||
|
||||
@@ -98,13 +98,21 @@ function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function workspaceConflictGitCommands(conflict: WorkspaceResultConflict):
|
||||
export function workspaceConflictGitCommands(
|
||||
conflict: WorkspaceResultConflict,
|
||||
requestedPath?: string,
|
||||
):
|
||||
| {
|
||||
inspect: string;
|
||||
takeCloud: string;
|
||||
}
|
||||
| undefined {
|
||||
const entryPath = conflict.paths.find((candidate) => !hasTerminalControl(candidate));
|
||||
const entryPath =
|
||||
requestedPath === undefined
|
||||
? conflict.paths.find((candidate) => !hasTerminalControl(candidate))
|
||||
: conflict.paths.includes(requestedPath) && !hasTerminalControl(requestedPath)
|
||||
? requestedPath
|
||||
: undefined;
|
||||
if (!entryPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
|
||||
import { countSessionToolOverrides } from "../../lib/sessions/tool-overrides.ts";
|
||||
import {
|
||||
renderChatComposerPlusMenu,
|
||||
type ChatComposerPlusMenuView,
|
||||
} from "../chat/components/chat-composer-plus-menu.ts";
|
||||
import type { CapabilityMenuProps } from "../chat/components/chat-composer-types.ts";
|
||||
import type { NewSessionVisibility } from "./create-params.ts";
|
||||
|
||||
type NewSessionComposerCapabilityOptions = {
|
||||
submitting: boolean;
|
||||
messageLocked?: boolean;
|
||||
visibility?: NewSessionVisibility;
|
||||
draftAvailable?: boolean;
|
||||
capabilityMenu?: CapabilityMenuProps;
|
||||
toolOverrides?: SessionToolOverrides | null;
|
||||
textareaController: {
|
||||
capabilityMenuOpen: boolean;
|
||||
capabilityMenuView: ChatComposerPlusMenuView;
|
||||
};
|
||||
requestUpdate: () => void;
|
||||
onVisibilityChange?: (visibility: NewSessionVisibility) => void;
|
||||
};
|
||||
|
||||
export function renderNewSessionDraftVisibility(options: NewSessionComposerCapabilityOptions) {
|
||||
const active = options.visibility === "draft";
|
||||
const disabled = options.submitting || options.messageLocked;
|
||||
const label = t("newSession.draft");
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class="new-session-page__visibility new-session-page__visibility--draft ${active
|
||||
? "new-session-page__visibility--active"
|
||||
: ""}"
|
||||
role="switch"
|
||||
aria-label=${label}
|
||||
aria-checked=${String(active)}
|
||||
?disabled=${disabled}
|
||||
title=${t("newSession.draftDescription")}
|
||||
@click=${() => options.onVisibilityChange?.(active ? "normal" : "draft")}
|
||||
>
|
||||
<span class="new-session-page__visibility-icon" aria-hidden="true">${icons.pencil}</span>
|
||||
<span class="new-session-page__visibility-label">${label}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderNewSessionPlusMenu(
|
||||
options: NewSessionComposerCapabilityOptions,
|
||||
attachments: Parameters<typeof renderChatComposerPlusMenu>[0]["attachments"],
|
||||
) {
|
||||
const draftEnabled = options.visibility === "draft";
|
||||
const overrideCount = countSessionToolOverrides(options.toolOverrides);
|
||||
const selectedCount = overrideCount + (draftEnabled ? 1 : 0);
|
||||
const disabled = options.submitting || options.messageLocked === true;
|
||||
const controller = options.textareaController;
|
||||
return renderChatComposerPlusMenu({
|
||||
attachments,
|
||||
capabilityMenu: options.capabilityMenu,
|
||||
disabled,
|
||||
open: controller.capabilityMenuOpen,
|
||||
view: controller.capabilityMenuView,
|
||||
toolOverrides: options.toolOverrides,
|
||||
rootToggles: options.draftAvailable
|
||||
? [
|
||||
{
|
||||
value: "new-session-draft",
|
||||
label: t("newSession.draft"),
|
||||
icon: icons.pencil,
|
||||
checked: draftEnabled,
|
||||
disabled,
|
||||
title: t("newSession.draftDescription"),
|
||||
onChange: (checked) => options.onVisibilityChange?.(checked ? "draft" : "normal"),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
selectedLabel:
|
||||
selectedCount > 0
|
||||
? t("newSession.composerOptionsSelected", { count: String(selectedCount) })
|
||||
: undefined,
|
||||
onOpenChange: (open) => {
|
||||
controller.capabilityMenuOpen = open;
|
||||
if (!open) {
|
||||
controller.capabilityMenuView = "root";
|
||||
}
|
||||
options.requestUpdate();
|
||||
},
|
||||
onViewChange: (view) => {
|
||||
controller.capabilityMenuView = view;
|
||||
options.requestUpdate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function renderNewSessionSelectionStatus(options: NewSessionComposerCapabilityOptions) {
|
||||
const draftEnabled = options.visibility === "draft";
|
||||
const overrideCount = countSessionToolOverrides(options.toolOverrides);
|
||||
if (!draftEnabled && overrideCount === 0) {
|
||||
return nothing;
|
||||
}
|
||||
const disabled = options.submitting || options.messageLocked === true;
|
||||
const openMenu = () => {
|
||||
options.textareaController.capabilityMenuView = "root";
|
||||
options.textareaController.capabilityMenuOpen = true;
|
||||
options.requestUpdate();
|
||||
};
|
||||
return html`
|
||||
${draftEnabled
|
||||
? html`<button
|
||||
type="button"
|
||||
class="new-session-page__selection-status"
|
||||
?disabled=${disabled}
|
||||
@click=${openMenu}
|
||||
>
|
||||
${icons.pencil}${t("newSession.draft")}
|
||||
</button>`
|
||||
: nothing}
|
||||
${overrideCount > 0
|
||||
? html`<button
|
||||
type="button"
|
||||
class="new-session-page__selection-status"
|
||||
?disabled=${disabled}
|
||||
@click=${openMenu}
|
||||
>
|
||||
${t(
|
||||
overrideCount === 1
|
||||
? "chat.composer.overrides.countOne"
|
||||
: "chat.composer.overrides.count",
|
||||
{ count: String(overrideCount) },
|
||||
)}
|
||||
</button>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const dictationHarness = vi.hoisted(() => ({
|
||||
options: null as null | {
|
||||
onCommit: (transcript: string) => void;
|
||||
onTap: () => void;
|
||||
},
|
||||
controllers: [] as Array<{
|
||||
active: boolean;
|
||||
locksComposer: boolean;
|
||||
handlePointerDown: () => void;
|
||||
}>,
|
||||
}));
|
||||
|
||||
vi.mock("../chat/composer-dictation.ts", () => ({
|
||||
ComposerDictationController: class {
|
||||
active = false;
|
||||
locksComposer = false;
|
||||
|
||||
constructor(options: { onCommit: (transcript: string) => void; onTap: () => void }) {
|
||||
dictationHarness.options = options;
|
||||
dictationHarness.controllers.push(this);
|
||||
}
|
||||
update(options: { onCommit: (transcript: string) => void; onTap: () => void }) {
|
||||
dictationHarness.options = options;
|
||||
}
|
||||
dispose() {
|
||||
this.active = false;
|
||||
}
|
||||
handlePointerDown() {
|
||||
this.active = true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../chat/composer-microphone-picker.ts", () => ({
|
||||
ComposerMicrophonePicker: class {
|
||||
devices = [];
|
||||
loading = false;
|
||||
open = false;
|
||||
issue = null;
|
||||
handleOpen() {}
|
||||
handleClose() {}
|
||||
dispose() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import { NewSessionDictationControl } from "./composer-dictation-control.ts";
|
||||
|
||||
describe("NewSessionDictationControl", () => {
|
||||
beforeEach(() => {
|
||||
dictationHarness.options = null;
|
||||
dictationHarness.controllers = [];
|
||||
});
|
||||
|
||||
it("drops a final transcript when cloud placement claims the draft in flight", () => {
|
||||
let canCommit = true;
|
||||
const insertTranscript = vi.fn(() => "spoken task");
|
||||
const onMessage = vi.fn();
|
||||
const control = new NewSessionDictationControl({
|
||||
textarea: { captureSelection: vi.fn(), insertTranscript } as never,
|
||||
getClient: () => ({}) as never,
|
||||
isConnected: () => true,
|
||||
canCommit: () => canCommit,
|
||||
onMessage,
|
||||
onError: vi.fn(),
|
||||
onClearError: vi.fn(),
|
||||
requestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
control.render("agent-a");
|
||||
canCommit = false;
|
||||
dictationHarness.options?.onCommit("spoken task");
|
||||
|
||||
expect(insertTranscript).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears its short-tap hint after a later dictation commits", () => {
|
||||
const onError = vi.fn();
|
||||
const onClearError = vi.fn();
|
||||
const onMessage = vi.fn();
|
||||
const control = new NewSessionDictationControl({
|
||||
textarea: {
|
||||
captureSelection: vi.fn(),
|
||||
insertTranscript: vi.fn(() => "spoken task"),
|
||||
} as never,
|
||||
getClient: () => ({}) as never,
|
||||
isConnected: () => true,
|
||||
canCommit: () => true,
|
||||
onMessage,
|
||||
onError,
|
||||
onClearError,
|
||||
requestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
control.render("agent-a");
|
||||
dictationHarness.options?.onTap();
|
||||
const hint = "Hold the microphone to dictate, then release to insert what you said.";
|
||||
expect(onError).toHaveBeenCalledWith(hint);
|
||||
|
||||
dictationHarness.options?.onCommit("spoken task");
|
||||
|
||||
expect(onClearError).toHaveBeenCalledWith(hint);
|
||||
expect(onMessage).toHaveBeenCalledWith("spoken task");
|
||||
});
|
||||
|
||||
it("cancels active dictation and drops its late transcript when the route owner changes", () => {
|
||||
const insertTranscript = vi.fn(() => "route B draft");
|
||||
const onMessage = vi.fn();
|
||||
const control = new NewSessionDictationControl({
|
||||
textarea: { captureSelection: vi.fn(), insertTranscript } as never,
|
||||
getClient: () => ({}) as never,
|
||||
isConnected: () => true,
|
||||
canCommit: () => true,
|
||||
onMessage,
|
||||
onError: vi.fn(),
|
||||
onClearError: vi.fn(),
|
||||
requestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
control.render("agent-a");
|
||||
const routeAController = dictationHarness.controllers[0];
|
||||
routeAController?.handlePointerDown();
|
||||
const routeACommit = dictationHarness.options?.onCommit;
|
||||
|
||||
control.render("agent-a");
|
||||
expect(dictationHarness.controllers).toHaveLength(1);
|
||||
expect(routeAController?.active).toBe(true);
|
||||
|
||||
control.render("agent-b");
|
||||
routeACommit?.("late route A transcript");
|
||||
|
||||
expect(routeAController?.active).toBe(false);
|
||||
expect(dictationHarness.controllers).toHaveLength(2);
|
||||
expect(insertTranscript).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes whether dictation currently locks draft submission", () => {
|
||||
const control = new NewSessionDictationControl({
|
||||
textarea: { captureSelection: vi.fn(), insertTranscript: vi.fn() } as never,
|
||||
getClient: () => ({}) as never,
|
||||
isConnected: () => true,
|
||||
canCommit: () => true,
|
||||
onMessage: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onClearError: vi.fn(),
|
||||
requestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
control.render("agent-a");
|
||||
expect(control.locked).toBe(false);
|
||||
|
||||
const controller = dictationHarness.controllers[0];
|
||||
if (!controller) {
|
||||
throw new Error("expected dictation controller");
|
||||
}
|
||||
controller.locksComposer = true;
|
||||
|
||||
expect(control.locked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { loadSettings, patchSettings } from "../../app/settings.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import {
|
||||
renderComposerVoiceButton,
|
||||
renderMicrophonePicker,
|
||||
} from "../chat/components/chat-composer-controls.ts";
|
||||
import { ComposerDictationController } from "../chat/composer-dictation.ts";
|
||||
import { ComposerMicrophonePicker } from "../chat/composer-microphone-picker.ts";
|
||||
import type { NewSessionComposerTextareaController } from "./composer.ts";
|
||||
|
||||
type NewSessionDictationOptions = {
|
||||
textarea: NewSessionComposerTextareaController;
|
||||
getClient: () => GatewayBrowserClient | null;
|
||||
isConnected: () => boolean;
|
||||
canCommit: () => boolean;
|
||||
onMessage: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
onClearError: (message: string) => void;
|
||||
requestUpdate: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dictation for the new-session draft: hold the microphone, speak, release, and
|
||||
* the transcript lands at the caret.
|
||||
*
|
||||
* Deliberately dictation only. The chat composer's microphone doubles as a Talk
|
||||
* toggle, but Talk needs a live session and this composer exists precisely
|
||||
* because there is not one yet, so a plain tap has nothing to start and says so
|
||||
* instead of silently doing nothing.
|
||||
*/
|
||||
export class NewSessionDictationControl {
|
||||
private readonly devicePicker: ComposerMicrophonePicker;
|
||||
private dictation: ComposerDictationController | null = null;
|
||||
private owner: { key: string } | null = null;
|
||||
|
||||
constructor(private readonly options: NewSessionDictationOptions) {
|
||||
this.devicePicker = new ComposerMicrophonePicker(options.requestUpdate);
|
||||
}
|
||||
|
||||
get locked(): boolean {
|
||||
return this.dictation?.locksComposer === true;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.owner = null;
|
||||
this.dictation?.dispose();
|
||||
this.dictation = null;
|
||||
this.devicePicker.dispose();
|
||||
}
|
||||
|
||||
render(ownerKey: string) {
|
||||
if (this.owner?.key !== ownerKey) {
|
||||
this.owner = { key: ownerKey };
|
||||
this.dictation?.dispose();
|
||||
this.dictation = null;
|
||||
}
|
||||
const owner = this.owner;
|
||||
const ownsDraft = () => this.owner === owner;
|
||||
const client = this.options.getClient();
|
||||
const connected = this.options.isConnected() && client !== null;
|
||||
const enabled = this.options.canCommit();
|
||||
const dictationOptions = {
|
||||
client,
|
||||
connected,
|
||||
enabled,
|
||||
realtimeTalkActive: false,
|
||||
onCommit: (transcript: string) => {
|
||||
// Route changes replace draft ownership while finalization is asynchronous.
|
||||
// Object identity keeps even an A -> B -> A transition from accepting A's result.
|
||||
if (!ownsDraft() || !this.options.canCommit()) {
|
||||
return;
|
||||
}
|
||||
this.options.onClearError(t("newSession.dictationHoldToSpeak"));
|
||||
const next = this.options.textarea.insertTranscript(transcript);
|
||||
if (next !== null) {
|
||||
this.options.onMessage(next);
|
||||
}
|
||||
this.options.requestUpdate();
|
||||
},
|
||||
onError: (message: string) => {
|
||||
if (ownsDraft()) {
|
||||
this.options.onError(message);
|
||||
}
|
||||
},
|
||||
onStateChange: () => {
|
||||
if (ownsDraft()) {
|
||||
this.options.requestUpdate();
|
||||
}
|
||||
},
|
||||
onTap: () => {
|
||||
if (ownsDraft()) {
|
||||
this.options.onError(t("newSession.dictationHoldToSpeak"));
|
||||
}
|
||||
},
|
||||
};
|
||||
this.dictation ??= new ComposerDictationController(dictationOptions);
|
||||
this.dictation.update(dictationOptions);
|
||||
const dictation = this.dictation;
|
||||
|
||||
return renderComposerVoiceButton({
|
||||
connected,
|
||||
sending: false,
|
||||
isBusy: !enabled,
|
||||
dictation,
|
||||
idleLabel: t("newSession.dictate"),
|
||||
microphonePicker: renderMicrophonePicker({
|
||||
devices: this.devicePicker.devices,
|
||||
loading: this.devicePicker.loading,
|
||||
open: this.devicePicker.open,
|
||||
selectedDeviceId: loadSettings().realtimeTalkInputDeviceId?.trim() ?? "",
|
||||
voiceActive: false,
|
||||
issue: this.devicePicker.issue,
|
||||
onOpen: this.devicePicker.handleOpen,
|
||||
onClose: this.devicePicker.handleClose,
|
||||
onSelect: (deviceId: string) => {
|
||||
patchSettings({ realtimeTalkInputDeviceId: deviceId.trim() || undefined });
|
||||
this.devicePicker.handleClose();
|
||||
},
|
||||
}),
|
||||
onDictationPointerDown: (event: PointerEvent) => {
|
||||
this.options.textarea.captureSelection();
|
||||
dictation.handlePointerDown(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildFallbackSlashCommands, replaceSlashCommands } from "../../lib/chat/commands.ts";
|
||||
import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { createDeferred } from "../../../../test/helpers/promise.ts";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import {
|
||||
buildFallbackSlashCommands,
|
||||
getSkillCommandCompletions,
|
||||
replaceSlashCommands,
|
||||
} from "../../lib/chat/commands.ts";
|
||||
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
|
||||
import { waitForFast } from "../../test-helpers/wait-for.ts";
|
||||
import { adjustTextareaHeight } from "../chat/components/chat-composer-dom.ts";
|
||||
@@ -32,6 +40,9 @@ function renderComposer(
|
||||
toolOverrides?: SessionToolOverrides | null;
|
||||
onVisibilityChange?: (visibility: NewSessionVisibility) => void;
|
||||
message?: string;
|
||||
draftOwnerKey?: string;
|
||||
agentId?: string;
|
||||
context?: ApplicationContext;
|
||||
onInput?: (message: string) => void;
|
||||
onSubmit?: () => void;
|
||||
textareaController?: NewSessionComposerTextareaController;
|
||||
@@ -49,13 +60,16 @@ function renderComposer(
|
||||
textareaControllers.push(textareaController);
|
||||
}
|
||||
let message = overrides.message ?? "";
|
||||
let agentId = overrides.agentId ?? "main";
|
||||
let draftOwnerKey = overrides.draftOwnerKey ?? "draft:one";
|
||||
const renderCurrent = () =>
|
||||
render(
|
||||
renderNewSessionDraftComposer({
|
||||
agentId: "main",
|
||||
agentId,
|
||||
attachmentDraft,
|
||||
canSubmit: overrides.canSubmit ?? true,
|
||||
context: undefined,
|
||||
context: overrides.context,
|
||||
draftOwnerKey,
|
||||
isCatalogTarget: true,
|
||||
message,
|
||||
visibility: overrides.visibility,
|
||||
@@ -85,7 +99,22 @@ function renderComposer(
|
||||
if (!composer) {
|
||||
throw new Error("Expected new-session composer");
|
||||
}
|
||||
return { attachmentDraft, composer, container, textareaController };
|
||||
return {
|
||||
attachmentDraft,
|
||||
composer,
|
||||
container,
|
||||
textareaController,
|
||||
rerender: renderCurrent,
|
||||
rerenderForAgent: (nextAgentId: string) => {
|
||||
agentId = nextAgentId;
|
||||
renderCurrent();
|
||||
},
|
||||
rerenderForDraftRoute: (nextDraftOwnerKey: string, nextMessage: string) => {
|
||||
draftOwnerKey = nextDraftOwnerKey;
|
||||
message = nextMessage;
|
||||
renderCurrent();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDragEvent(type: string, files: File[] = [], types = ["Files"]): Event {
|
||||
@@ -155,6 +184,107 @@ describe("new-session composer keyboard submission", () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a pending skill completion when the selected agent changes", async () => {
|
||||
const response = createDeferred<CommandsListResult>();
|
||||
const request = vi.fn(() => response.promise);
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const context = {
|
||||
gateway: { snapshot: { client } },
|
||||
} as unknown as ApplicationContext;
|
||||
const { composer, rerenderForAgent, textareaController } = renderComposer({
|
||||
agentId: "writer",
|
||||
context,
|
||||
message: "$",
|
||||
});
|
||||
const textarea = composer.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected composer textarea");
|
||||
}
|
||||
|
||||
textarea.setSelectionRange(1, 1);
|
||||
textarea.dispatchEvent(new Event("select", { bubbles: true }));
|
||||
expect(request).toHaveBeenCalledWith("commands.list", {
|
||||
agentId: "writer",
|
||||
includeArgs: true,
|
||||
scope: "text",
|
||||
});
|
||||
|
||||
rerenderForAgent("reviewer");
|
||||
response.resolve({
|
||||
commands: [
|
||||
{
|
||||
acceptsArgs: true,
|
||||
description: "Only available to the previous agent.",
|
||||
name: "writer_only",
|
||||
scope: "both",
|
||||
source: "skill",
|
||||
skillModelVisible: true,
|
||||
textAliases: ["/writer_only"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(textareaController.skillMenuState.skillCommandRefreshPending).toBe(false);
|
||||
});
|
||||
expect(composer.querySelector(".skill-menu")?.textContent ?? "").not.toContain("writer_only");
|
||||
expect(getSkillCommandCompletions("writer_only")).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a pending skill completion when the Gateway client changes", async () => {
|
||||
const response = createDeferred<CommandsListResult>();
|
||||
const firstRequest = vi.fn(() => response.promise);
|
||||
const firstClient = {
|
||||
request: firstRequest,
|
||||
} as unknown as GatewayBrowserClient;
|
||||
const secondClient = {
|
||||
request: vi.fn(),
|
||||
} as unknown as GatewayBrowserClient;
|
||||
const snapshot = { client: firstClient };
|
||||
const context = {
|
||||
gateway: { snapshot },
|
||||
} as unknown as ApplicationContext;
|
||||
const { composer, rerender, textareaController } = renderComposer({
|
||||
agentId: "writer",
|
||||
context,
|
||||
message: "$",
|
||||
});
|
||||
const textarea = composer.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected composer textarea");
|
||||
}
|
||||
|
||||
textarea.setSelectionRange(1, 1);
|
||||
textarea.dispatchEvent(new Event("select", { bubbles: true }));
|
||||
expect(firstRequest).toHaveBeenCalledWith("commands.list", {
|
||||
agentId: "writer",
|
||||
includeArgs: true,
|
||||
scope: "text",
|
||||
});
|
||||
|
||||
snapshot.client = secondClient;
|
||||
rerender();
|
||||
response.resolve({
|
||||
commands: [
|
||||
{
|
||||
acceptsArgs: true,
|
||||
description: "Only available through the previous Gateway client.",
|
||||
name: "previous_client_only",
|
||||
scope: "both",
|
||||
source: "skill",
|
||||
skillModelVisible: true,
|
||||
textAliases: ["/previous_client_only"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(textareaController.skillMenuState.skillCommandRefreshPending).toBe(false);
|
||||
});
|
||||
expect(composer.querySelector(".skill-menu")?.textContent ?? "").not.toContain(
|
||||
"previous_client_only",
|
||||
);
|
||||
expect(getSkillCommandCompletions("previous_client_only")).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens skill mentions and inserts the selected skill with Enter", () => {
|
||||
replaceSlashCommands([
|
||||
{
|
||||
@@ -187,6 +317,36 @@ describe("new-session composer keyboard submission", () => {
|
||||
expect(message).toBe("$release_notes ");
|
||||
});
|
||||
|
||||
it("closes a skill menu when a route change replaces the owned draft", () => {
|
||||
replaceSlashCommands([
|
||||
{
|
||||
key: "release_notes",
|
||||
name: "release_notes",
|
||||
description: "Draft release notes.",
|
||||
source: "skill",
|
||||
skillModelVisible: true,
|
||||
},
|
||||
]);
|
||||
const onSubmit = vi.fn();
|
||||
const { composer, rerenderForDraftRoute } = renderComposer({ onSubmit });
|
||||
const textarea = composer.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected composer textarea");
|
||||
}
|
||||
textarea.value = "$";
|
||||
textarea.setSelectionRange(1, 1);
|
||||
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
|
||||
expect(composer.querySelector(".skill-menu")).not.toBeNull();
|
||||
|
||||
rerenderForDraftRoute("draft:two", "replacement task");
|
||||
expect(composer.querySelector(".skill-menu")).toBeNull();
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }),
|
||||
);
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "Enter", requiresModifier: false, ctrlKey: false, metaKey: false },
|
||||
{ label: "Ctrl+Enter", requiresModifier: true, ctrlKey: true, metaKey: false },
|
||||
@@ -388,6 +548,7 @@ describe("new-session composer sizing lifecycle", () => {
|
||||
attachmentDraft: first.attachmentDraft,
|
||||
canSubmit: true,
|
||||
context: undefined,
|
||||
draftOwnerKey: "draft:one",
|
||||
isCatalogTarget: true,
|
||||
message: "typed",
|
||||
modelControl: new NewSessionModelControl(() => undefined),
|
||||
@@ -413,6 +574,7 @@ describe("new-session composer sizing lifecycle", () => {
|
||||
attachmentDraft: first.attachmentDraft,
|
||||
canSubmit: true,
|
||||
context: undefined,
|
||||
draftOwnerKey: "draft:one",
|
||||
isCatalogTarget: true,
|
||||
message: "restored programmatically",
|
||||
modelControl: new NewSessionModelControl(() => undefined),
|
||||
@@ -465,31 +627,20 @@ describe("new-session composer attachment drops", () => {
|
||||
expect(switches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("moves Draft into Plus and keeps active selections visible", () => {
|
||||
it("lets the draft pill replace page-level incognito", () => {
|
||||
const onVisibilityChange = vi.fn();
|
||||
const { composer } = renderComposer({
|
||||
draftAvailable: true,
|
||||
visibility: "draft",
|
||||
toolOverrides: { skills: { release: false } },
|
||||
visibility: "incognito",
|
||||
onVisibilityChange,
|
||||
});
|
||||
const menu = composer.querySelector<HTMLElement>("wa-dropdown.agent-chat__capability-menu");
|
||||
const draftToggle = menu?.querySelector<HTMLElement>(
|
||||
'wa-dropdown-item[value="new-session-draft"]',
|
||||
);
|
||||
const draftPill = composer.querySelector<HTMLButtonElement>('[role="switch"]');
|
||||
|
||||
expect(
|
||||
(draftToggle?.querySelector("wa-switch") as HTMLElement & { checked?: boolean })?.checked,
|
||||
).toBe(true);
|
||||
expect(composer.querySelectorAll(".new-session-page__selection-status")).toHaveLength(2);
|
||||
expect(composer.textContent).toContain("Draft");
|
||||
expect(composer.textContent).toContain("1 session override");
|
||||
expect(composer.querySelector(".agent-chat__input-btn--selected")).not.toBeNull();
|
||||
expect(draftPill?.textContent).toContain("Draft");
|
||||
expect(draftPill?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
menu?.dispatchEvent(
|
||||
new CustomEvent("wa-select", { bubbles: true, detail: { item: draftToggle } }),
|
||||
);
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith("normal");
|
||||
draftPill?.click();
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith("draft");
|
||||
});
|
||||
|
||||
it("adds a dropped file through the shared attachment handling", async () => {
|
||||
@@ -585,3 +736,80 @@ describe("new-session composer attachment drops", () => {
|
||||
expect(disabledTextareaDrop.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("new-session composer dictation insertion", () => {
|
||||
function draftTextarea(composer: HTMLElement, value: string, start: number, end = start) {
|
||||
const textarea = composer.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected composer textarea");
|
||||
}
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = start;
|
||||
textarea.selectionEnd = end;
|
||||
return textarea;
|
||||
}
|
||||
|
||||
it("inserts at the caret the writer left, not where the caret ended up", () => {
|
||||
const { composer, textareaController } = renderComposer({ message: "ship it" });
|
||||
const textarea = draftTextarea(composer, "ship it", 4);
|
||||
|
||||
// Pressing the microphone blurs the draft, so the caret is read then rather
|
||||
// than when the transcript comes back and the caret has moved.
|
||||
textareaController.captureSelection();
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
|
||||
expect(textareaController.insertTranscript("please")).toBe("ship please it");
|
||||
expect(textarea.value).toBe("ship please it");
|
||||
});
|
||||
|
||||
it("replaces the range the writer had highlighted", () => {
|
||||
const { composer, textareaController } = renderComposer({ message: "ship the thing" });
|
||||
draftTextarea(composer, "ship the thing", 5, 14);
|
||||
textareaController.captureSelection();
|
||||
|
||||
expect(textareaController.insertTranscript("it now")).toBe("ship it now");
|
||||
});
|
||||
|
||||
it("appends when no caret was captured", () => {
|
||||
const { composer, textareaController } = renderComposer({ message: "ship" });
|
||||
draftTextarea(composer, "ship", 0);
|
||||
|
||||
expect(textareaController.insertTranscript("it")).toBe("ship it");
|
||||
});
|
||||
|
||||
it("reads uncommitted keystrokes from the draft rather than the committed value", () => {
|
||||
const { composer, textareaController } = renderComposer({ message: "ship" });
|
||||
// The writer kept typing after the last commit upward; the element holds it.
|
||||
draftTextarea(composer, "ship the", 8);
|
||||
textareaController.captureSelection();
|
||||
|
||||
expect(textareaController.insertTranscript("thing")).toBe("ship the thing");
|
||||
});
|
||||
|
||||
it("reports nothing to insert for a blank transcript so the draft is left alone", () => {
|
||||
const { composer, textareaController } = renderComposer({ message: "ship it" });
|
||||
const textarea = draftTextarea(composer, "ship it", 7);
|
||||
textareaController.captureSelection();
|
||||
|
||||
expect(textareaController.insertTranscript(" ")).toBeNull();
|
||||
expect(textarea.value).toBe("ship it");
|
||||
});
|
||||
|
||||
it("spends a captured caret once, so a later transcript appends instead of reusing it", () => {
|
||||
const { composer, textareaController } = renderComposer({ message: "ship it" });
|
||||
draftTextarea(composer, "ship it", 0);
|
||||
textareaController.captureSelection();
|
||||
|
||||
expect(textareaController.insertTranscript("please")).toBe("please ship it");
|
||||
expect(textareaController.insertTranscript("now")).toBe("please ship it now");
|
||||
});
|
||||
|
||||
it("has nothing to insert into once the draft is gone", () => {
|
||||
const { textareaController } = renderComposer({ message: "ship it" });
|
||||
textareaController.captureSelection();
|
||||
textareaController.disconnect();
|
||||
|
||||
expect(textareaController.insertTranscript("it")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import type { ImageLightboxItem } from "../../components/image-lightbox.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -8,7 +9,6 @@ import "../../components/tooltip.ts";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
|
||||
import { countSessionToolOverrides } from "../../lib/sessions/tool-overrides.ts";
|
||||
import { refreshSlashCommands } from "../chat/chat-commands.ts";
|
||||
import {
|
||||
createChatAttachmentDropHandlers,
|
||||
@@ -23,10 +23,7 @@ import {
|
||||
paneDomId,
|
||||
scheduleTextareaHeightAdjustment,
|
||||
} from "../chat/components/chat-composer-dom.ts";
|
||||
import {
|
||||
renderChatComposerPlusMenu,
|
||||
type ChatComposerPlusMenuView,
|
||||
} from "../chat/components/chat-composer-plus-menu.ts";
|
||||
import type { ChatComposerPlusMenuView } from "../chat/components/chat-composer-plus-menu.ts";
|
||||
import {
|
||||
createSkillMenuState,
|
||||
getActiveSkillMenuOptionId,
|
||||
@@ -50,7 +47,13 @@ import {
|
||||
updateSlashMenu,
|
||||
} from "../chat/components/chat-composer-slash-menu.ts";
|
||||
import type { CapabilityMenuProps } from "../chat/components/chat-composer-types.ts";
|
||||
import { insertComposerDictation } from "../chat/composer-dictation.ts";
|
||||
import type { NewSessionAttachmentDraft } from "./attachment-draft.ts";
|
||||
import {
|
||||
renderNewSessionDraftVisibility,
|
||||
renderNewSessionPlusMenu,
|
||||
renderNewSessionSelectionStatus,
|
||||
} from "./composer-capability-controls.ts";
|
||||
import type { NewSessionVisibility } from "./create-params.ts";
|
||||
import type { NewSessionModelControl } from "./model-control.ts";
|
||||
|
||||
@@ -61,6 +64,7 @@ type NewSessionComposerOptions = {
|
||||
getAttachments: () => ChatAttachment[];
|
||||
message: string;
|
||||
modelControl?: TemplateResult | typeof nothing;
|
||||
permissionControl?: TemplateResult | typeof nothing;
|
||||
pendingAttachmentReads: number;
|
||||
readSignal: AbortSignal;
|
||||
requiresModifier: boolean;
|
||||
@@ -75,6 +79,7 @@ type NewSessionComposerOptions = {
|
||||
};
|
||||
submitting: boolean;
|
||||
textareaController: NewSessionComposerTextareaController;
|
||||
voiceControl?: TemplateResult;
|
||||
messageLocked?: boolean;
|
||||
visibility?: NewSessionVisibility;
|
||||
draftAvailable?: boolean;
|
||||
@@ -157,6 +162,15 @@ function renderStartControl(options: NewSessionComposerOptions) {
|
||||
|
||||
export class NewSessionComposerTextareaController {
|
||||
private textarea: HTMLTextAreaElement | null = null;
|
||||
private placeholderFrame: number | null = null;
|
||||
private placeholderStartedAt: number | null = null;
|
||||
private placeholderText = "";
|
||||
private placeholderTarget = "";
|
||||
private placeholderEntered = false;
|
||||
private capturedSelection: { start: number; end: number } | null = null;
|
||||
private skillCommandClient: GatewayBrowserClient | null = null;
|
||||
private skillCommandAgentId = "";
|
||||
private skillCommandDraftOwnerKey = "";
|
||||
readonly skillMenuState = createSkillMenuState();
|
||||
readonly slashMenuState = createSlashMenuState();
|
||||
capabilityMenuOpen = false;
|
||||
@@ -167,6 +181,9 @@ export class NewSessionComposerTextareaController {
|
||||
if (this.textarea && this.textarea !== nextTextarea) {
|
||||
disconnectTextareaOverflowObserver(this.textarea);
|
||||
}
|
||||
if (this.textarea && !nextTextarea) {
|
||||
this.resetPlaceholder();
|
||||
}
|
||||
this.textarea = nextTextarea;
|
||||
if (nextTextarea) {
|
||||
observeTextareaOverflow(nextTextarea);
|
||||
@@ -182,9 +199,142 @@ export class NewSessionComposerTextareaController {
|
||||
}
|
||||
}
|
||||
|
||||
getPlaceholder(target: string, message: string, requestUpdate: () => void) {
|
||||
if (message.length > 0 || this.placeholderEntered) {
|
||||
this.placeholderEntered = true;
|
||||
if (this.placeholderFrame !== null) {
|
||||
globalThis.cancelAnimationFrame?.(this.placeholderFrame);
|
||||
this.placeholderFrame = null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
if (this.placeholderTarget !== target) {
|
||||
this.resetPlaceholder();
|
||||
this.placeholderTarget = target;
|
||||
}
|
||||
const requestFrame = globalThis.requestAnimationFrame?.bind(globalThis);
|
||||
if (
|
||||
!requestFrame ||
|
||||
(globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false)
|
||||
) {
|
||||
this.placeholderText = target;
|
||||
this.placeholderEntered = true;
|
||||
return target;
|
||||
}
|
||||
if (this.placeholderFrame === null) {
|
||||
const step = (timestamp: number) => {
|
||||
this.placeholderStartedAt ??= timestamp;
|
||||
const elapsed = Math.max(0, timestamp - this.placeholderStartedAt - 220);
|
||||
const length = Math.min(target.length, Math.floor(elapsed / 36));
|
||||
if (length !== this.placeholderText.length) {
|
||||
this.placeholderText = target.slice(0, length);
|
||||
requestUpdate();
|
||||
}
|
||||
if (length < target.length) {
|
||||
this.placeholderFrame = requestFrame(step);
|
||||
return;
|
||||
}
|
||||
this.placeholderFrame = null;
|
||||
this.placeholderEntered = true;
|
||||
};
|
||||
this.placeholderFrame = requestFrame(step);
|
||||
}
|
||||
return this.placeholderText;
|
||||
}
|
||||
|
||||
private resetPlaceholder() {
|
||||
if (this.placeholderFrame !== null) {
|
||||
globalThis.cancelAnimationFrame?.(this.placeholderFrame);
|
||||
this.placeholderFrame = null;
|
||||
}
|
||||
this.placeholderStartedAt = null;
|
||||
this.placeholderText = "";
|
||||
this.placeholderTarget = "";
|
||||
this.placeholderEntered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers where the caret was before another control takes focus. Pressing
|
||||
* the microphone blurs the draft, so the caret has to be read while it still
|
||||
* belongs to the writer rather than when the transcript arrives.
|
||||
*/
|
||||
captureSelection() {
|
||||
const target = this.textarea;
|
||||
this.capturedSelection = target
|
||||
? { start: target.selectionStart, end: target.selectionEnd }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a transcript into the draft at the remembered caret and returns the
|
||||
* new draft, or null when there is nothing to insert.
|
||||
*
|
||||
* The element is the draft here, not a copy of it: it holds keystrokes that
|
||||
* have not been committed upward yet, so reading the committed value instead
|
||||
* would insert into a stale draft and drop them. It is written directly too,
|
||||
* so the box grows with the speech before the next render commits.
|
||||
*/
|
||||
insertTranscript(transcript: string): string | null {
|
||||
const target = this.textarea;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const value = target.value;
|
||||
const selection = this.capturedSelection ?? { start: value.length, end: value.length };
|
||||
this.capturedSelection = null;
|
||||
const insertion = insertComposerDictation(value, transcript, selection.start, selection.end);
|
||||
if (insertion.value === value) {
|
||||
return null;
|
||||
}
|
||||
target.value = insertion.value;
|
||||
adjustTextareaHeight(target);
|
||||
queueMicrotask(() => {
|
||||
if (!target.isConnected) {
|
||||
return;
|
||||
}
|
||||
target.focus({ preventScroll: true });
|
||||
target.selectionStart = insertion.caret;
|
||||
target.selectionEnd = insertion.caret;
|
||||
});
|
||||
return insertion.value;
|
||||
}
|
||||
|
||||
readonly getTextarea = () => this.textarea;
|
||||
|
||||
syncSkillCommandOwner(
|
||||
client: GatewayBrowserClient | null,
|
||||
agentId: string,
|
||||
draftOwnerKey: string,
|
||||
) {
|
||||
const normalizedAgentId = agentId.trim();
|
||||
if (
|
||||
this.skillCommandClient === client &&
|
||||
this.skillCommandAgentId === normalizedAgentId &&
|
||||
this.skillCommandDraftOwnerKey === draftOwnerKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The controller survives route, agent, and Gateway changes. Invalidate its
|
||||
// menu generation so a prior owner cannot publish into the next draft.
|
||||
this.skillCommandClient = client;
|
||||
this.skillCommandAgentId = normalizedAgentId;
|
||||
this.skillCommandDraftOwnerKey = draftOwnerKey;
|
||||
resetSkillMenuState(this.skillMenuState);
|
||||
}
|
||||
|
||||
ownsSkillCommands(client: GatewayBrowserClient, agentId: string, draftOwnerKey: string): boolean {
|
||||
return (
|
||||
this.skillCommandClient === client &&
|
||||
this.skillCommandAgentId === agentId.trim() &&
|
||||
this.skillCommandDraftOwnerKey === draftOwnerKey
|
||||
);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.resetPlaceholder();
|
||||
this.skillCommandClient = null;
|
||||
this.skillCommandAgentId = "";
|
||||
this.skillCommandDraftOwnerKey = "";
|
||||
resetSkillMenuState(this.skillMenuState);
|
||||
resetSlashMenuState(this.slashMenuState);
|
||||
this.capabilityMenuOpen = false;
|
||||
@@ -256,95 +406,6 @@ function handleComposerKeydown(
|
||||
}
|
||||
}
|
||||
|
||||
function renderNewSessionPlusMenu(
|
||||
options: NewSessionComposerOptions,
|
||||
attachments: Parameters<typeof renderChatComposerPlusMenu>[0]["attachments"],
|
||||
) {
|
||||
const capabilityMenu = options.capabilityMenu;
|
||||
const draftEnabled = options.visibility === "draft";
|
||||
const overrideCount = countSessionToolOverrides(options.toolOverrides);
|
||||
const selectedCount = overrideCount + (draftEnabled ? 1 : 0);
|
||||
const disabled = options.submitting || options.messageLocked === true;
|
||||
const controller = options.textareaController;
|
||||
return renderChatComposerPlusMenu({
|
||||
attachments,
|
||||
capabilityMenu,
|
||||
disabled,
|
||||
open: controller.capabilityMenuOpen,
|
||||
view: controller.capabilityMenuView,
|
||||
toolOverrides: options.toolOverrides,
|
||||
rootToggles: options.draftAvailable
|
||||
? [
|
||||
{
|
||||
value: "new-session-draft",
|
||||
label: t("newSession.draft"),
|
||||
icon: icons.pencil,
|
||||
checked: draftEnabled,
|
||||
disabled,
|
||||
title: t("newSession.draftDescription"),
|
||||
onChange: (checked) => options.onVisibilityChange?.(checked ? "draft" : "normal"),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
selectedLabel:
|
||||
selectedCount > 0
|
||||
? t("newSession.composerOptionsSelected", { count: String(selectedCount) })
|
||||
: undefined,
|
||||
onOpenChange: (open) => {
|
||||
controller.capabilityMenuOpen = open;
|
||||
if (!open) {
|
||||
controller.capabilityMenuView = "root";
|
||||
}
|
||||
options.requestUpdate();
|
||||
},
|
||||
onViewChange: (view) => {
|
||||
controller.capabilityMenuView = view;
|
||||
options.requestUpdate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderNewSessionSelectionStatus(options: NewSessionComposerOptions) {
|
||||
const draftEnabled = options.visibility === "draft";
|
||||
const overrideCount = countSessionToolOverrides(options.toolOverrides);
|
||||
if (!draftEnabled && overrideCount === 0) {
|
||||
return nothing;
|
||||
}
|
||||
const disabled = options.submitting || options.messageLocked === true;
|
||||
const openMenu = () => {
|
||||
options.textareaController.capabilityMenuView = "root";
|
||||
options.textareaController.capabilityMenuOpen = true;
|
||||
options.requestUpdate();
|
||||
};
|
||||
return html`
|
||||
${draftEnabled
|
||||
? html`<button
|
||||
type="button"
|
||||
class="new-session-page__selection-status"
|
||||
?disabled=${disabled}
|
||||
@click=${openMenu}
|
||||
>
|
||||
${icons.pencil}${t("newSession.draft")}
|
||||
</button>`
|
||||
: nothing}
|
||||
${overrideCount > 0
|
||||
? html`<button
|
||||
type="button"
|
||||
class="new-session-page__selection-status"
|
||||
?disabled=${disabled}
|
||||
@click=${openMenu}
|
||||
>
|
||||
${t(
|
||||
overrideCount === 1
|
||||
? "chat.composer.overrides.countOne"
|
||||
: "chat.composer.overrides.count",
|
||||
{ count: String(overrideCount) },
|
||||
)}
|
||||
</button>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Draft message box styled as the chat composer shell so both pickers match. */
|
||||
function renderNewSessionComposer(options: NewSessionComposerOptions) {
|
||||
const skillMenuState = options.textareaController.skillMenuState;
|
||||
@@ -405,6 +466,12 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
|
||||
canCompose: !options.submitting && !options.messageLocked,
|
||||
});
|
||||
options.textareaController.syncDraft(options.message);
|
||||
const messagePlaceholder = t("newSession.messagePlaceholder");
|
||||
const animatedPlaceholder = options.textareaController.getPlaceholder(
|
||||
messagePlaceholder,
|
||||
options.message,
|
||||
options.requestUpdate,
|
||||
);
|
||||
const skillMenuVisible =
|
||||
!options.submitting && !options.messageLocked && isSkillMenuVisible(skillMenuState);
|
||||
const slashMenuVisible =
|
||||
@@ -449,8 +516,8 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
|
||||
class="new-session-page__message"
|
||||
rows="1"
|
||||
?disabled=${options.submitting || options.messageLocked}
|
||||
placeholder=${t("newSession.messagePlaceholder")}
|
||||
aria-label=${t("newSession.messagePlaceholder")}
|
||||
placeholder=${animatedPlaceholder}
|
||||
aria-label=${messagePlaceholder}
|
||||
.value=${options.message}
|
||||
aria-autocomplete="list"
|
||||
aria-controls=${ifDefined(menuVisible ? menuListboxId : undefined)}
|
||||
@@ -481,16 +548,24 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
|
||||
>${activeMenuOptionLabel}</span
|
||||
>
|
||||
</div>
|
||||
<div class="agent-chat__composer-actions">${renderStartControl(options)}</div>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
<div class="agent-chat__composer-controls">
|
||||
<div class="agent-chat__composer-lead">
|
||||
${renderNewSessionPlusMenu(options, attachmentProps)}
|
||||
${options.modelControl && options.modelControl !== nothing
|
||||
? html`<div class="chat-composer-model-control">${options.modelControl}</div>`
|
||||
: nothing}
|
||||
${options.permissionControl ?? nothing}
|
||||
${options.draftAvailable ? renderNewSessionDraftVisibility(options) : nothing}
|
||||
${renderNewSessionSelectionStatus(options)}
|
||||
</div>
|
||||
<div class="agent-chat__composer-trail">
|
||||
<div class="agent-chat__composer-controls">
|
||||
${options.modelControl && options.modelControl !== nothing
|
||||
? html`<div class="chat-composer-model-control">${options.modelControl}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="agent-chat__composer-actions">
|
||||
${options.voiceControl ?? nothing}${renderStartControl(options)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${options.blockedSubmitNotice
|
||||
? html`<div class="new-session-page__blocked-submit" role="status">
|
||||
@@ -511,6 +586,7 @@ export function renderNewSessionDraftComposer(options: {
|
||||
attachmentDraft: NewSessionAttachmentDraft;
|
||||
canSubmit: boolean;
|
||||
context: import("../../app/context.ts").ApplicationContext | undefined;
|
||||
draftOwnerKey: string;
|
||||
isCatalogTarget: boolean;
|
||||
message: string;
|
||||
visibility?: NewSessionVisibility;
|
||||
@@ -518,7 +594,9 @@ export function renderNewSessionDraftComposer(options: {
|
||||
capabilityMenu?: CapabilityMenuProps;
|
||||
toolOverrides?: SessionToolOverrides | null;
|
||||
modelControl: NewSessionModelControl;
|
||||
permissionControl?: TemplateResult;
|
||||
textareaController: NewSessionComposerTextareaController;
|
||||
voiceControl?: TemplateResult;
|
||||
requiresModifier: boolean;
|
||||
requestUpdate: () => void;
|
||||
submitDisabledReason?: string;
|
||||
@@ -536,7 +614,12 @@ export function renderNewSessionDraftComposer(options: {
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const readSignal = options.attachmentDraft.readSignal;
|
||||
const commandClient = options.context?.gateway.snapshot.client;
|
||||
const commandClient = options.context?.gateway.snapshot.client ?? null;
|
||||
options.textareaController.syncSkillCommandOwner(
|
||||
commandClient,
|
||||
options.agentId,
|
||||
options.draftOwnerKey,
|
||||
);
|
||||
return renderNewSessionComposer({
|
||||
attachmentLimits: options.context?.gateway.snapshot.hello?.policy?.attachments,
|
||||
attachments: options.attachmentDraft.attachments,
|
||||
@@ -555,18 +638,30 @@ export function renderNewSessionDraftComposer(options: {
|
||||
context: options.context,
|
||||
sending: options.submitting,
|
||||
}),
|
||||
permissionControl: options.permissionControl,
|
||||
pendingAttachmentReads: options.attachmentDraft.pendingReads,
|
||||
readSignal,
|
||||
requiresModifier: options.requiresModifier,
|
||||
requestUpdate: options.requestUpdate,
|
||||
refreshCommands: commandClient
|
||||
? () => refreshSlashCommands({ client: commandClient, agentId: options.agentId })
|
||||
? () =>
|
||||
refreshSlashCommands({
|
||||
client: commandClient,
|
||||
agentId: options.agentId,
|
||||
shouldApply: () =>
|
||||
options.textareaController.ownsSkillCommands(
|
||||
commandClient,
|
||||
options.agentId,
|
||||
options.draftOwnerKey,
|
||||
),
|
||||
})
|
||||
: undefined,
|
||||
submitDisabledReason: options.submitDisabledReason,
|
||||
blockedSubmitNotice: options.blockedSubmitNotice,
|
||||
terminalAction: options.terminalAction,
|
||||
submitting: options.submitting,
|
||||
textareaController: options.textareaController,
|
||||
voiceControl: options.voiceControl,
|
||||
messageLocked: options.messageLocked,
|
||||
onAttachmentsChange: (attachments) => {
|
||||
if (!options.submitting && !options.messageLocked) {
|
||||
|
||||
@@ -1,12 +1,114 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { quoteCliArg } from "../../../../src/cli/quote-cli-arg.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { renderConnectCommand } from "../../components/connect-command.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/modal-dialog.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { DevicePairSetup } from "../../lib/device-pair-setup.ts";
|
||||
import { requestDevicePairJoinSetup, type DevicePairSetup } from "../../lib/device-pair-setup.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
import { formatTimeMs } from "../../lib/format.ts";
|
||||
|
||||
/**
|
||||
* The join setup behind the connect-machine dialog.
|
||||
*
|
||||
* The request token and the open flag travel together on purpose: a setup that
|
||||
* arrives after the dialog closed, after the gateway changed, or after a newer
|
||||
* request started must be dropped rather than shown, and closing has to retire
|
||||
* whatever is still in flight.
|
||||
*/
|
||||
export class ConnectMachineSetupState {
|
||||
private openValue = false;
|
||||
private loadingValue = false;
|
||||
private errorValue: string | null = null;
|
||||
private setupValue: DevicePairSetup | null = null;
|
||||
private requestId = 0;
|
||||
|
||||
constructor(
|
||||
private readonly gateway: () => { client: GatewayBrowserClient | null; connected: boolean },
|
||||
private readonly requestUpdate: () => void,
|
||||
) {}
|
||||
|
||||
get open(): boolean {
|
||||
return this.openValue;
|
||||
}
|
||||
|
||||
get loading(): boolean {
|
||||
return this.loadingValue;
|
||||
}
|
||||
|
||||
get error(): string | null {
|
||||
return this.errorValue;
|
||||
}
|
||||
|
||||
get setup(): DevicePairSetup | null {
|
||||
return this.setupValue;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.openValue = true;
|
||||
this.errorValue = null;
|
||||
this.setupValue = null;
|
||||
this.requestUpdate();
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.requestId += 1;
|
||||
this.openValue = false;
|
||||
this.loadingValue = false;
|
||||
this.errorValue = null;
|
||||
this.setupValue = null;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (!this.openValue || this.loadingValue) {
|
||||
return;
|
||||
}
|
||||
const { client, connected } = this.gateway();
|
||||
if (!connected || !client) {
|
||||
this.errorValue = t("newSession.connectMachineUnavailable");
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
const requestId = ++this.requestId;
|
||||
this.loadingValue = true;
|
||||
this.errorValue = null;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const setup = await requestDevicePairJoinSetup(client);
|
||||
if (!this.stillCurrent(requestId, client)) {
|
||||
return;
|
||||
}
|
||||
if (!setup.joinUrl?.trim()) {
|
||||
this.setupValue = null;
|
||||
this.errorValue = t("newSession.connectMachineMissingUrl");
|
||||
return;
|
||||
}
|
||||
this.setupValue = setup;
|
||||
} catch (error) {
|
||||
if (this.stillCurrent(requestId, client)) {
|
||||
this.errorValue = formatUiError(error);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === this.requestId) {
|
||||
this.loadingValue = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stillCurrent(requestId: number, client: GatewayBrowserClient): boolean {
|
||||
const gateway = this.gateway();
|
||||
return (
|
||||
requestId === this.requestId &&
|
||||
client === gateway.client &&
|
||||
gateway.connected &&
|
||||
this.openValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectMachineDialogProps = {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
|
||||
@@ -33,6 +33,7 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
contextWindow?: string;
|
||||
thinkingLevel?: string;
|
||||
toolOverrides?: SessionCreateParams["toolOverrides"] | null;
|
||||
permissionMode?: SessionCreateParams["permissionMode"];
|
||||
visibility?: NewSessionVisibility;
|
||||
attachments?: SessionCreateParams["attachments"];
|
||||
projectId?: string;
|
||||
@@ -66,6 +67,7 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
...(!catalogId && contextWindow ? { contextWindow } : {}),
|
||||
...(!catalogId && thinkingLevel ? { thinkingLevel } : {}),
|
||||
...(draft.toolOverrides ? { toolOverrides: draft.toolOverrides } : {}),
|
||||
...(draft.permissionMode ? { permissionMode: draft.permissionMode } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(customFolder ? { cwd: customFolder } : {}),
|
||||
...(draft.worktree
|
||||
|
||||
@@ -57,6 +57,7 @@ describe("new-session placement target", () => {
|
||||
key: "agent:main:cloud",
|
||||
agentId: "main",
|
||||
message: "",
|
||||
permissionMode: "guarded",
|
||||
visibility: "draft",
|
||||
toolOverrides: { skills: { release: false } },
|
||||
worktree: true,
|
||||
@@ -64,6 +65,7 @@ describe("new-session placement target", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
draft: {
|
||||
permissionMode: "guarded",
|
||||
visibility: "draft",
|
||||
toolOverrides: { skills: { release: false } },
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ export function projectDraftSessionPlacementRecovery(recovery: SessionPlacementR
|
||||
attachments: restoreChatApiAttachments(recovery.attachments),
|
||||
visibility,
|
||||
toolOverrides: recovery.createParams?.toolOverrides ?? null,
|
||||
permissionMode: recovery.createParams?.permissionMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
DraftSubmissionCallbacks,
|
||||
DraftSubmissionSnapshot,
|
||||
} from "./draft-submission-contract.ts";
|
||||
import { NewSessionPermissionSelection } from "./permission-selection.ts";
|
||||
import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts";
|
||||
import {
|
||||
PendingSessionPlacementRecoveryState,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
import { StartedSessionNavigation } from "./started-session-navigation.ts";
|
||||
import {
|
||||
PAGE_RENDERED_GATES,
|
||||
resolveCloudPlacementDisabledReason,
|
||||
resolveNewSessionSubmitBlock,
|
||||
type NewSessionSubmitBlock,
|
||||
} from "./submit-gates.ts";
|
||||
@@ -65,6 +67,7 @@ export class DraftSubmissionFlow {
|
||||
readonly pendingPlacement = new PendingSessionPlacementRecoveryState();
|
||||
readonly attachmentDraft: NewSessionAttachmentDraft;
|
||||
readonly composerTextarea = new NewSessionComposerTextareaController();
|
||||
readonly permission = new NewSessionPermissionSelection(() => this.callbacks.requestUpdate());
|
||||
readonly draftPersistence: NewSessionDraftPersistence;
|
||||
readonly capabilities: NewSessionCapabilityController;
|
||||
|
||||
@@ -76,6 +79,7 @@ export class DraftSubmissionFlow {
|
||||
) {
|
||||
this.capabilities = new NewSessionCapabilityController(callbacks.requestUpdate);
|
||||
this.capabilities.setMutationCallback(() => (this.startedSession.current = null));
|
||||
this.permission.setMutationCallback(() => (this.startedSession.current = null));
|
||||
this.sessionStartup = new DraftSessionStartup(gateway);
|
||||
this.draftPersistence = new NewSessionDraftPersistence(
|
||||
() => ({
|
||||
@@ -145,11 +149,15 @@ export class DraftSubmissionFlow {
|
||||
attachments: ChatAttachment[];
|
||||
visibility: NewSessionVisibility;
|
||||
toolOverrides?: NewSessionCapabilityController["toolOverrides"];
|
||||
permissionMode?: SessionCreateParams["permissionMode"];
|
||||
}) {
|
||||
this.draftPersistence.noteDraftReplaced();
|
||||
this.messageValue = state.message;
|
||||
this.visibilityValue = state.visibility;
|
||||
this.capabilities.restoreToolOverrides(state.toolOverrides);
|
||||
if ("permissionMode" in state) {
|
||||
this.permission.restore(state.permissionMode);
|
||||
}
|
||||
this.attachmentDraft.restore(state.attachments);
|
||||
}
|
||||
|
||||
@@ -232,6 +240,7 @@ export class DraftSubmissionFlow {
|
||||
contextWindow: this.place.modelControl.contextWindow,
|
||||
thinkingLevel: this.place.modelControl.thinkingLevel,
|
||||
toolOverrides: this.capabilities.toolOverrides,
|
||||
permissionMode: this.permission.value,
|
||||
visibility: options.visibility ?? this.visibilityValue,
|
||||
attachments: options.attachments,
|
||||
projectId: this.place.browser.remoteProject?.projectId ?? this.place.browser.projectId,
|
||||
@@ -348,19 +357,7 @@ export class DraftSubmissionFlow {
|
||||
});
|
||||
}
|
||||
|
||||
cloudDisabledReason(): string | undefined {
|
||||
const runtimeReason = this.place.modelControl.cloudRuntimeUnsupportedReason();
|
||||
if (runtimeReason) {
|
||||
return runtimeReason;
|
||||
}
|
||||
if (this.place.repository.kind === "checking") {
|
||||
return t("newSession.checkingGit");
|
||||
}
|
||||
if (this.place.repository.kind === "unavailable" && !this.place.worktreeAvailable()) {
|
||||
return t("newSession.gitCheckUnavailable");
|
||||
}
|
||||
return this.place.worktreeAvailable() ? undefined : t("newSession.cloudRequiresWorktree");
|
||||
}
|
||||
cloudDisabledReason = () => resolveCloudPlacementDisabledReason(this.place);
|
||||
|
||||
invalidate(outcomeUnknown: SubmissionOutcomeReason | null = null) {
|
||||
this.submitRequestToken += 1;
|
||||
@@ -385,6 +382,7 @@ export class DraftSubmissionFlow {
|
||||
: null;
|
||||
this.visibilityValue = "normal";
|
||||
this.capabilities.reset();
|
||||
this.permission.reset();
|
||||
this.attachmentDraft.reset({ release: true });
|
||||
if (preservePendingPlacement) {
|
||||
if (!this.pendingPlacement.restored) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { html } from "lit";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { html, svg } from "lit";
|
||||
import { strokeIcon } from "../../components/icons-tools.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { NewSessionVisibility } from "./create-params.ts";
|
||||
|
||||
const shredderIcon = strokeIcon(svg` <path
|
||||
d="M4 13V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v5"
|
||||
/>
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M10 22v-5" />
|
||||
<path d="M14 19v-2" />
|
||||
<path d="M18 20v-3" />
|
||||
<path d="M2 13h20" />
|
||||
<path d="M6 20v-3" />`);
|
||||
|
||||
/** Page-level session privacy control for the fixed new-session rail. */
|
||||
export function renderNewSessionIncognitoControl(submission: {
|
||||
visibility: NewSessionVisibility;
|
||||
@@ -38,9 +48,28 @@ export function renderNewSessionIncognitoControl(submission: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.eyeOff}
|
||||
${shredderIcon}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Persistent context beside the draft while ephemeral session mode is active. */
|
||||
export function renderNewSessionIncognitoNotice(active: boolean) {
|
||||
const description = t("newSession.incognitoDescription");
|
||||
return html`
|
||||
<div
|
||||
class="new-session-page__incognito-notice ${active
|
||||
? "new-session-page__incognito-notice--visible"
|
||||
: ""}"
|
||||
role="status"
|
||||
aria-hidden=${String(!active)}
|
||||
>
|
||||
<span class="new-session-page__incognito-notice-icon" aria-hidden="true">
|
||||
${shredderIcon}
|
||||
</span>
|
||||
<span>${description}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import type { ImageLightboxItem } from "../../components/image-lightbox.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import "../../components/web-awesome-popover.ts";
|
||||
import { normalizeAgentTargetLabel } from "../../lib/agents/display.ts";
|
||||
import { requestDevicePairJoinSetup, type DevicePairSetup } from "../../lib/device-pair-setup.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
import { canCallGatewayMethod } from "../../lib/gateway-methods.ts";
|
||||
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
|
||||
import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
@@ -20,10 +18,12 @@ import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import "../../styles/chat.css";
|
||||
import "../../styles/new-session.css";
|
||||
import { renderChatImageLightbox } from "../chat/components/chat-image-lightbox.ts";
|
||||
import { renderChatPermissionPicker } from "../chat/components/chat-permission-picker.ts";
|
||||
import { renderWelcomeState } from "../chat/components/chat-welcome.ts";
|
||||
import * as catalog from "./catalog-target.ts";
|
||||
import { NewSessionDictationControl } from "./composer-dictation-control.ts";
|
||||
import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts";
|
||||
import { renderConnectMachineDialog } from "./connect-machine-dialog.ts";
|
||||
import { ConnectMachineSetupState, renderConnectMachineDialog } from "./connect-machine-dialog.ts";
|
||||
import { isWorktreeNameValid } from "./create-params.ts";
|
||||
import { renderDetailChip, resolveDetailChip } from "./detail-chip.ts";
|
||||
import { DraftGatewayState } from "./draft-gateway-state.ts";
|
||||
@@ -31,7 +31,10 @@ import * as drafts from "./draft-navigation-handoff.ts";
|
||||
import { DraftPlaceBrowser } from "./draft-place-browser.ts";
|
||||
import { DraftPlaceState } from "./draft-place-state.ts";
|
||||
import { DraftSubmissionFlow } from "./draft-submission-flow.ts";
|
||||
import { renderNewSessionIncognitoControl } from "./incognito-control.ts";
|
||||
import {
|
||||
renderNewSessionIncognitoControl,
|
||||
renderNewSessionIncognitoNotice,
|
||||
} from "./incognito-control.ts";
|
||||
import type { NewSessionRouteData } from "./location.ts";
|
||||
import {
|
||||
closeAgentPicker,
|
||||
@@ -59,11 +62,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
private openedAgentId = "";
|
||||
private messageOwnerKey = "";
|
||||
private presenceSignature = "";
|
||||
private connectMachineOpen = false;
|
||||
private connectMachineLoading = false;
|
||||
private connectMachineError: string | null = null;
|
||||
private connectMachineSetup: DevicePairSetup | null = null;
|
||||
private connectMachineRequestId = 0;
|
||||
private readonly connectMachine: ConnectMachineSetupState;
|
||||
@state() private imageLightbox: ImageLightboxItem | null = null;
|
||||
private readonly groupRouteRevalidation = new catalog.GroupRouteRevalidation(
|
||||
() => this.data,
|
||||
@@ -73,6 +72,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
private readonly browser: DraftPlaceBrowser;
|
||||
private readonly place: DraftPlaceState;
|
||||
private readonly submission: DraftSubmissionFlow;
|
||||
private readonly dictation: NewSessionDictationControl;
|
||||
private readonly subscriptions: SubscriptionsController;
|
||||
private readonly flushDraft = () => this.submission.draftPersistence.persistNow();
|
||||
|
||||
@@ -156,6 +156,20 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
closeTransientUi: () => closeSessionMenus(this),
|
||||
},
|
||||
);
|
||||
this.connectMachine = new ConnectMachineSetupState(
|
||||
() => ({ client: this.gateway.client, connected: this.gateway.connected }),
|
||||
() => this.requestUpdate(),
|
||||
);
|
||||
this.dictation = new NewSessionDictationControl({
|
||||
textarea: this.submission.composerTextarea,
|
||||
getClient: () => this.gateway.client,
|
||||
isConnected: () => this.gateway.connected,
|
||||
canCommit: () => !this.submission.submitting && !this.submission.pendingPlacement.sessionKey,
|
||||
onMessage: (message) => this.setMessageFromUser(message),
|
||||
onError: (message) => this.submission.setError(message),
|
||||
onClearError: (message) => this.submission.clearErrorIf(message),
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
});
|
||||
this.subscriptions = new SubscriptionsController(this)
|
||||
.watch(
|
||||
() => this.context?.gateway,
|
||||
@@ -235,13 +249,14 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.gateway.disconnect();
|
||||
this.browser.disconnect();
|
||||
this.submission.disconnect();
|
||||
this.closeConnectMachine();
|
||||
this.dictation.dispose();
|
||||
this.connectMachine.close();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override updated() {
|
||||
if (this.connectMachineOpen && !this.place.isAdmin()) {
|
||||
this.closeConnectMachine();
|
||||
if (this.connectMachine.open && !this.place.isAdmin()) {
|
||||
this.connectMachine.close();
|
||||
}
|
||||
this.gateway.retryPendingCatalogTarget();
|
||||
void this.context?.agentIdentity.ensure(this.place.agents().map((agent) => agent.id));
|
||||
@@ -258,9 +273,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
agentsReady && this.place.agentId ? (this.place.selectedAgent()?.id ?? "") : "",
|
||||
this.context?.config.current.cliAgentsEnabled === true && !catalog.isTarget(this.data),
|
||||
);
|
||||
const openKey = this.data
|
||||
? catalog.routeKey(this.data)
|
||||
: catalog.routeKeyFromSearch(window.location.search);
|
||||
const openKey = this.routeOwnerKey();
|
||||
const resolvedAgentId = this.data?.agentId ?? "";
|
||||
const groupDefaults = catalog.groupDefaultsKey(this.data);
|
||||
if (this.openedFor !== openKey) {
|
||||
@@ -306,7 +319,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
if (resetHostSelection) {
|
||||
this.submission.clearError();
|
||||
}
|
||||
this.closeConnectMachine();
|
||||
this.connectMachine.close();
|
||||
}
|
||||
|
||||
private resetDraft() {
|
||||
@@ -316,11 +329,14 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.browser.clearPopoverHiding();
|
||||
closeAgentPicker(this);
|
||||
this.browser.close();
|
||||
this.closeConnectMachine();
|
||||
this.connectMachine.close();
|
||||
this.place.adoptAgentDefaults();
|
||||
void this.updateComplete.then(() => {
|
||||
this.querySelector<HTMLTextAreaElement>(".new-session-page__message")?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private routeOwnerKey(): string {
|
||||
return this.data
|
||||
? catalog.routeKey(this.data)
|
||||
: catalog.routeKeyFromSearch(window.location.search);
|
||||
}
|
||||
|
||||
private setMessage(message: string, ownerKey = catalog.routeKey(this.data)) {
|
||||
@@ -514,72 +530,15 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
this.browser.close();
|
||||
this.connectMachineOpen = true;
|
||||
this.connectMachineError = null;
|
||||
this.connectMachineSetup = null;
|
||||
this.requestUpdate();
|
||||
void this.refreshConnectMachine();
|
||||
}
|
||||
|
||||
private async refreshConnectMachine() {
|
||||
if (!this.connectMachineOpen || this.connectMachineLoading) {
|
||||
return;
|
||||
}
|
||||
const client = this.gateway.connected ? this.gateway.client : null;
|
||||
if (!client) {
|
||||
this.connectMachineError = t("newSession.connectMachineUnavailable");
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
const requestId = ++this.connectMachineRequestId;
|
||||
this.connectMachineLoading = true;
|
||||
this.connectMachineError = null;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const setup = await requestDevicePairJoinSetup(client);
|
||||
if (
|
||||
requestId !== this.connectMachineRequestId ||
|
||||
client !== this.gateway.client ||
|
||||
!this.gateway.connected ||
|
||||
!this.connectMachineOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!setup.joinUrl?.trim()) {
|
||||
this.connectMachineSetup = null;
|
||||
this.connectMachineError = t("newSession.connectMachineMissingUrl");
|
||||
return;
|
||||
}
|
||||
this.connectMachineSetup = setup;
|
||||
} catch (error) {
|
||||
if (
|
||||
requestId === this.connectMachineRequestId &&
|
||||
client === this.gateway.client &&
|
||||
this.gateway.connected &&
|
||||
this.connectMachineOpen
|
||||
) {
|
||||
this.connectMachineError = formatUiError(error);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === this.connectMachineRequestId) {
|
||||
this.connectMachineLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private closeConnectMachine() {
|
||||
this.connectMachineRequestId += 1;
|
||||
this.connectMachineOpen = false;
|
||||
this.connectMachineLoading = false;
|
||||
this.connectMachineError = null;
|
||||
this.connectMachineSetup = null;
|
||||
this.connectMachine.start();
|
||||
}
|
||||
|
||||
private renderDraftBlock() {
|
||||
const worktreeNameInvalid =
|
||||
this.place.worktree && !isWorktreeNameValid(this.place.worktreeName);
|
||||
const capabilities = this.submission.capabilities;
|
||||
const voiceControl = this.dictation.render(this.routeOwnerKey());
|
||||
const dictationLocked = this.dictation.locked;
|
||||
return html`
|
||||
<div class="new-session-page__draft" aria-busy=${String(this.submission.submitting)}>
|
||||
${this.renderTargetBar()}
|
||||
@@ -604,24 +563,42 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
agent: this.place.selectedAgent(),
|
||||
agentId: this.place.agentId,
|
||||
attachmentDraft: this.submission.attachmentDraft,
|
||||
canSubmit: !this.submission.submitting && this.submission.canSubmit(),
|
||||
canSubmit: !this.submission.submitting && !dictationLocked && this.submission.canSubmit(),
|
||||
submitDisabledReason: this.submission.submitDisabledReason(),
|
||||
blockedSubmitNotice: this.submission.blockedSubmitNotice(),
|
||||
context: this.context,
|
||||
isCatalogTarget: catalog.isTarget(this.data),
|
||||
draftOwnerKey: this.routeOwnerKey(),
|
||||
message: this.submission.message,
|
||||
visibility: this.submission.visibility,
|
||||
draftAvailable: capabilities.canStartAsDraft(this.context),
|
||||
...capabilities.composerProps(this.context, this.gateway, this.place.agentId),
|
||||
modelControl: this.place.modelControl,
|
||||
permissionControl: catalog.isTarget(this.data)
|
||||
? undefined
|
||||
: renderChatPermissionPicker({
|
||||
canSelectFull: this.place.isAdmin(),
|
||||
disabled:
|
||||
this.submission.submitting ||
|
||||
Boolean(this.submission.pendingPlacement.sessionKey),
|
||||
disabledReason: this.submission.submitting ? t("newSession.starting") : undefined,
|
||||
mode: this.submission.permission.value,
|
||||
sessionRoot: this.place.workspacePath(),
|
||||
onSelect: (permissionMode) =>
|
||||
this.submission.permission.set(permissionMode ?? undefined),
|
||||
}),
|
||||
requiresModifier: loadSettings().chatSendShortcut === "modifier-enter",
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
submitting: this.submission.submitting,
|
||||
textareaController: this.submission.composerTextarea,
|
||||
voiceControl,
|
||||
messageLocked: Boolean(this.submission.pendingPlacement.sessionKey),
|
||||
terminalAction: this.submission.showStartInTerminal()
|
||||
? {
|
||||
canStart: !this.submission.submitting && this.submission.canSubmit("terminal"),
|
||||
canStart:
|
||||
!this.submission.submitting &&
|
||||
!dictationLocked &&
|
||||
this.submission.canSubmit("terminal"),
|
||||
disabledReason: this.submission.submitBlock("terminal")?.reason,
|
||||
onStart: () => void this.submission.startInTerminal(),
|
||||
}
|
||||
@@ -637,6 +614,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
},
|
||||
onSubmit: () => void this.submission.submit(),
|
||||
})}
|
||||
${renderNewSessionIncognitoNotice(this.submission.visibility === "incognito")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -651,6 +629,8 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
assistantAvatarUrl: agent?.identity?.avatarUrl ?? null,
|
||||
hint: t("newSession.hint"),
|
||||
composer: this.renderDraftBlock(),
|
||||
hideSecondaryContent: this.submission.visibility === "incognito",
|
||||
fadeSecondaryContent: this.submission.message.trim().length > 0,
|
||||
modelSetupRequired: this.submission.requiresModelSetup(),
|
||||
onModelSetup: () => this.context?.navigate("model-setup"),
|
||||
sessions: this.context?.sessions.state.result,
|
||||
@@ -689,7 +669,11 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="new-session-page">
|
||||
<div
|
||||
class="new-session-page ${this.submission.visibility === "incognito"
|
||||
? "new-session-page--incognito"
|
||||
: ""}"
|
||||
>
|
||||
${renderNewSessionIncognitoControl(this.submission)}
|
||||
<div
|
||||
class="new-session-page__scroll"
|
||||
@@ -700,17 +684,17 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
${this.renderWelcome()}
|
||||
</div>
|
||||
${renderConnectMachineDialog({
|
||||
open: this.connectMachineOpen && this.place.isAdmin(),
|
||||
loading: this.connectMachineLoading,
|
||||
error: this.connectMachineError,
|
||||
setup: this.connectMachineSetup,
|
||||
onRefresh: () => void this.refreshConnectMachine(),
|
||||
open: this.connectMachine.open && this.place.isAdmin(),
|
||||
loading: this.connectMachine.loading,
|
||||
error: this.connectMachine.error,
|
||||
setup: this.connectMachine.setup,
|
||||
onRefresh: () => void this.connectMachine.refresh(),
|
||||
onClose: () => {
|
||||
this.closeConnectMachine();
|
||||
this.connectMachine.close();
|
||||
this.requestUpdate();
|
||||
},
|
||||
onManageDevices: () => {
|
||||
this.closeConnectMachine();
|
||||
this.connectMachine.close();
|
||||
this.context?.navigate("devices");
|
||||
},
|
||||
})}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user