diff --git a/CHANGELOG.md b/CHANGELOG.md index 313e25070bc..0697d588893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ Docs: https://docs.openclaw.ai - Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639. - Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777. - Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici. +- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. ## 2026.3.11 diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 554dc87e2b8..3e3f254d0ee 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -1020,3 +1020,144 @@ describe("acp prompt size hardening", () => { }); }); }); + +describe("acp final chat snapshots", () => { + async function createSnapshotHarness() { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + await agent.loadSession(createLoadSessionRequest("snapshot-session")); + sessionUpdate.mockClear(); + const promptPromise = agent.prompt(createPromptRequest("snapshot-session", "hello")); + const runId = sessionStore.getSession("snapshot-session")?.activeRunId; + if (!runId) { + throw new Error("Expected ACP prompt run to be active"); + } + return { agent, sessionUpdate, promptPromise, runId, sessionStore }; + } + + it("emits final snapshot text before resolving end_turn", async () => { + const { agent, sessionUpdate, promptPromise, runId, sessionStore } = + await createSnapshotHarness(); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "final", + stopReason: "end_turn", + message: { + content: [{ type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }], + }, + }, + } as unknown as EventFrame); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "snapshot-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }, + }, + }); + expect(sessionStore.getSession("snapshot-session")?.activeRunId).toBeNull(); + sessionStore.clearAllSessionsForTest(); + }); + + it("does not duplicate text when final repeats the last delta snapshot", async () => { + const { agent, sessionUpdate, promptPromise, runId, sessionStore } = + await createSnapshotHarness(); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "delta", + message: { + content: [{ type: "text", text: "Hello world" }], + }, + }, + } as unknown as EventFrame); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "final", + stopReason: "end_turn", + message: { + content: [{ type: "text", text: "Hello world" }], + }, + }, + } as unknown as EventFrame); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + const chunks = sessionUpdate.mock.calls.filter( + (call: unknown[]) => + (call[0] as Record)?.update && + (call[0] as Record>).update?.sessionUpdate === + "agent_message_chunk", + ); + expect(chunks).toHaveLength(1); + sessionStore.clearAllSessionsForTest(); + }); + + it("emits only the missing tail when the final snapshot extends prior deltas", async () => { + const { agent, sessionUpdate, promptPromise, runId, sessionStore } = + await createSnapshotHarness(); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "delta", + message: { + content: [{ type: "text", text: "Hello" }], + }, + }, + } as unknown as EventFrame); + + await agent.handleGatewayEvent({ + event: "chat", + payload: { + sessionKey: "snapshot-session", + runId, + state: "final", + stopReason: "max_tokens", + message: { + content: [{ type: "text", text: "Hello world" }], + }, + }, + } as unknown as EventFrame); + + await expect(promptPromise).resolves.toEqual({ stopReason: "max_tokens" }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "snapshot-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "snapshot-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: " world" }, + }, + }); + sessionStore.clearAllSessionsForTest(); + }); +}); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index b5a6802d07b..8ab1f821fc8 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -800,9 +800,15 @@ export class AcpGatewayAgent implements Agent { return; } - if (state === "delta" && messageData) { + const shouldHandleMessageSnapshot = messageData && (state === "delta" || state === "final"); + if (shouldHandleMessageSnapshot) { + // Gateway chat events can carry the latest full assistant snapshot on both + // incremental updates and the terminal final event. Process the snapshot + // first so ACP clients never drop the last visible assistant text. await this.handleDeltaEvent(pending.sessionId, messageData); - return; + if (state === "delta") { + return; + } } if (state === "final") { diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index ae29f988afd..96ff7b1b0cb 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -54,7 +54,7 @@ async function sendSlackOutboundMessage(params: { text: string; mediaUrl?: string; mediaLocalRoots?: readonly string[]; - blocks?: Parameters[2]["blocks"]; + blocks?: NonNullable[2]>["blocks"]; accountId?: string | null; deps?: { sendSlack?: typeof sendMessageSlack } | null; replyToId?: string | null; diff --git a/src/cron/isolated-agent/run.fast-mode.test.ts b/src/cron/isolated-agent/run.fast-mode.test.ts new file mode 100644 index 00000000000..471471e9ecd --- /dev/null +++ b/src/cron/isolated-agent/run.fast-mode.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + makeIsolatedAgentTurnJob, + makeIsolatedAgentTurnParams, + setupRunCronIsolatedAgentTurnSuite, +} from "./run.suite-helpers.js"; +import { + loadRunCronIsolatedAgentTurn, + makeCronSession, + resolveCronSessionMock, + runEmbeddedPiAgentMock, + runWithModelFallbackMock, +} from "./run.test-harness.js"; + +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); + +describe("runCronIsolatedAgentTurn — fast mode", () => { + setupRunCronIsolatedAgentTurnSuite(); + + it("passes config-driven fast mode into embedded cron runs", async () => { + const cronSession = makeCronSession(); + resolveCronSessionMock.mockReturnValue(cronSession); + + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + return { + result: { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider, + model, + attempts: [], + }; + }); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-4": { + params: { + fastMode: true, + }, + }, + }, + }, + }, + }, + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test fast mode", + model: "openai/gpt-4", + }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({ + provider: "openai", + model: "gpt-4", + fastMode: true, + }); + }); + + it("honors session fastMode=false over config fastMode=true", async () => { + const cronSession = makeCronSession({ + sessionEntry: { + ...makeCronSession().sessionEntry, + fastMode: false, + }, + }); + resolveCronSessionMock.mockReturnValue(cronSession); + + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + return { + result: { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider, + model, + attempts: [], + }; + }); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-4": { + params: { + fastMode: true, + }, + }, + }, + }, + }, + }, + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test fast mode override", + model: "openai/gpt-4", + }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({ + provider: "openai", + model: "gpt-4", + fastMode: false, + }); + }); + + it("honors session fastMode=true over config fastMode=false", async () => { + const cronSession = makeCronSession({ + sessionEntry: { + ...makeCronSession().sessionEntry, + fastMode: true, + }, + }); + resolveCronSessionMock.mockReturnValue(cronSession); + + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + await run(provider, model); + return { + result: { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider, + model, + attempts: [], + }; + }); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-4": { + params: { + fastMode: false, + }, + }, + }, + }, + }, + }, + job: makeIsolatedAgentTurnJob({ + payload: { + kind: "agentTurn", + message: "test fast mode session override", + model: "openai/gpt-4", + }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock.mock.calls[0][0]).toMatchObject({ + provider: "openai", + model: "gpt-4", + fastMode: true, + }); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4c7a5c87fe2..8a074338da7 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -12,6 +12,7 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; import { resolveCronStyleNow } from "../../agents/current-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { resolveFastModeState } from "../../agents/fast-mode.js"; import { resolveNestedAgentLane } from "../../agents/lanes.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; @@ -617,6 +618,12 @@ export async function runCronIsolatedAgentTurn(params: { authProfileId, authProfileIdSource, thinkLevel, + fastMode: resolveFastModeState({ + cfg: cfgWithAgentDefaults, + provider: providerOverride, + model: modelOverride, + sessionEntry: cronSession.sessionEntry, + }).enabled, verboseLevel: resolvedVerboseLevel, timeoutMs, bootstrapContextMode: agentPayload?.lightContext ? "lightweight" : undefined, diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index 4b45445f727..633df0fee7e 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -179,6 +179,7 @@ describe("readScheduledTaskCommand", () => { const result = await readScheduledTaskCommand(env); expect(result).toEqual({ programArguments: ["C:/Program Files/Node/node.exe", "gateway.js"], + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -222,6 +223,7 @@ describe("readScheduledTaskCommand", () => { NODE_ENV: "production", OPENCLAW_PORT: "18789", }, + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -245,6 +247,7 @@ describe("readScheduledTaskCommand", () => { "--port", "18789", ], + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -268,6 +271,7 @@ describe("readScheduledTaskCommand", () => { "--port", "18789", ], + sourcePath: resolveTaskScriptPath(env), }); }, ); @@ -283,6 +287,7 @@ describe("readScheduledTaskCommand", () => { const result = await readScheduledTaskCommand(env); expect(result).toEqual({ programArguments: ["node", "gateway.js", "--from-state-dir"], + sourcePath: resolveTaskScriptPath(env), }); }, ); diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 1896e80388a..0f08affe6a0 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -96,30 +96,6 @@ const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; const originalWindowsPath = (process.env as NodeJS.ProcessEnv & { Path?: string }).Path; -async function installFakeWindowsCliPackage(params: { - rootDir: string; - packageName: "qmd" | "mcporter"; -}): Promise { - const nodeModulesDir = path.join(params.rootDir, "node_modules"); - const shimDir = path.join(nodeModulesDir, ".bin"); - const packageDir = path.join(nodeModulesDir, params.packageName); - const scriptPath = path.join(packageDir, "dist", "cli.js"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(shimDir, { recursive: true }); - await fs.writeFile(path.join(shimDir, `${params.packageName}.cmd`), "@echo off\r\n", "utf8"); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: params.packageName, - version: "0.0.0", - bin: { [params.packageName]: "dist/cli.js" }, - }), - "utf8", - ); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - return shimDir; -} - describe("QmdMemoryManager", () => { let fixtureRoot: string; let fixtureCount = 0; @@ -167,20 +143,9 @@ describe("QmdMemoryManager", () => { // created lazily by manager code when needed. await fs.mkdir(workspaceDir); process.env.OPENCLAW_STATE_DIR = stateDir; - if (process.platform === "win32") { - const qmdShimDir = await installFakeWindowsCliPackage({ - rootDir: path.join(tmpRoot, "fake-qmd-cli"), - packageName: "qmd", - }); - const mcporterShimDir = await installFakeWindowsCliPackage({ - rootDir: path.join(tmpRoot, "fake-mcporter-cli"), - packageName: "mcporter", - }); - const nextPath = [qmdShimDir, mcporterShimDir, originalPath].filter(Boolean).join(";"); - process.env.PATH = nextPath; - process.env.PATHEXT = ".CMD;.EXE"; - (process.env as NodeJS.ProcessEnv & { Path?: string }).Path = nextPath; - } + // Keep the default Windows path unresolved for most tests so spawn mocks can + // match the logical package command. Tests that verify wrapper resolution + // install explicit shim fixtures inline. cfg = { agents: { list: [{ id: agentId, default: true, workspace: workspaceDir }], diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts new file mode 100644 index 00000000000..1fcdf14db7f --- /dev/null +++ b/ui/src/ui/app-chat.test.ts @@ -0,0 +1,65 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshChatAvatar, type ChatHost } from "./app-chat.ts"; + +function makeHost(overrides?: Partial): ChatHost { + return { + client: null, + chatMessages: [], + chatStream: null, + connected: true, + chatMessage: "", + chatAttachments: [], + chatQueue: [], + chatRunId: null, + chatSending: false, + lastError: null, + sessionKey: "agent:main", + basePath: "", + hello: null, + chatAvatarUrl: null, + refreshSessionsAfterChat: new Set(), + ...overrides, + }; +} + +describe("refreshChatAvatar", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses a route-relative avatar endpoint before basePath bootstrap finishes", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ avatarUrl: "/avatar/main" }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const host = makeHost({ basePath: "", sessionKey: "agent:main" }); + await refreshChatAvatar(host); + + expect(fetchMock).toHaveBeenCalledWith( + "avatar/main?meta=1", + expect.objectContaining({ method: "GET" }), + ); + expect(host.chatAvatarUrl).toBe("/avatar/main"); + }); + + it("keeps mounted dashboard avatar endpoints under the normalized base path", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const host = makeHost({ basePath: "/openclaw/", sessionKey: "agent:ops:main" }); + await refreshChatAvatar(host); + + expect(fetchMock).toHaveBeenCalledWith( + "/openclaw/avatar/ops?meta=1", + expect.objectContaining({ method: "GET" }), + ); + expect(host.chatAvatarUrl).toBeNull(); + }); +}); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 791bdd639ba..05f6aa8c9e2 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -372,7 +372,7 @@ function resolveAgentIdForSession(host: ChatHost): string | null { function buildAvatarMetaUrl(basePath: string, agentId: string): string { const base = normalizeBasePath(basePath); const encoded = encodeURIComponent(agentId); - return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`; + return base ? `${base}/avatar/${encoded}?meta=1` : `avatar/${encoded}?meta=1`; } export async function refreshChatAvatar(host: ChatHost) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1b5390adc15..74644f07708 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -78,6 +78,7 @@ import "./components/dashboard-header.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; +import { agentLogoUrl } from "./views/agents-utils.ts"; import { resolveAgentConfig, resolveConfiguredCronModelSuggestions, @@ -450,7 +451,7 @@ export function renderApp(state: AppViewState) { ? nothing : html` ` diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index eea9bec03c8..8935520c2a4 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + agentLogoUrl, resolveConfiguredCronModelSuggestions, resolveEffectiveModelFallbacks, sortLocaleStrings, @@ -98,3 +99,14 @@ describe("sortLocaleStrings", () => { expect(sortLocaleStrings(new Set(["beta", "alpha"]))).toEqual(["alpha", "beta"]); }); }); + +describe("agentLogoUrl", () => { + it("keeps base-mounted control UI logo paths absolute to the mount", () => { + expect(agentLogoUrl("/ui")).toBe("/ui/favicon.svg"); + expect(agentLogoUrl("/apps/openclaw/")).toBe("/apps/openclaw/favicon.svg"); + }); + + it("uses a route-relative fallback before basePath bootstrap finishes", () => { + expect(agentLogoUrl("")).toBe("favicon.svg"); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 45b39e5a77b..1eb28892bb5 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -215,7 +215,7 @@ export function resolveAgentAvatarUrl( export function agentLogoUrl(basePath: string): string { const base = basePath?.trim() ? basePath.replace(/\/$/, "") : ""; - return base ? `${base}/favicon.svg` : "/favicon.svg"; + return base ? `${base}/favicon.svg` : "favicon.svg"; } function isLikelyEmoji(value: string) { diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 2e04413d39a..341409a9bac 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -1,3 +1,5 @@ +/* @vitest-environment jsdom */ + import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import type { SessionsListResult } from "../types.ts"; @@ -54,6 +56,46 @@ function createProps(overrides: Partial = {}): ChatProps { } describe("chat view", () => { + it("uses the assistant avatar URL for the welcome state when the identity avatar is only initials", () => { + const container = document.createElement("div"); + render( + renderChat( + createProps({ + assistantName: "Assistant", + assistantAvatar: "A", + assistantAvatarUrl: "/avatar/main", + }), + ), + container, + ); + + const welcomeImage = container.querySelector(".agent-chat__welcome > img"); + expect(welcomeImage).not.toBeNull(); + expect(welcomeImage?.getAttribute("src")).toBe("/avatar/main"); + }); + + it("falls back to the bundled logo in the welcome state when the assistant avatar is not a URL", () => { + const container = document.createElement("div"); + render( + renderChat( + createProps({ + assistantName: "Assistant", + assistantAvatar: "A", + assistantAvatarUrl: null, + }), + ), + container, + ); + + const welcomeImage = container.querySelector(".agent-chat__welcome > img"); + const logoImage = container.querySelector( + ".agent-chat__welcome .agent-chat__avatar--logo img", + ); + expect(welcomeImage).toBeNull(); + expect(logoImage).not.toBeNull(); + expect(logoImage?.getAttribute("src")).toBe("favicon.svg"); + }); + it("renders compacting indicator as a badge", () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index db0b924322d..36412b965a6 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -31,7 +31,7 @@ import { detectTextDirection } from "../text-direction.ts"; import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; import type { ChatItem, MessageGroup } from "../types/chat-types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; -import { agentLogoUrl } from "./agents-utils.ts"; +import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts"; import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; import "../components/resizable-divider.ts"; @@ -566,7 +566,12 @@ const WELCOME_SUGGESTIONS = [ function renderWelcomeState(props: ChatProps): TemplateResult { const name = props.assistantName || "Assistant"; - const avatar = props.assistantAvatar ?? props.assistantAvatarUrl; + const avatar = resolveAgentAvatarUrl({ + identity: { + avatar: props.assistantAvatar ?? undefined, + avatarUrl: props.assistantAvatarUrl ?? undefined, + }, + }); const logoUrl = agentLogoUrl(props.basePath ?? ""); return html` @@ -802,7 +807,13 @@ export function renderChat(props: ChatProps) { const showReasoning = props.showThinking && reasoningLevel !== "off"; const assistantIdentity = { name: props.assistantName, - avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, + avatar: + resolveAgentAvatarUrl({ + identity: { + avatar: props.assistantAvatar ?? undefined, + avatarUrl: props.assistantAvatarUrl ?? undefined, + }, + }) ?? null, }; const pinned = getPinnedMessages(props.sessionKey); const deleted = getDeletedMessages(props.sessionKey); diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts index d63a12c047e..77613822cdf 100644 --- a/ui/src/ui/views/login-gate.ts +++ b/ui/src/ui/views/login-gate.ts @@ -4,10 +4,11 @@ import { renderThemeToggle } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; import { icons } from "../icons.ts"; import { normalizeBasePath } from "../navigation.ts"; +import { agentLogoUrl } from "./agents-utils.ts"; export function renderLoginGate(state: AppViewState) { const basePath = normalizeBasePath(state.basePath ?? ""); - const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg"; + const faviconSrc = agentLogoUrl(basePath); return html`