mirror of https://github.com/openclaw/openclaw.git
fix(ui): prevent premature compaction status update on retry (#55132)
Merged via squash.
Prepared head SHA: e7e562f982
Co-authored-by: mpz4life <32388289+mpz4life@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
parent
7cb323d84f
commit
7027dda8cd
|
|
@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Gateway/session reset: emit the typed `before_reset` hook for gateway `/new` and `/reset`, preserving reset-hook behavior even when the previous transcript has already been archived. (#53872) thanks @VACInc
|
||||
- Plugins/commands: pass the active host `sessionKey` into plugin command contexts, and include `sessionId` when it is already available from the active session entry, so bundled and third-party commands can resolve the current conversation reliably. (#59044) Thanks @jalehman.
|
||||
- Agents/auth: honor `models.providers.*.authHeader` for pi embedded runner model requests by injecting `Authorization: Bearer <apiKey>` when requested. (#54390) Thanks @lndyzwdxhs.
|
||||
- UI/compaction: keep the compaction indicator in a retry-pending state until the run actually finishes, so the UI does not show `Context compacted` before compaction actually finishes. (#55132) Thanks @mpz4life.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
|
|
|
|||
|
|
@ -139,4 +139,174 @@ describe("app-tool-stream fallback lifecycle handling", () => {
|
|||
expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps compaction in retry-pending state until the matching lifecycle end", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "compaction",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toEqual({
|
||||
phase: "active",
|
||||
runId: "run-1",
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 2,
|
||||
stream: "compaction",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "end", willRetry: true, completed: true },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toEqual({
|
||||
phase: "retrying",
|
||||
runId: "run-1",
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: null,
|
||||
});
|
||||
expect(host.compactionClearTimer).toBeNull();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-2",
|
||||
seq: 3,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toEqual({
|
||||
phase: "retrying",
|
||||
runId: "run-1",
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 4,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toEqual({
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: expect.any(Number),
|
||||
});
|
||||
expect(host.compactionClearTimer).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(host.compactionStatus).toBeNull();
|
||||
expect(host.compactionClearTimer).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("treats lifecycle error as terminal for retry-pending compaction", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "compaction",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 2,
|
||||
stream: "compaction",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "end", willRetry: true, completed: true },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toEqual({
|
||||
phase: "retrying",
|
||||
runId: "run-1",
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "error", error: "boom" },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toEqual({
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: expect.any(Number),
|
||||
});
|
||||
expect(host.compactionClearTimer).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(host.compactionStatus).toBeNull();
|
||||
expect(host.compactionClearTimer).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not surface retrying or complete when retry compaction failed", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "compaction",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 2,
|
||||
stream: "compaction",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "end", willRetry: true, completed: false },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toBeNull();
|
||||
expect(host.compactionClearTimer).toBeNull();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: { phase: "error", error: "boom" },
|
||||
});
|
||||
|
||||
expect(host.compactionStatus).toBeNull();
|
||||
expect(host.compactionClearTimer).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -245,7 +245,8 @@ export function resetToolStream(host: ToolStreamHost) {
|
|||
}
|
||||
|
||||
export type CompactionStatus = {
|
||||
active: boolean;
|
||||
phase: "active" | "retrying" | "complete";
|
||||
runId: string | null;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
|
@ -270,34 +271,87 @@ type CompactionHost = ToolStreamHost & {
|
|||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
const FALLBACK_TOAST_DURATION_MS = 8000;
|
||||
|
||||
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
|
||||
// Clear any existing timer
|
||||
function clearCompactionTimer(host: CompactionHost) {
|
||||
if (host.compactionClearTimer != null) {
|
||||
window.clearTimeout(host.compactionClearTimer);
|
||||
host.compactionClearTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCompactionClear(host: CompactionHost) {
|
||||
host.compactionClearTimer = window.setTimeout(() => {
|
||||
host.compactionStatus = null;
|
||||
host.compactionClearTimer = null;
|
||||
}, COMPACTION_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
function setCompactionComplete(host: CompactionHost, runId: string) {
|
||||
host.compactionStatus = {
|
||||
phase: "complete",
|
||||
runId,
|
||||
startedAt: host.compactionStatus?.startedAt ?? null,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
scheduleCompactionClear(host);
|
||||
}
|
||||
|
||||
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
const completed = data.completed === true;
|
||||
|
||||
clearCompactionTimer(host);
|
||||
|
||||
if (phase === "start") {
|
||||
host.compactionStatus = {
|
||||
active: true,
|
||||
phase: "active",
|
||||
runId: payload.runId,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
};
|
||||
} else if (phase === "end") {
|
||||
host.compactionStatus = {
|
||||
active: false,
|
||||
startedAt: host.compactionStatus?.startedAt ?? null,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
// Auto-clear the toast after duration
|
||||
host.compactionClearTimer = window.setTimeout(() => {
|
||||
host.compactionStatus = null;
|
||||
host.compactionClearTimer = null;
|
||||
}, COMPACTION_TOAST_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
if (phase === "end") {
|
||||
if (data.willRetry === true && completed) {
|
||||
// Compaction already succeeded, but the run is still retrying.
|
||||
// Keep that distinct state until the matching lifecycle end arrives.
|
||||
host.compactionStatus = {
|
||||
phase: "retrying",
|
||||
runId: payload.runId,
|
||||
startedAt: host.compactionStatus?.startedAt ?? Date.now(),
|
||||
completedAt: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (completed) {
|
||||
setCompactionComplete(host, payload.runId);
|
||||
return;
|
||||
}
|
||||
host.compactionStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLifecycleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = toTrimmedString(data.phase);
|
||||
if (phase !== "end" && phase !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
// We scope lifecycle cleanup to the visible chat session first, then
|
||||
// use runId only to match the specific compaction retry we started tracking.
|
||||
const accepted = resolveAcceptedSession(host, payload, { allowSessionScopedWhenIdle: true });
|
||||
if (!accepted.accepted) {
|
||||
return;
|
||||
}
|
||||
if (host.compactionStatus?.phase !== "retrying") {
|
||||
return;
|
||||
}
|
||||
if (host.compactionStatus.runId && host.compactionStatus.runId !== payload.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCompactionComplete(host, payload.runId);
|
||||
}
|
||||
|
||||
function resolveAcceptedSession(
|
||||
|
|
@ -400,7 +454,13 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
|||
return;
|
||||
}
|
||||
|
||||
if (payload.stream === "lifecycle" || payload.stream === "fallback") {
|
||||
if (payload.stream === "lifecycle") {
|
||||
handleLifecycleCompactionEvent(host as CompactionHost, payload);
|
||||
handleLifecycleFallbackEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream === "fallback") {
|
||||
handleLifecycleFallbackEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -519,7 +519,8 @@ describe("chat view", () => {
|
|||
renderChat(
|
||||
createProps({
|
||||
compactionStatus: {
|
||||
active: true,
|
||||
phase: "active",
|
||||
runId: "run-1",
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
},
|
||||
|
|
@ -533,6 +534,27 @@ describe("chat view", () => {
|
|||
expect(indicator?.textContent).toContain("Compacting context...");
|
||||
});
|
||||
|
||||
it("renders retry-pending compaction indicator as a badge", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
compactionStatus: {
|
||||
phase: "retrying",
|
||||
runId: "run-1",
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const indicator = container.querySelector(".compaction-indicator--active");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Retrying after compaction...");
|
||||
});
|
||||
|
||||
it("renders completion indicator shortly after compaction", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
|
|
@ -540,7 +562,8 @@ describe("chat view", () => {
|
|||
renderChat(
|
||||
createProps({
|
||||
compactionStatus: {
|
||||
active: false,
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: 900,
|
||||
completedAt: 900,
|
||||
},
|
||||
|
|
@ -562,7 +585,8 @@ describe("chat view", () => {
|
|||
renderChat(
|
||||
createProps({
|
||||
compactionStatus: {
|
||||
active: false,
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: 0,
|
||||
completedAt: 0,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import type {
|
||||
CompactionStatus as CompactionIndicatorStatus,
|
||||
FallbackStatus as FallbackIndicatorStatus,
|
||||
} from "../app-tool-stream.ts";
|
||||
import {
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
isSupportedChatAttachmentMimeType,
|
||||
|
|
@ -35,22 +39,6 @@ import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
|
|||
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
||||
import "../components/resizable-divider.ts";
|
||||
|
||||
export type CompactionIndicatorStatus = {
|
||||
active: boolean;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type FallbackIndicatorStatus = {
|
||||
phase?: "active" | "cleared";
|
||||
selected: string;
|
||||
active: string;
|
||||
previous?: string;
|
||||
reason?: string;
|
||||
attempts: string[];
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
export type ChatProps = {
|
||||
sessionKey: string;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
|
|
@ -193,7 +181,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
|
|||
if (!status) {
|
||||
return nothing;
|
||||
}
|
||||
if (status.active) {
|
||||
if (status.phase === "active") {
|
||||
return html`
|
||||
<div
|
||||
class="compaction-indicator compaction-indicator--active"
|
||||
|
|
@ -204,7 +192,18 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
if (status.completedAt) {
|
||||
if (status.phase === "retrying") {
|
||||
return html`
|
||||
<div
|
||||
class="compaction-indicator compaction-indicator--active"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
${icons.loader} Retrying after compaction...
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (status.phase === "complete" && status.completedAt) {
|
||||
const elapsed = Date.now() - status.completedAt;
|
||||
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
|
||||
return html`
|
||||
|
|
@ -642,15 +641,17 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
|
|||
return html`
|
||||
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
|
||||
<div class="agent-chat__welcome-glow"></div>
|
||||
${avatar
|
||||
? html`<img
|
||||
${
|
||||
avatar
|
||||
? html`<img
|
||||
src=${avatar}
|
||||
alt=${name}
|
||||
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
|
||||
/>`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
|
||||
<img src=${logoUrl} alt="OpenClaw" />
|
||||
</div>`}
|
||||
</div>`
|
||||
}
|
||||
<h2>${name}</h2>
|
||||
<div class="agent-chat__badges">
|
||||
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
|
||||
|
|
@ -741,8 +742,9 @@ function renderPinnedSection(
|
|||
>${icons.chevronDown}</span
|
||||
>
|
||||
</button>
|
||||
${vs.pinnedExpanded
|
||||
? html`
|
||||
${
|
||||
vs.pinnedExpanded
|
||||
? html`
|
||||
<div class="agent-chat__pinned-list">
|
||||
${entries.map(
|
||||
({ index, text, role }) => html`
|
||||
|
|
@ -768,7 +770,8 @@ function renderPinnedSection(
|
|||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -801,9 +804,11 @@ function renderSlashMenu(
|
|||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
${vs.slashMenuCommand?.icon
|
||||
? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>`
|
||||
: nothing}
|
||||
${
|
||||
vs.slashMenuCommand?.icon
|
||||
? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>`
|
||||
: nothing
|
||||
}
|
||||
<span class="slash-menu-name">${arg}</span>
|
||||
<span class="slash-menu-desc">/${vs.slashMenuCommand?.name} ${arg}</span>
|
||||
</div>
|
||||
|
|
@ -845,9 +850,9 @@ function renderSlashMenu(
|
|||
${entries.map(
|
||||
({ cmd, globalIdx }) => html`
|
||||
<div
|
||||
class="slash-menu-item ${globalIdx === vs.slashMenuIndex
|
||||
? "slash-menu-item--active"
|
||||
: ""}"
|
||||
class="slash-menu-item ${
|
||||
globalIdx === vs.slashMenuIndex ? "slash-menu-item--active" : ""
|
||||
}"
|
||||
role="option"
|
||||
aria-selected=${globalIdx === vs.slashMenuIndex}
|
||||
@click=${() => selectSlashCommand(cmd, props, requestUpdate)}
|
||||
|
|
@ -860,11 +865,15 @@ function renderSlashMenu(
|
|||
<span class="slash-menu-name">/${cmd.name}</span>
|
||||
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
|
||||
<span class="slash-menu-desc">${cmd.description}</span>
|
||||
${cmd.argOptions?.length
|
||||
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
|
||||
: cmd.executeLocal && !cmd.args
|
||||
? html` <span class="slash-menu-badge">instant</span> `
|
||||
: nothing}
|
||||
${
|
||||
cmd.argOptions?.length
|
||||
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
|
||||
: cmd.executeLocal && !cmd.args
|
||||
? html`
|
||||
<span class="slash-menu-badge">instant</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
|
|
@ -944,49 +953,46 @@ export function renderChat(props: ChatProps) {
|
|||
@click=${handleCodeBlockCopy}
|
||||
>
|
||||
<div class="chat-thread-inner">
|
||||
${props.loading
|
||||
? html`
|
||||
<div class="chat-loading-skeleton" aria-label="Loading chat">
|
||||
<div class="chat-line assistant">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble">
|
||||
<div
|
||||
class="skeleton skeleton-line skeleton-line--long"
|
||||
style="margin-bottom: 8px"
|
||||
></div>
|
||||
<div
|
||||
class="skeleton skeleton-line skeleton-line--medium"
|
||||
style="margin-bottom: 8px"
|
||||
></div>
|
||||
<div class="skeleton skeleton-line skeleton-line--short"></div>
|
||||
${
|
||||
props.loading
|
||||
? html`
|
||||
<div class="chat-loading-skeleton" aria-label="Loading chat">
|
||||
<div class="chat-line assistant">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble">
|
||||
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
|
||||
<div class="skeleton skeleton-line skeleton-line--medium" style="margin-bottom: 8px"></div>
|
||||
<div class="skeleton skeleton-line skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-line user" style="margin-top: 12px">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble">
|
||||
<div class="skeleton skeleton-line skeleton-line--medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-line assistant" style="margin-top: 12px">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble">
|
||||
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
|
||||
<div class="skeleton skeleton-line skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-line user" style="margin-top: 12px">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble">
|
||||
<div class="skeleton skeleton-line skeleton-line--medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-line assistant" style="margin-top: 12px">
|
||||
<div class="chat-msg">
|
||||
<div class="chat-bubble">
|
||||
<div
|
||||
class="skeleton skeleton-line skeleton-line--long"
|
||||
style="margin-bottom: 8px"
|
||||
></div>
|
||||
<div class="skeleton skeleton-line skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
|
||||
${isEmpty && vs.searchOpen
|
||||
? html` <div class="agent-chat__empty">No matching messages</div> `
|
||||
: nothing}
|
||||
${
|
||||
isEmpty && vs.searchOpen
|
||||
? html`
|
||||
<div class="agent-chat__empty">No matching messages</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${repeat(
|
||||
chatItems,
|
||||
(item) => item.key,
|
||||
|
|
@ -1164,8 +1170,9 @@ export function renderChat(props: ChatProps) {
|
|||
>
|
||||
${props.disabledReason ? html`<div class="callout">${props.disabledReason}</div>` : nothing}
|
||||
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
|
||||
${props.focusMode
|
||||
? html`
|
||||
${
|
||||
props.focusMode
|
||||
? html`
|
||||
<button
|
||||
class="chat-focus-exit"
|
||||
type="button"
|
||||
|
|
@ -1176,7 +1183,8 @@ export function renderChat(props: ChatProps) {
|
|||
${icons.x}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
: nothing
|
||||
}
|
||||
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
|
||||
|
||||
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
|
||||
|
|
@ -1187,8 +1195,9 @@ export function renderChat(props: ChatProps) {
|
|||
${thread}
|
||||
</div>
|
||||
|
||||
${sidebarOpen
|
||||
? html`
|
||||
${
|
||||
sidebarOpen
|
||||
? html`
|
||||
<resizable-divider
|
||||
.splitRatio=${splitRatio}
|
||||
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
|
||||
|
|
@ -1207,11 +1216,13 @@ export function renderChat(props: ChatProps) {
|
|||
})}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
|
||||
${props.queue.length
|
||||
? html`
|
||||
${
|
||||
props.queue.length
|
||||
? html`
|
||||
<div class="chat-queue" role="status" aria-live="polite">
|
||||
<div class="chat-queue__title">Queued (${props.queue.length})</div>
|
||||
<div class="chat-queue__list">
|
||||
|
|
@ -1219,8 +1230,10 @@ export function renderChat(props: ChatProps) {
|
|||
(item) => html`
|
||||
<div class="chat-queue__item">
|
||||
<div class="chat-queue__text">
|
||||
${item.text ||
|
||||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
|
||||
${
|
||||
item.text ||
|
||||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="btn chat-queue__remove"
|
||||
|
|
@ -1236,17 +1249,20 @@ export function renderChat(props: ChatProps) {
|
|||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
: nothing
|
||||
}
|
||||
${renderFallbackIndicator(props.fallbackStatus)}
|
||||
${renderCompactionIndicator(props.compactionStatus)}
|
||||
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null)}
|
||||
${props.showNewMessages
|
||||
? html`
|
||||
${
|
||||
props.showNewMessages
|
||||
? html`
|
||||
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
|
||||
${icons.arrowDown} New messages
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
: nothing
|
||||
}
|
||||
|
||||
<!-- Input bar -->
|
||||
<div class="agent-chat__input">
|
||||
|
|
@ -1260,9 +1276,11 @@ export function renderChat(props: ChatProps) {
|
|||
@change=${(e: Event) => handleFileSelect(e, props)}
|
||||
/>
|
||||
|
||||
${vs.sttRecording && vs.sttInterimText
|
||||
? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>`
|
||||
: nothing}
|
||||
${
|
||||
vs.sttRecording && vs.sttInterimText
|
||||
? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<textarea
|
||||
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
||||
|
|
@ -1290,12 +1308,13 @@ export function renderChat(props: ChatProps) {
|
|||
${icons.paperclip}
|
||||
</button>
|
||||
|
||||
${isSttSupported()
|
||||
? html`
|
||||
${
|
||||
isSttSupported()
|
||||
? html`
|
||||
<button
|
||||
class="agent-chat__input-btn ${vs.sttRecording
|
||||
? "agent-chat__input-btn--recording"
|
||||
: ""}"
|
||||
class="agent-chat__input-btn ${
|
||||
vs.sttRecording ? "agent-chat__input-btn--recording" : ""
|
||||
}"
|
||||
@click=${() => {
|
||||
if (vs.sttRecording) {
|
||||
stopStt();
|
||||
|
|
@ -1342,15 +1361,17 @@ export function renderChat(props: ChatProps) {
|
|||
${vs.sttRecording ? icons.micOff : icons.mic}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
: nothing
|
||||
}
|
||||
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__toolbar-right">
|
||||
${nothing /* search hidden for now */}
|
||||
${canAbort
|
||||
? nothing
|
||||
: html`
|
||||
${
|
||||
canAbort
|
||||
? nothing
|
||||
: html`
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
@click=${props.onNewSession}
|
||||
|
|
@ -1359,7 +1380,8 @@ export function renderChat(props: ChatProps) {
|
|||
>
|
||||
${icons.plus}
|
||||
</button>
|
||||
`}
|
||||
`
|
||||
}
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
@click=${() => exportMarkdown(props)}
|
||||
|
|
@ -1370,8 +1392,9 @@ export function renderChat(props: ChatProps) {
|
|||
${icons.download}
|
||||
</button>
|
||||
|
||||
${canAbort && (isBusy || props.sending)
|
||||
? html`
|
||||
${
|
||||
canAbort && (isBusy || props.sending)
|
||||
? html`
|
||||
<button
|
||||
class="chat-send-btn chat-send-btn--stop"
|
||||
@click=${props.onAbort}
|
||||
|
|
@ -1381,7 +1404,7 @@ export function renderChat(props: ChatProps) {
|
|||
${icons.stop}
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
: html`
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
@click=${() => {
|
||||
|
|
@ -1396,7 +1419,8 @@ export function renderChat(props: ChatProps) {
|
|||
>
|
||||
${icons.send}
|
||||
</button>
|
||||
`}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export default defineConfig({
|
|||
"ui/src/ui/controllers/chat.test.ts",
|
||||
"ui/src/ui/controllers/sessions.test.ts",
|
||||
"ui/src/ui/views/sessions.test.ts",
|
||||
"ui/src/ui/app-tool-stream.node.test.ts",
|
||||
"ui/src/ui/app-gateway.sessions.node.test.ts",
|
||||
"ui/src/ui/chat/slash-command-executor.node.test.ts",
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue