mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(ui): remove local-only chat message hide feature (#120681)
Remove the localStorage-backed per-browser hide action ("Hide in this browser only") so the transcript mirrors agent context. The action was un-undoable because restore() had no UI path.
Stale openclaw:deleted:* localStorage keys become inert.
This commit is contained in:
committed by
GitHub
parent
29a446f886
commit
4930c9d7de
@@ -464,7 +464,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config
|
||||
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
|
||||
- The session header shows a small facepile beside the workspace chip when other people are viewing the same session; it lists up to four viewer avatars with an overflow count and disappears when you are alone.
|
||||
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
|
||||
- User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store. When that store contains multiple transcript branches, the chat title bar shows a branch menu with each branch's latest message, message count, and recency; selecting an inactive branch switches the current session back to that preserved path (`sessions.branches.list`, `operator.read`; `sessions.branches.switch`, `operator.admin`). Branch switching is also unavailable while the agent is working, and selecting the already-active branch is a typed no-op error at the RPC boundary. The separate hide action on user bubbles hides a message in the current browser only; the message stays in the transcript and the agent still sees it.
|
||||
- User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store. When that store contains multiple transcript branches, the chat title bar shows a branch menu with each branch's latest message, message count, and recency; selecting an inactive branch switches the current session back to that preserved path (`sessions.branches.list`, `operator.read`; `sessions.branches.switch`, `operator.admin`). Branch switching is also unavailable while the agent is working, and selecting the already-active branch is a typed no-op error at the RPC boundary.
|
||||
- When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. The Gateway polls only sessions visible in a connected Control UI and pushes changed snapshots through `controlUi.sessionPullRequests.changed`; it reuses `GH_TOKEN`/`GITHUB_TOKEN` when set. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). Once the pushed branch has commits to compare, the row adds a Create PR button that opens GitHub's new-pull-request page; before that, a session with changed files (committed, uncommitted, or untracked) still gets the row without the button. The row hides itself while an open or draft PR exists; once the branch's PR is merged and the pushed tip still matches the merged head, the row disappears too (returning without the Create PR button only when new local work appears, and with it once new commits are pushed past the merged head). The branch row comes from local git only, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets.
|
||||
- The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens the detail panel with a per-file diff of branch, uncommitted, and untracked work against the checkout's default-branch merge base — status dot, rename arrow, per-file +/− counts, collapsible files, and "N unmodified lines" markers between hunks. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`.
|
||||
- Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal.
|
||||
|
||||
@@ -120,7 +120,7 @@ suite.define(() => {
|
||||
return element.getAttribute("aria-label");
|
||||
}),
|
||||
);
|
||||
expect(footerOrder).toEqual(["Reply to message", "Hide message", "Rewind", "name", "time"]);
|
||||
expect(footerOrder).toEqual(["Reply to message", "Rewind", "name", "time"]);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
@@ -254,43 +254,14 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
const inlineActions = group.locator(".chat-group-footer-actions button");
|
||||
expect(
|
||||
await inlineActions.evaluateAll((buttons) => buttons.map((button) => button.ariaLabel)),
|
||||
).toEqual(["Reply to message", "Hide message", "Copy as markdown"]);
|
||||
).toEqual(["Reply to message", "Copy as markdown"]);
|
||||
for (const button of await inlineActions.all()) {
|
||||
const label = await button.getAttribute("aria-label");
|
||||
await expectHoverColor(button, label === "Hide message" ? "--danger" : "--accent");
|
||||
await expectHoverColor(button, "--accent");
|
||||
}
|
||||
const replyButton = group.getByRole("button", { name: "Reply to message" });
|
||||
await expectHoverTooltip(replyButton, "Reply");
|
||||
await screenshot(page, "01-inline-actions.png");
|
||||
|
||||
const hideButton = group.getByRole("button", { name: "Hide message" });
|
||||
expect(
|
||||
await hideButton.evaluate((element) => {
|
||||
const row = element.closest<HTMLElement>(".chat-virtual-row");
|
||||
return Boolean(row && getComputedStyle(row).transform !== "none");
|
||||
}),
|
||||
).toBe(true);
|
||||
await hideButton.click();
|
||||
const hideConfirmation = page.locator(".chat-delete-confirm");
|
||||
await hideConfirmation.waitFor({ state: "visible" });
|
||||
expect(
|
||||
await hideConfirmation.evaluate((element) => element.parentElement === document.body),
|
||||
).toBe(true);
|
||||
const hideConfirmationBounds = await hideConfirmation.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
expect(hideConfirmationBounds).not.toBeNull();
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(hideConfirmationBounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(hideConfirmationBounds!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(hideConfirmationBounds!.x + hideConfirmationBounds!.width).toBeLessThanOrEqual(
|
||||
viewport!.width,
|
||||
);
|
||||
expect(hideConfirmationBounds!.y + hideConfirmationBounds!.height).toBeLessThanOrEqual(
|
||||
viewport!.height,
|
||||
);
|
||||
await screenshot(page, "02-hide-confirmation.png");
|
||||
await hideConfirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await group.hover();
|
||||
await replyButton.click();
|
||||
const replyPreview = page.locator(".chat-reply-preview");
|
||||
@@ -332,7 +303,6 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
expect(await menu.getByRole("menuitem").allTextContents()).toEqual([
|
||||
"Copy",
|
||||
"Reply",
|
||||
"Hide message",
|
||||
"Copy as markdown",
|
||||
]);
|
||||
await screenshot(page, "04-selected-text-context-menu.png");
|
||||
@@ -346,7 +316,6 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
await menu.waitFor({ state: "visible" });
|
||||
expect(await menu.getByRole("menuitem").allTextContents()).toEqual([
|
||||
"Reply",
|
||||
"Hide message",
|
||||
"Copy as markdown",
|
||||
]);
|
||||
expect(
|
||||
@@ -435,11 +404,6 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
state: "visible",
|
||||
});
|
||||
expect(await gateway.getRequests("chat.message.get")).toHaveLength(1);
|
||||
|
||||
await bubble.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Hide message" }).click();
|
||||
await page.locator(".chat-delete-confirm").getByRole("button", { name: "Hide" }).click();
|
||||
await expect.poll(() => group.count()).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
@@ -710,7 +710,7 @@ suite.define(() => {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await page.getByText(/^focus retention message 200\n/).waitFor();
|
||||
const thread = page.locator(".chat-thread");
|
||||
const action = thread.locator("button.chat-group-delete").last();
|
||||
const action = thread.locator("button.chat-reply-btn").last();
|
||||
await action.focus();
|
||||
const focusedRowKey = await action.evaluate(
|
||||
(element) => element.closest<HTMLElement>(".chat-virtual-row")?.dataset.virtualRowKey ?? "",
|
||||
|
||||
@@ -4814,10 +4814,6 @@ export const en: TranslationMap = {
|
||||
activity: "Activity",
|
||||
copySelection: "Copy",
|
||||
forkFromHere: "Fork from here",
|
||||
hide: "Hide",
|
||||
hideConfirm: "Hide this message in this browser? The agent still sees it.",
|
||||
hideMessage: "Hide message",
|
||||
hideTooltip: "Hide in this browser only",
|
||||
fullContentLoadFailed: "Could not load the full message.",
|
||||
reply: "Reply",
|
||||
replyToMessage: "Reply to message",
|
||||
|
||||
@@ -351,17 +351,6 @@ export function buildCachedChatItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
export function deletedChatItemsSignature(
|
||||
deleted: { has: (key: string) => boolean },
|
||||
chatItems: ReturnType<typeof buildChatItems>,
|
||||
): string {
|
||||
const deletedKeys = chatItems
|
||||
.map((item) => item.key)
|
||||
.filter((key) => deleted.has(key))
|
||||
.toSorted();
|
||||
return deletedKeys.length === 0 ? "" : deletedKeys.join("\u0000");
|
||||
}
|
||||
|
||||
export function getExpansionStateVersion(values: ReadonlyMap<string, boolean>): number {
|
||||
return expandedBooleanMapVersions.get(values) ?? 0;
|
||||
}
|
||||
|
||||
@@ -2612,28 +2612,6 @@ describe("chat loading skeleton", () => {
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps live status standalone when the preceding response is hidden", () => {
|
||||
const sessionKey = "deleted-active-status";
|
||||
renderChatView({
|
||||
sessionKey,
|
||||
messages: [{ role: "assistant", content: "Hidden answer", timestamp: 1 }],
|
||||
});
|
||||
const onDelete = renderMessageGroupMock.mock.calls[0]?.[1].onDelete;
|
||||
expect(onDelete).toBeTypeOf("function");
|
||||
onDelete?.();
|
||||
renderMessageGroupMock.mockClear();
|
||||
|
||||
const container = renderChatView({
|
||||
canAbort: true,
|
||||
sessionKey,
|
||||
messages: [{ role: "assistant", content: "Hidden answer", timestamp: 1 }],
|
||||
stream: null,
|
||||
});
|
||||
|
||||
expect(renderMessageGroupMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows prompt-bar progress beside context usage while the current session send is awaiting acknowledgement", () => {
|
||||
const container = renderChatView({
|
||||
sending: true,
|
||||
@@ -6210,7 +6188,7 @@ describe("right-click Reply", () => {
|
||||
const labels = [...document.querySelectorAll(".chat-reply-context-menu button")].map((button) =>
|
||||
button.textContent?.trim(),
|
||||
);
|
||||
expect(labels).toEqual(["Reply", "Rewind to here", "Hide message", "Fork from here"]);
|
||||
expect(labels).toEqual(["Reply", "Rewind to here", "Fork from here"]);
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Fork from here"]')!.click();
|
||||
expect(onForkMessage).toHaveBeenCalledWith("persisted-user");
|
||||
|
||||
@@ -6229,7 +6207,7 @@ describe("right-click Reply", () => {
|
||||
[...document.querySelectorAll(".chat-reply-context-menu button")].map((button) =>
|
||||
button.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["Reply", "Hide message", "Copy as markdown"]);
|
||||
).toEqual(["Reply", "Copy as markdown"]);
|
||||
expect(
|
||||
document.querySelector('.chat-reply-context-menu [aria-label="Reply to message"] svg'),
|
||||
).toBeNull();
|
||||
@@ -6239,37 +6217,6 @@ describe("right-click Reply", () => {
|
||||
expect(onCopy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("confirms Hide before hiding every member of an aggregate activity row", () => {
|
||||
const storedValues = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
clear: () => storedValues.clear(),
|
||||
getItem: (key: string) => storedValues.get(key) ?? null,
|
||||
key: (index: number) => [...storedValues.keys()][index] ?? null,
|
||||
get length() {
|
||||
return storedValues.size;
|
||||
},
|
||||
removeItem: (key: string) => storedValues.delete(key),
|
||||
setItem: (key: string, value: string) => storedValues.set(key, value),
|
||||
} satisfies Storage);
|
||||
const onRequestUpdate = vi.fn();
|
||||
const { bubble, group } = renderChatBubble(
|
||||
{ sessionKey: "context-hide-test", onRequestUpdate },
|
||||
{ groupClass: "chat-group assistant" },
|
||||
);
|
||||
group.dataset.chatRowKey = "group:tool:first";
|
||||
group.dataset.chatRowKeys = JSON.stringify(["group:tool:first", "group:tool:second"]);
|
||||
|
||||
dispatchContextMenu(bubble);
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Hide message"]')!.click();
|
||||
expect(document.querySelector(".chat-delete-confirm")).not.toBeNull();
|
||||
document.querySelector<HTMLButtonElement>(".chat-delete-confirm__yes")!.click();
|
||||
|
||||
expect(storedValues.get("openclaw:deleted:context-hide-test")).toBe(
|
||||
JSON.stringify(["group:tool:first", "group:tool:second"]),
|
||||
);
|
||||
expect(onRequestUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("dismisses an inline confirmation before opening the reply context menu", () => {
|
||||
const container = renderChatView({ onSetReply: vi.fn() });
|
||||
document.body.appendChild(container);
|
||||
|
||||
@@ -412,7 +412,6 @@ export function renderChat(props: ChatProps) {
|
||||
showThinking: props.showThinking,
|
||||
showToolCalls: props.showToolCalls,
|
||||
persistCommentary: props.persistCommentary,
|
||||
readOnly: true,
|
||||
sessions: props.sessions,
|
||||
sessionHost: props.sessionHost,
|
||||
gatewayUrl: props.gatewayUrl,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { getSafeLocalStorage } from "../../../local-storage.ts";
|
||||
|
||||
const SKIP_DELETE_CONFIRM_PREFERENCE = "openclaw:skipDeleteConfirm";
|
||||
const SKIP_REWIND_CONFIRM_PREFERENCE = "openclaw:skip-rewind-confirm";
|
||||
const DELETE_CONFIRM_VIEWPORT_MARGIN_PX = 8;
|
||||
const DELETE_CONFIRM_TRIGGER_GAP_PX = 6;
|
||||
@@ -100,22 +99,6 @@ function placeDeleteConfirmPopover(
|
||||
popover.dataset.placement = placeBelow ? "below" : "above";
|
||||
}
|
||||
|
||||
export function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) {
|
||||
// "Hide" is honest copy: this action only hides the bubble in this browser's
|
||||
// localStorage; the message stays in the transcript and in agent context.
|
||||
return renderConfirmedActionButton({
|
||||
action: onDelete,
|
||||
ariaLabel: t("chat.messages.hideMessage"),
|
||||
buttonClass: "chat-group-delete",
|
||||
confirmLabel: t("chat.messages.hide"),
|
||||
confirmText: t("chat.messages.hideConfirm"),
|
||||
icon: icons.eyeOff ?? icons.x,
|
||||
preferenceName: SKIP_DELETE_CONFIRM_PREFERENCE,
|
||||
side,
|
||||
tooltip: t("chat.messages.hideTooltip"),
|
||||
});
|
||||
}
|
||||
|
||||
export function renderRewindButton(
|
||||
onRewind: () => void,
|
||||
disabled: boolean,
|
||||
@@ -160,16 +143,6 @@ export function openChatRewindConfirmation(trigger: HTMLElement, action: () => v
|
||||
});
|
||||
}
|
||||
|
||||
export function openChatHideConfirmation(trigger: HTMLElement, action: () => void): void {
|
||||
openConfirmedActionPopover(trigger, {
|
||||
action,
|
||||
confirmLabel: t("chat.messages.hide"),
|
||||
confirmText: t("chat.messages.hideConfirm"),
|
||||
preferenceName: SKIP_DELETE_CONFIRM_PREFERENCE,
|
||||
side: "right",
|
||||
});
|
||||
}
|
||||
|
||||
function openConfirmedActionPopover(
|
||||
btn: HTMLElement,
|
||||
params: Pick<
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { workspaceResultConflictFromTranscript } from "../workspace-conflict.ts";
|
||||
import { renderChatAuthorAvatar } from "./chat-author-avatar.ts";
|
||||
import { renderGroupedMessage } from "./chat-message-bubble.ts";
|
||||
import { renderDeleteButton, renderRewindButton } from "./chat-message-confirmation.ts";
|
||||
import { renderRewindButton } from "./chat-message-confirmation.ts";
|
||||
import {
|
||||
renderMessageActionButtons,
|
||||
renderReplyButton,
|
||||
@@ -92,7 +92,6 @@ type RenderMessageGroupOptions = {
|
||||
embedSandboxMode?: EmbedSandboxMode;
|
||||
allowExternalEmbedUrls?: boolean;
|
||||
contextWindow?: number | null;
|
||||
onDelete?: () => void;
|
||||
onReply?: (target: MessageReplyTarget) => void;
|
||||
onRewind?: () => void;
|
||||
rewindDisabled?: boolean;
|
||||
@@ -253,7 +252,6 @@ export function renderActivityGroup(
|
||||
<div
|
||||
class="chat-group tool chat-group--activity chat-group--with-footer"
|
||||
data-chat-row-key=${firstGroup.key}
|
||||
data-chat-row-keys=${JSON.stringify(groups.map((group) => group.key))}
|
||||
>
|
||||
${showAvatarGutter
|
||||
? renderChatAvatar(
|
||||
@@ -323,7 +321,6 @@ export function renderActivityGroup(
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">${t("chat.messages.activity")}</span>
|
||||
${renderChatTimestamp(firstGroup.timestamp)}
|
||||
${opts.onDelete ? renderDeleteButton(opts.onDelete, "right") : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -397,7 +394,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
const footerActionDetails = messageActionDetails[lastMessageIndex] ?? null;
|
||||
const hasUserFooterActions =
|
||||
normalizedRole === "user" &&
|
||||
Boolean((footerActionDetails?.replyTarget && opts.onReply) || opts.onDelete || opts.onRewind);
|
||||
Boolean((footerActionDetails?.replyTarget && opts.onReply) || opts.onRewind);
|
||||
|
||||
// Attributed (logged-in) senders tint their bubbles with the same stable
|
||||
// identity hue as their avatar initials; CSS owns per-theme lightness so
|
||||
@@ -487,7 +484,6 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
${footerActionDetails?.replyTarget && opts.onReply
|
||||
? renderReplyButton(footerActionDetails.replyTarget, opts.onReply)
|
||||
: nothing}
|
||||
${opts.onDelete ? renderDeleteButton(opts.onDelete, "left") : nothing}
|
||||
${opts.onRewind
|
||||
? renderRewindButton(opts.onRewind, Boolean(opts.rewindDisabled), "left")
|
||||
: nothing}
|
||||
@@ -500,21 +496,13 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
<span class="chat-sender-name">${who}</span>
|
||||
${renderMessageMeta(group.timestamp, meta)}
|
||||
</div>
|
||||
${normalizedRole !== "user" && (footerActionDetails || opts.onDelete)
|
||||
${normalizedRole !== "user" && footerActionDetails
|
||||
? html`
|
||||
<div
|
||||
class="chat-group-footer-actions"
|
||||
data-message-actions-for=${group.messages[lastMessageIndex]?.key ?? nothing}
|
||||
>
|
||||
${footerActionDetails
|
||||
? renderMessageActionButtons(
|
||||
footerActionDetails,
|
||||
opts,
|
||||
normalizedRole !== "user" ? opts.onDelete : undefined,
|
||||
)
|
||||
: opts.onDelete
|
||||
? renderDeleteButton(opts.onDelete, "right")
|
||||
: nothing}
|
||||
${renderMessageActionButtons(footerActionDetails, opts)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { normalizeRoleForGrouping } from "../../../lib/chat/message-normalizer.t
|
||||
import { stripThinkingTags } from "../../../lib/strip-thinking-tags.ts";
|
||||
import { detectTextDirection } from "../../../lib/text-direction.ts";
|
||||
import { persistedMessageEntryId, type AssistantMessageExpansionState } from "../chat-thread.ts";
|
||||
import { renderDeleteButton } from "./chat-message-confirmation.ts";
|
||||
|
||||
export type MessageReplyTarget = {
|
||||
messageId: string;
|
||||
@@ -155,13 +154,11 @@ export function renderMessageActionButtons(
|
||||
opts: {
|
||||
onReply?: (target: MessageReplyTarget) => void;
|
||||
},
|
||||
onDelete?: () => void,
|
||||
) {
|
||||
return html`
|
||||
${details.replyTarget && opts.onReply
|
||||
? renderReplyButton(details.replyTarget, opts.onReply)
|
||||
: nothing}
|
||||
${onDelete ? renderDeleteButton(onDelete, "right") : nothing}
|
||||
${details.markdown ? renderCopyAsMarkdownButton(details.markdown) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -402,8 +402,8 @@ function renderMessageGroups(
|
||||
render(html`${groups.map((group) => renderTestMessageGroup(group, opts))}`, container);
|
||||
}
|
||||
|
||||
function clearDeleteConfirmSkip() {
|
||||
localStorageValues.delete("openclaw:skipDeleteConfirm");
|
||||
function clearConfirmedActionSkip() {
|
||||
localStorageValues.delete("openclaw:skip-rewind-confirm");
|
||||
}
|
||||
|
||||
function stubAnimationFrameQueue() {
|
||||
@@ -480,33 +480,33 @@ function countCaptureKeydownListenerRemovals(calls: readonly unknown[][], listen
|
||||
).length;
|
||||
}
|
||||
|
||||
function renderDeleteConfirmFixture() {
|
||||
function renderConfirmedActionFixture() {
|
||||
const container = document.createElement("div");
|
||||
container.dataset.deleteConfirmFixture = "true";
|
||||
container.dataset.confirmedActionFixture = "true";
|
||||
document.body.appendChild(container);
|
||||
const onDelete = vi.fn();
|
||||
clearDeleteConfirmSkip();
|
||||
const onAction = vi.fn();
|
||||
clearConfirmedActionSkip();
|
||||
renderMessageGroups(
|
||||
container,
|
||||
[
|
||||
createMessageGroup(
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello from assistant",
|
||||
role: "user",
|
||||
content: "hello from user",
|
||||
timestamp: 1000,
|
||||
},
|
||||
"assistant",
|
||||
"user",
|
||||
),
|
||||
],
|
||||
{ onDelete },
|
||||
{ onRewind: onAction },
|
||||
);
|
||||
const deleteButton = container.querySelector<HTMLButtonElement>(".chat-group-delete");
|
||||
expect(deleteButton).toBeInstanceOf(HTMLButtonElement);
|
||||
return { container, deleteButton: deleteButton!, onDelete };
|
||||
const actionButton = container.querySelector<HTMLButtonElement>(".chat-group-rewind");
|
||||
expect(actionButton).toBeInstanceOf(HTMLButtonElement);
|
||||
return { actionButton: actionButton!, container, onAction };
|
||||
}
|
||||
|
||||
function openDeleteConfirm(deleteButton: HTMLButtonElement) {
|
||||
deleteButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
function openConfirmedAction(actionButton: HTMLButtonElement) {
|
||||
actionButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
|
||||
function domRect(params: {
|
||||
@@ -533,7 +533,7 @@ function domRect(params: {
|
||||
return rect as DOMRect;
|
||||
}
|
||||
|
||||
function stubDeleteConfirmGeometry(params: {
|
||||
function stubConfirmedActionGeometry(params: {
|
||||
trigger: { left: number; top: number; width: number; height: number };
|
||||
popover: { width: number; height: number };
|
||||
viewport: { left?: number; top?: number; width: number; height: number };
|
||||
@@ -548,7 +548,7 @@ function stubDeleteConfirmGeometry(params: {
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
if (this.classList.contains("chat-group-delete")) {
|
||||
if (this.classList.contains("chat-group-rewind")) {
|
||||
return domRect(params.trigger);
|
||||
}
|
||||
if (this.classList.contains("chat-delete-confirm")) {
|
||||
@@ -559,23 +559,23 @@ function stubDeleteConfirmGeometry(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function clickDeleteButtonIconPath(deleteButton: HTMLButtonElement) {
|
||||
function clickConfirmedActionIconPath(actionButton: HTMLButtonElement) {
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
icon.appendChild(path);
|
||||
deleteButton.appendChild(icon);
|
||||
actionButton.appendChild(icon);
|
||||
path.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setupArmedDeleteConfirm() {
|
||||
function setupArmedConfirmedAction() {
|
||||
const flushAnimationFrames = stubAnimationFrameQueue();
|
||||
const addListenerSpy = vi.spyOn(document, "addEventListener");
|
||||
const removeListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
const addKeyListenerSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeKeyListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
const fixture = renderDeleteConfirmFixture();
|
||||
const fixture = renderConfirmedActionFixture();
|
||||
|
||||
openDeleteConfirm(fixture.deleteButton);
|
||||
openConfirmedAction(fixture.actionButton);
|
||||
flushAnimationFrames();
|
||||
|
||||
const outsideClickListener = expectLastCaptureClickListener(addListenerSpy.mock.calls);
|
||||
@@ -596,7 +596,7 @@ function setupArmedDeleteConfirm() {
|
||||
};
|
||||
}
|
||||
|
||||
function expectDeleteConfirmDismissed(params: {
|
||||
function expectConfirmedActionDismissed(params: {
|
||||
escapeListener: unknown;
|
||||
outsideClickListener: unknown;
|
||||
outsideContextMenuListener: unknown;
|
||||
@@ -664,11 +664,11 @@ afterEach(() => {
|
||||
document.querySelectorAll("[data-media-player-test-fixture]").forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
document.querySelectorAll("[data-delete-confirm-fixture]").forEach((element) => {
|
||||
document.querySelectorAll("[data-confirmed-action-fixture]").forEach((element) => {
|
||||
dismissConfirmedActionPopovers(element);
|
||||
element.remove();
|
||||
});
|
||||
clearDeleteConfirmSkip();
|
||||
clearConfirmedActionSkip();
|
||||
setAvatarGatewayOrigin(null);
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -747,7 +747,7 @@ describe("grouped chat rendering", () => {
|
||||
timestamp: 1000,
|
||||
__openclaw: { id: "assistant-entry-1" },
|
||||
}),
|
||||
{ onDelete: vi.fn(), onReply },
|
||||
{ onReply },
|
||||
);
|
||||
|
||||
const actions = container.querySelectorAll<HTMLButtonElement>(
|
||||
@@ -755,7 +755,6 @@ describe("grouped chat rendering", () => {
|
||||
);
|
||||
expect([...actions].map((button) => button.getAttribute("aria-label"))).toEqual([
|
||||
"Reply to message",
|
||||
"Hide message",
|
||||
"Copy as markdown",
|
||||
]);
|
||||
|
||||
@@ -791,7 +790,6 @@ describe("grouped chat rendering", () => {
|
||||
it("orders user footer actions before the sender name and timestamp", () => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(container, createUserMessage("User footer order."), "user", {
|
||||
onDelete: vi.fn(),
|
||||
onReply: vi.fn(),
|
||||
onRewind: vi.fn(),
|
||||
userName: "Jason",
|
||||
@@ -810,7 +808,7 @@ describe("grouped chat rendering", () => {
|
||||
return element.getAttribute("aria-label");
|
||||
});
|
||||
|
||||
expect(order).toEqual(["Reply to message", "Hide message", "Rewind", "name", "time"]);
|
||||
expect(order).toEqual(["Reply to message", "Rewind", "name", "time"]);
|
||||
});
|
||||
|
||||
it("keeps hidden assistant thinking out of inline reply context", () => {
|
||||
@@ -966,63 +964,6 @@ describe("grouped chat rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("positions delete confirm by message side", () => {
|
||||
const container = document.createElement("div");
|
||||
clearDeleteConfirmSkip();
|
||||
renderMessageGroups(
|
||||
container,
|
||||
[
|
||||
createMessageGroup(
|
||||
{
|
||||
role: "user",
|
||||
content: "hello from user",
|
||||
timestamp: 1000,
|
||||
},
|
||||
"user",
|
||||
),
|
||||
createMessageGroup(
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello from assistant",
|
||||
timestamp: 1001,
|
||||
},
|
||||
"assistant",
|
||||
),
|
||||
],
|
||||
{ onDelete: vi.fn() },
|
||||
);
|
||||
|
||||
try {
|
||||
const userDeleteButton = container.querySelector<HTMLButtonElement>(
|
||||
".chat-group.user .chat-group-delete",
|
||||
);
|
||||
expect(userDeleteButton).toBeInstanceOf(HTMLButtonElement);
|
||||
userDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
const userConfirm = document.querySelector<HTMLElement>(".chat-delete-confirm--left");
|
||||
expect(userConfirm).toBeInstanceOf(HTMLElement);
|
||||
expect([...userConfirm!.classList]).toEqual([
|
||||
"chat-delete-confirm",
|
||||
"chat-delete-confirm--left",
|
||||
]);
|
||||
|
||||
const assistantDeleteButton = container.querySelector<HTMLButtonElement>(
|
||||
".chat-group.assistant .chat-group-delete",
|
||||
);
|
||||
expect(assistantDeleteButton).toBeInstanceOf(HTMLButtonElement);
|
||||
assistantDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
const assistantConfirm = document.querySelector<HTMLElement>(".chat-delete-confirm--right");
|
||||
expect(assistantConfirm).toBeInstanceOf(HTMLElement);
|
||||
expect([...assistantConfirm!.classList]).toEqual([
|
||||
"chat-delete-confirm",
|
||||
"chat-delete-confirm--right",
|
||||
]);
|
||||
} finally {
|
||||
dismissConfirmedActionPopovers(container);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a confirmed rewind action only for user groups", () => {
|
||||
const container = document.createElement("div");
|
||||
const onRewind = vi.fn();
|
||||
@@ -1062,32 +1003,32 @@ describe("grouped chat rendering", () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "places the delete confirm below the trigger near the top viewport edge",
|
||||
name: "places the confirmation below the trigger near the top viewport edge",
|
||||
trigger: { left: 20, top: 4, width: 24, height: 24 },
|
||||
popover: { width: 200, height: 96 },
|
||||
viewport: { width: 320, height: 240 },
|
||||
placement: "below",
|
||||
top: "34px",
|
||||
left: "20px",
|
||||
left: "8px",
|
||||
},
|
||||
{
|
||||
name: "places the delete confirm above the trigger near the bottom viewport edge",
|
||||
name: "places the confirmation above the trigger near the bottom viewport edge",
|
||||
trigger: { left: 20, top: 190, width: 24, height: 24 },
|
||||
popover: { width: 200, height: 80 },
|
||||
viewport: { width: 320, height: 240 },
|
||||
placement: "above",
|
||||
top: "104px",
|
||||
left: "20px",
|
||||
left: "8px",
|
||||
},
|
||||
{
|
||||
name: "clamps the delete confirm horizontally inside narrow viewports",
|
||||
name: "clamps the confirmation horizontally inside narrow viewports",
|
||||
trigger: { left: 260, top: 120, width: 24, height: 24 },
|
||||
popover: { width: 200, height: 80 },
|
||||
viewport: { width: 320, height: 240 },
|
||||
left: "112px",
|
||||
left: "84px",
|
||||
},
|
||||
{
|
||||
name: "clamps the delete confirm inside shifted visual viewports",
|
||||
name: "clamps the confirmation inside shifted visual viewports",
|
||||
trigger: { left: 620, top: 540, width: 24, height: 24 },
|
||||
popover: { width: 200, height: 80 },
|
||||
viewport: { left: 320, top: 300, width: 320, height: 240 },
|
||||
@@ -1096,10 +1037,10 @@ describe("grouped chat rendering", () => {
|
||||
left: "432px",
|
||||
},
|
||||
])("$name", ({ trigger, popover, viewport, placement, top, left }) => {
|
||||
stubDeleteConfirmGeometry({ trigger, popover, viewport });
|
||||
const fixture = renderDeleteConfirmFixture();
|
||||
stubConfirmedActionGeometry({ trigger, popover, viewport });
|
||||
const fixture = renderConfirmedActionFixture();
|
||||
|
||||
openDeleteConfirm(fixture.deleteButton);
|
||||
openConfirmedAction(fixture.actionButton);
|
||||
|
||||
const element = expectElement(document.body, ".chat-delete-confirm", HTMLElement);
|
||||
expect(element.parentElement).toBe(document.body);
|
||||
@@ -1112,9 +1053,9 @@ describe("grouped chat rendering", () => {
|
||||
expect(element.style.left).toBe(left);
|
||||
});
|
||||
it("exposes dialog semantics and keeps keyboard focus inside the confirmation", () => {
|
||||
const fixture = renderDeleteConfirmFixture();
|
||||
const fixture = renderConfirmedActionFixture();
|
||||
|
||||
openDeleteConfirm(fixture.deleteButton);
|
||||
openConfirmedAction(fixture.actionButton);
|
||||
|
||||
const popover = expectElement(document.body, ".chat-delete-confirm", HTMLElement);
|
||||
const check = expectElement(popover, ".chat-delete-confirm__check", HTMLInputElement);
|
||||
@@ -1149,7 +1090,7 @@ describe("grouped chat rendering", () => {
|
||||
});
|
||||
|
||||
it("dismisses the confirmation with Escape before underlying keyboard handlers run", () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
const cancel = expectElement(
|
||||
fixture.popover,
|
||||
".chat-delete-confirm__cancel",
|
||||
@@ -1168,36 +1109,36 @@ describe("grouped chat rendering", () => {
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(leakedKeydown).not.toHaveBeenCalled();
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(fixture.deleteButton);
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
expect(fixture.onAction).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(fixture.actionButton);
|
||||
} finally {
|
||||
document.removeEventListener("keydown", leakedKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
it("dismisses only confirmations contained by the requested owner", () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
const sibling = renderDeleteConfirmFixture();
|
||||
openDeleteConfirm(sibling.deleteButton);
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
const sibling = renderConfirmedActionFixture();
|
||||
openConfirmedAction(sibling.actionButton);
|
||||
const siblingPopover = [...document.querySelectorAll<HTMLElement>(".chat-delete-confirm")].find(
|
||||
(popover) => popover !== fixture.popover,
|
||||
);
|
||||
|
||||
dismissConfirmedActionPopovers(fixture.container);
|
||||
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
expect(siblingPopover?.isConnected).toBe(true);
|
||||
dismissConfirmedActionPopovers(sibling.container);
|
||||
});
|
||||
|
||||
it("dismisses a portaled confirmation when its owner is detached", async () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
|
||||
fixture.container.remove();
|
||||
await Promise.resolve();
|
||||
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
});
|
||||
|
||||
it("does not attach an outside-click listener after owner cleanup before the next frame", () => {
|
||||
@@ -1206,9 +1147,9 @@ describe("grouped chat rendering", () => {
|
||||
const removeListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
const addKeyListenerSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeKeyListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
const fixture = renderDeleteConfirmFixture();
|
||||
const fixture = renderConfirmedActionFixture();
|
||||
|
||||
openDeleteConfirm(fixture.deleteButton);
|
||||
openConfirmedAction(fixture.actionButton);
|
||||
const contextMenuListener = getLastCaptureContextMenuListener(addListenerSpy.mock.calls);
|
||||
const escapeListener = getLastCaptureKeydownListener(addKeyListenerSpy.mock.calls);
|
||||
expect(typeof contextMenuListener).toBe("function");
|
||||
@@ -1226,33 +1167,33 @@ describe("grouped chat rendering", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("removes the delete confirm outside-click listener when Cancel dismisses it", () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
it("removes the confirmation outside-click listener when Cancel dismisses it", () => {
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
const cancel = fixture.popover.querySelector<HTMLButtonElement>(".chat-delete-confirm__cancel");
|
||||
|
||||
expect(cancel).toBeInstanceOf(HTMLButtonElement);
|
||||
cancel!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(fixture.deleteButton);
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
expect(fixture.onAction).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(fixture.actionButton);
|
||||
});
|
||||
|
||||
it("removes the delete confirm outside-click listener when Delete dismisses it", () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
it("removes the confirmation outside-click listener when Rewind dismisses it", () => {
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
const confirm = fixture.popover.querySelector<HTMLButtonElement>(".chat-delete-confirm__yes");
|
||||
|
||||
expect(confirm).toBeInstanceOf(HTMLButtonElement);
|
||||
confirm!.focus();
|
||||
confirm!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expect(fixture.onDelete).toHaveBeenCalledTimes(1);
|
||||
expect(document.activeElement).not.toBe(fixture.deleteButton);
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
expect(fixture.onAction).toHaveBeenCalledTimes(1);
|
||||
expect(document.activeElement).not.toBe(fixture.actionButton);
|
||||
});
|
||||
|
||||
it("removes the delete confirm outside-click listener when an outside click dismisses it", () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
it("removes the confirmation outside-click listener when an outside click dismisses it", () => {
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
const outsideButton = document.createElement("button");
|
||||
document.body.appendChild(outsideButton);
|
||||
|
||||
@@ -1260,44 +1201,44 @@ describe("grouped chat rendering", () => {
|
||||
outsideButton.focus();
|
||||
outsideButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
expect(fixture.onAction).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
} finally {
|
||||
outsideButton.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the delete confirm outside-click listener when the delete button toggles it", () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
it("removes the confirmation outside-click listener when the action button toggles it", () => {
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
|
||||
openDeleteConfirm(fixture.deleteButton);
|
||||
openConfirmedAction(fixture.actionButton);
|
||||
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
expect(fixture.onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the delete confirm outside-click listener when the delete button icon toggles it", () => {
|
||||
const fixture = setupArmedDeleteConfirm();
|
||||
it("removes the confirmation outside-click listener when the action icon toggles it", () => {
|
||||
const fixture = setupArmedConfirmedAction();
|
||||
|
||||
clickDeleteButtonIconPath(fixture.deleteButton);
|
||||
clickConfirmedActionIconPath(fixture.actionButton);
|
||||
|
||||
expectDeleteConfirmDismissed(fixture);
|
||||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||||
expectConfirmedActionDismissed(fixture);
|
||||
expect(fixture.onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attach the delete confirm outside-click listener after an immediate toggle", () => {
|
||||
it("does not attach the confirmation outside-click listener after an immediate toggle", () => {
|
||||
const flushAnimationFrames = stubAnimationFrameQueue();
|
||||
const addListenerSpy = vi.spyOn(document, "addEventListener");
|
||||
const fixture = renderDeleteConfirmFixture();
|
||||
const fixture = renderConfirmedActionFixture();
|
||||
|
||||
openDeleteConfirm(fixture.deleteButton);
|
||||
openDeleteConfirm(fixture.deleteButton);
|
||||
openConfirmedAction(fixture.actionButton);
|
||||
openConfirmedAction(fixture.actionButton);
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(document.querySelector(".chat-delete-confirm")).toBeNull();
|
||||
expect(getLastCaptureClickListener(addListenerSpy.mock.calls)).toBeNull();
|
||||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||||
expect(fixture.onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders assistant context usage from input and cache tokens", () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import "../../../components/tooltip.ts";
|
||||
export { getAssistantAttachmentAvailabilityRenderVersion } from "./chat-message-attachments.ts";
|
||||
export {
|
||||
dismissConfirmedActionPopovers,
|
||||
openChatHideConfirmation,
|
||||
openChatRewindConfirmation,
|
||||
} from "./chat-message-confirmation.ts";
|
||||
export { renderActivityGroup, renderMessageGroup } from "./chat-message-group.ts";
|
||||
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
coalesceActivityRuns,
|
||||
coalesceStreamRuns,
|
||||
collapseCompletedTurnWork,
|
||||
deletedChatItemsSignature,
|
||||
getExpansionStateVersion,
|
||||
getExpandedToolCards,
|
||||
getExpandedAssistantMessages,
|
||||
@@ -68,7 +67,6 @@ import {
|
||||
setExpansionState,
|
||||
syncToolCardExpansionState,
|
||||
} from "../chat-thread.ts";
|
||||
import { DeletedMessages } from "../deleted-messages.ts";
|
||||
import { PinnedMessages } from "../pinned-messages.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
|
||||
import {
|
||||
@@ -87,7 +85,6 @@ import type { ArtifactDownloadResolver } from "./chat-message-media.ts";
|
||||
import {
|
||||
dismissConfirmedActionPopovers,
|
||||
getAssistantAttachmentAvailabilityRenderVersion,
|
||||
openChatHideConfirmation,
|
||||
openChatRewindConfirmation,
|
||||
renderMessageGroup,
|
||||
renderActivityGroup,
|
||||
@@ -104,7 +101,6 @@ import { renderWelcomeState, resolveAssistantDisplayAvatar } from "./chat-welcom
|
||||
import { renderTurnRecapRow } from "./chat-working-indicator.ts";
|
||||
|
||||
const pinnedMessagesMap = new Map<string, PinnedMessages>();
|
||||
const deletedMessagesMap = new Map<string, DeletedMessages>();
|
||||
|
||||
type ChatThreadState = {
|
||||
searchOpen: boolean;
|
||||
@@ -134,8 +130,6 @@ type ChatThreadProps = {
|
||||
showThinking: boolean;
|
||||
showToolCalls: boolean;
|
||||
persistCommentary?: boolean;
|
||||
/** Suppresses transcript mutations while preserving read-only presentation controls. */
|
||||
readOnly?: boolean;
|
||||
/** True while the session has an abortable live run (marks running tool rows). */
|
||||
runActive?: boolean;
|
||||
/** True while the agent is visibly working (isChatRunWorking); shows the working spark. */
|
||||
@@ -843,14 +837,6 @@ function getPinnedMessages(sessionKey: string): PinnedMessages {
|
||||
);
|
||||
}
|
||||
|
||||
function getDeletedMessages(sessionKey: string): DeletedMessages {
|
||||
return getOrCreateSessionCacheValue(
|
||||
deletedMessagesMap,
|
||||
sessionKey,
|
||||
() => new DeletedMessages(sessionKey),
|
||||
);
|
||||
}
|
||||
|
||||
function getPinnedMessageSummary(message: unknown): string {
|
||||
return extractTextCached(message) ?? "";
|
||||
}
|
||||
@@ -1111,22 +1097,6 @@ function selectionIntersectsElement(selection: Selection | null, element: Elemen
|
||||
return false;
|
||||
}
|
||||
|
||||
function chatGroupRowKeys(group: HTMLElement): string[] {
|
||||
const serialized = group.dataset.chatRowKeys;
|
||||
if (serialized) {
|
||||
try {
|
||||
const keys = JSON.parse(serialized);
|
||||
if (Array.isArray(keys) && keys.every((key) => typeof key === "string")) {
|
||||
return keys;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the canonical single-row key.
|
||||
}
|
||||
}
|
||||
const key = group.dataset.chatRowKey?.trim();
|
||||
return key ? [key] : [];
|
||||
}
|
||||
|
||||
function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
|
||||
if (event.composedPath().some((target) => target instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
@@ -1150,7 +1120,6 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
|
||||
const text = truncateUtf16Safe((bubble as HTMLElement).dataset.messageText?.trim() ?? "", 500);
|
||||
const entryId = (bubble as HTMLElement).dataset.entryId?.trim() ?? "";
|
||||
const messageId = (bubble as HTMLElement).dataset.messageId?.trim() ?? "";
|
||||
const groupKeys = chatGroupRowKeys(group);
|
||||
const isUserMessage = group.classList.contains("user") && Boolean(entryId);
|
||||
// Grouped rows can contain several bubbles. Match the clicked bubble to its
|
||||
// own action owner so copy never targets a sibling message.
|
||||
@@ -1160,10 +1129,9 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
|
||||
const copyButton = actionOwner?.querySelector<HTMLButtonElement>(".chat-copy-btn");
|
||||
const canReply = Boolean(text && props.onSetReply);
|
||||
const canRewind = isUserMessage && typeof props.onRewindMessage === "function";
|
||||
const canHide = !props.readOnly && groupKeys.length > 0;
|
||||
const canCopy = Boolean(copyButton);
|
||||
const canFork = isUserMessage && typeof props.onForkMessage === "function";
|
||||
if (!canReply && !canRewind && !canHide && !canCopy && !canFork) {
|
||||
if (!canReply && !canRewind && !canCopy && !canFork) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1229,26 +1197,6 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
|
||||
menu.append(action.element);
|
||||
focusCandidates.push(action.button);
|
||||
}
|
||||
if (canHide) {
|
||||
const action = createMessageActionContextButton({
|
||||
label: t("chat.messages.hideMessage"),
|
||||
disabled: false,
|
||||
tooltip: t("chat.messages.hideTooltip"),
|
||||
onClick: () => {
|
||||
openChatHideConfirmation(action.button, () => {
|
||||
removeReplyContextMenu();
|
||||
const deleted = getDeletedMessages(props.sessionKey);
|
||||
for (const key of groupKeys) {
|
||||
deleted.delete(key);
|
||||
}
|
||||
props.onRequestUpdate?.();
|
||||
});
|
||||
},
|
||||
});
|
||||
action.element.classList.add("chat-delete-wrap");
|
||||
menu.append(action.element);
|
||||
focusCandidates.push(action.button);
|
||||
}
|
||||
if (canCopy) {
|
||||
const action = createMessageActionContextButton({
|
||||
label: copyMarkdownLabel(),
|
||||
@@ -1481,7 +1429,6 @@ function renderChatThreadContents(
|
||||
name: props.assistantName,
|
||||
avatar: resolveAssistantDisplayAvatar(props),
|
||||
};
|
||||
const deleted = getDeletedMessages(props.sessionKey);
|
||||
const locale = i18n.getLocale();
|
||||
const searchFiltering = state.searchOpen && Boolean(state.searchQuery.trim());
|
||||
const chatItems = buildCachedChatItems({
|
||||
@@ -1627,7 +1574,7 @@ function renderChatThreadContents(
|
||||
...sharedMessageRenderOptions,
|
||||
assistant: assistantIdentity,
|
||||
} satisfies StreamGroupOptions;
|
||||
const renderGroupOptions = (item: MessageGroup, onDelete: () => void) => {
|
||||
const renderGroupOptions = (item: MessageGroup) => {
|
||||
const lastMessage = item.messages.at(-1)?.message;
|
||||
const rewindEntryId =
|
||||
item.role.toLowerCase() === "user" && lastMessage
|
||||
@@ -1665,7 +1612,6 @@ function renderChatThreadContents(
|
||||
showAvatarGutter: !isDirectThread,
|
||||
contextWindow: threadContextWindow,
|
||||
onReply: props.onSetReply,
|
||||
onDelete: props.readOnly ? undefined : onDelete,
|
||||
onRewind:
|
||||
rewindEntryId && props.onRewindMessage
|
||||
? () => {
|
||||
@@ -1682,16 +1628,7 @@ function renderChatThreadContents(
|
||||
} satisfies Parameters<typeof renderMessageGroup>[1];
|
||||
};
|
||||
const renderGroupItem = (item: MessageGroup) => {
|
||||
if (deleted.has(item.key)) {
|
||||
return nothing;
|
||||
}
|
||||
return renderMessageGroup(
|
||||
item,
|
||||
renderGroupOptions(item, () => {
|
||||
deleted.delete(item.key);
|
||||
requestUpdate();
|
||||
}),
|
||||
);
|
||||
return renderMessageGroup(item, renderGroupOptions(item));
|
||||
};
|
||||
// Only the working indicator shows live usage, so rows without one keep
|
||||
// memoizing across usage patches.
|
||||
@@ -1746,23 +1683,14 @@ function renderChatThreadContents(
|
||||
`;
|
||||
}
|
||||
if (item.kind === "activity-run") {
|
||||
const visibleGroups = item.groups.filter((group) => !deleted.has(group.key));
|
||||
const firstGroup = visibleGroups[0];
|
||||
const firstGroup = item.groups[0];
|
||||
if (!firstGroup) {
|
||||
return nothing;
|
||||
}
|
||||
if (visibleGroups.length === 1) {
|
||||
if (item.groups.length === 1) {
|
||||
return renderGroupItem(firstGroup);
|
||||
}
|
||||
return renderActivityGroup(
|
||||
visibleGroups,
|
||||
renderGroupOptions(firstGroup, () => {
|
||||
for (const group of item.groups) {
|
||||
deleted.delete(group.key);
|
||||
}
|
||||
requestUpdate();
|
||||
}),
|
||||
);
|
||||
return renderActivityGroup(item.groups, renderGroupOptions(firstGroup));
|
||||
}
|
||||
if (item.kind === "group") {
|
||||
return renderGroupItem(item);
|
||||
@@ -1798,7 +1726,6 @@ function renderChatThreadContents(
|
||||
if (
|
||||
previous?.kind !== "group" ||
|
||||
!isActiveStatusRun ||
|
||||
deleted.has(previous.key) ||
|
||||
!assistantGroupCanOwnActiveRunStatus(previous)
|
||||
) {
|
||||
return true;
|
||||
@@ -1821,11 +1748,7 @@ function renderChatThreadContents(
|
||||
let turnRecapOwnerKey: string | null = null;
|
||||
if (turnRecap !== null) {
|
||||
const lastItem = transcriptItems.at(-1);
|
||||
if (
|
||||
lastItem?.kind === "group" &&
|
||||
!deleted.has(lastItem.key) &&
|
||||
assistantGroupCanOwnActiveRunStatus(lastItem)
|
||||
) {
|
||||
if (lastItem?.kind === "group" && assistantGroupCanOwnActiveRunStatus(lastItem)) {
|
||||
turnRecapByGroupKey.set(lastItem.key, turnRecap);
|
||||
turnRecapOwnerKey = lastItem.key;
|
||||
}
|
||||
@@ -1864,7 +1787,6 @@ function renderChatThreadContents(
|
||||
trackTranscriptRenderDependencies(state, [
|
||||
chatItems,
|
||||
locale,
|
||||
deletedChatItemsSignature(deleted, chatItems),
|
||||
expandedToolCards,
|
||||
getExpansionStateVersion(expandedToolCards),
|
||||
expandedUserMessages,
|
||||
@@ -1891,7 +1813,6 @@ function renderChatThreadContents(
|
||||
props.planStatus,
|
||||
props.questionPrompts,
|
||||
Boolean(props.autoExpandToolCalls),
|
||||
Boolean(props.readOnly),
|
||||
props.assistantName,
|
||||
assistantIdentity.avatar,
|
||||
props.userId,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// Control UI chat module implements deleted messages behavior.
|
||||
import { PersistedSet } from "./persisted-set.ts";
|
||||
|
||||
const PREFIX = "openclaw:deleted:";
|
||||
|
||||
export class DeletedMessages extends PersistedSet<string> {
|
||||
constructor(sessionKey: string) {
|
||||
super(PREFIX + sessionKey, (value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.add(key);
|
||||
}
|
||||
|
||||
restore(key: string): void {
|
||||
this.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -336,10 +336,6 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-group-delete:hover {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* Chat divider (e.g., compaction marker) */
|
||||
.chat-divider {
|
||||
display: grid;
|
||||
@@ -680,7 +676,6 @@ img.chat-avatar.chat-avatar--logo {
|
||||
|
||||
.chat-group-footer-actions .chat-copy-btn,
|
||||
.chat-group-footer-actions .chat-reply-btn,
|
||||
.chat-group-footer-actions .chat-group-delete,
|
||||
.chat-message-actions-row .chat-copy-btn,
|
||||
.chat-message-actions-row .chat-reply-btn {
|
||||
min-width: 44px;
|
||||
|
||||
Reference in New Issue
Block a user