feat(ui): add user name and avatar customization

This commit is contained in:
AVBharath10 2026-01-26 13:11:37 +05:30
parent 6859e1e6a6
commit 1320611421
5 changed files with 502 additions and 462 deletions

View File

@ -275,7 +275,10 @@ const FIELD_LABELS: Record<string, string> = {
"commands.useAccessGroups": "Use Access Groups", "commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color", "ui.seamColor": "Accent Color",
"ui.assistant.name": "Assistant Name", "ui.assistant.name": "Assistant Name",
"ui.assistant.name": "Assistant Name",
"ui.assistant.avatar": "Assistant Avatar", "ui.assistant.avatar": "Assistant Avatar",
"ui.user.name": "User Name",
"ui.user.avatar": "User Avatar",
"browser.controlUrl": "Browser Control URL", "browser.controlUrl": "Browser Control URL",
"browser.snapshotDefaults": "Browser Snapshot Defaults", "browser.snapshotDefaults": "Browser Snapshot Defaults",
"browser.snapshotDefaults.mode": "Browser Snapshot Mode", "browser.snapshotDefaults.mode": "Browser Snapshot Mode",
@ -875,9 +878,9 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
const pluginSchema = asSchemaObject(plugin.configSchema); const pluginSchema = asSchemaObject(plugin.configSchema);
const nextConfigSchema = const nextConfigSchema =
baseConfigSchema && baseConfigSchema &&
pluginSchema && pluginSchema &&
isObjectSchema(baseConfigSchema) && isObjectSchema(baseConfigSchema) &&
isObjectSchema(pluginSchema) isObjectSchema(pluginSchema)
? mergeObjectSchema(baseConfigSchema, pluginSchema) ? mergeObjectSchema(baseConfigSchema, pluginSchema)
: cloneSchema(plugin.configSchema); : cloneSchema(plugin.configSchema);

View File

@ -177,6 +177,13 @@ export const ClawdbotSchema = z
}) })
.strict() .strict()
.optional(), .optional(),
user: z
.object({
name: z.string().max(50).optional(),
avatar: z.string().max(200).optional(),
})
.strict()
.optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@ -111,6 +111,11 @@ export function renderApp(state: AppViewState) {
const assistantAvatarUrl = resolveAssistantAvatarUrl(state); const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
// Resolve user identity from config
const uiConfig = (state.configSnapshot?.config as any)?.ui;
const userName = uiConfig?.user?.name;
const userAvatar = uiConfig?.user?.avatar;
return html` return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}"> <div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
<header class="topbar"> <header class="topbar">
@ -118,10 +123,10 @@ export function renderApp(state: AppViewState) {
<button <button
class="nav-collapse-toggle" class="nav-collapse-toggle"
@click=${() => @click=${() =>
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
navCollapsed: !state.settings.navCollapsed, navCollapsed: !state.settings.navCollapsed,
})} })}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
> >
@ -148,20 +153,20 @@ export function renderApp(state: AppViewState) {
</header> </header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}"> <aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => { ${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false; const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab); const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html` return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}"> <div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button <button
class="nav-label" class="nav-label"
@click=${() => { @click=${() => {
const next = { ...state.settings.navGroupsCollapsed }; const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed; next[group.label] = !isGroupCollapsed;
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
navGroupsCollapsed: next, navGroupsCollapsed: next,
}); });
}} }}
aria-expanded=${!isGroupCollapsed} aria-expanded=${!isGroupCollapsed}
> >
<span class="nav-label__text">${group.label}</span> <span class="nav-label__text">${group.label}</span>
@ -172,7 +177,7 @@ export function renderApp(state: AppViewState) {
</div> </div>
</div> </div>
`; `;
})} })}
<div class="nav-group nav-group--links"> <div class="nav-group nav-group--links">
<div class="nav-label nav-label--static"> <div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span> <span class="nav-label__text">Resources</span>
@ -199,383 +204,385 @@ export function renderApp(state: AppViewState) {
</div> </div>
<div class="page-meta"> <div class="page-meta">
${state.lastError ${state.lastError
? html`<div class="pill danger">${state.lastError}</div>` ? html`<div class="pill danger">${state.lastError}</div>`
: nothing} : nothing}
${isChat ? renderChatControls(state) : nothing} ${isChat ? renderChatControls(state) : nothing}
</div> </div>
</section> </section>
${state.tab === "overview" ${state.tab === "overview"
? renderOverview({ ? renderOverview({
connected: state.connected, connected: state.connected,
hello: state.hello, hello: state.hello,
settings: state.settings, settings: state.settings,
password: state.password, password: state.password,
lastError: state.lastError, lastError: state.lastError,
presenceCount, presenceCount,
sessionsCount, sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null, cronEnabled: state.cronStatus?.enabled ?? null,
cronNext, cronNext,
lastChannelsRefresh: state.channelsLastSuccess, lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next), onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next), onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.resetToolStream(); state.resetToolStream();
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
sessionKey: next, sessionKey: next,
lastActiveSessionKey: next, lastActiveSessionKey: next,
}); });
void state.loadAssistantIdentity(); void state.loadAssistantIdentity();
}, },
onConnect: () => state.connect(), onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(), onRefresh: () => state.loadOverview(),
}) })
: nothing} : nothing}
${state.tab === "channels" ${state.tab === "channels"
? renderChannels({ ? renderChannels({
connected: state.connected, connected: state.connected,
loading: state.channelsLoading, loading: state.channelsLoading,
snapshot: state.channelsSnapshot, snapshot: state.channelsSnapshot,
lastError: state.channelsError, lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess, lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage, whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected, whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy, whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema, configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading, configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm, configForm: state.configForm,
configUiHints: state.configUiHints, configUiHints: state.configUiHints,
configSaving: state.configSaving, configSaving: state.configSaving,
configFormDirty: state.configFormDirty, configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState, nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId, nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe), onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(), onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(), onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(), onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) => onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile), state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(), onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) => onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value), state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(), onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(), onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
}) })
: nothing} : nothing}
${state.tab === "instances" ${state.tab === "instances"
? renderInstances({ ? renderInstances({
loading: state.presenceLoading, loading: state.presenceLoading,
entries: state.presenceEntries, entries: state.presenceEntries,
lastError: state.presenceError, lastError: state.presenceError,
statusMessage: state.presenceStatus, statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state), onRefresh: () => loadPresence(state),
}) })
: nothing} : nothing}
${state.tab === "sessions" ${state.tab === "sessions"
? renderSessions({ ? renderSessions({
loading: state.sessionsLoading, loading: state.sessionsLoading,
result: state.sessionsResult, result: state.sessionsResult,
error: state.sessionsError, error: state.sessionsError,
activeMinutes: state.sessionsFilterActive, activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit, limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal, includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown, includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath, basePath: state.basePath,
onFiltersChange: (next) => { onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit; state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal; state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown; state.sessionsIncludeUnknown = next.includeUnknown;
}, },
onRefresh: () => loadSessions(state), onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch), onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key), onDelete: (key) => deleteSession(state, key),
}) })
: nothing} : nothing}
${state.tab === "cron" ${state.tab === "cron"
? renderCron({ ? renderCron({
loading: state.cronLoading, loading: state.cronLoading,
status: state.cronStatus, status: state.cronStatus,
jobs: state.cronJobs, jobs: state.cronJobs,
error: state.cronError, error: state.cronError,
busy: state.cronBusy, busy: state.cronBusy,
form: state.cronForm, form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id) ? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [], : state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {}, channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [], channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId, runsJobId: state.cronRunsJobId,
runs: state.cronRuns, runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(), onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state), onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job), onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job), onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId), onLoadRuns: (jobId) => loadCronRuns(state, jobId),
}) })
: nothing} : nothing}
${state.tab === "skills" ${state.tab === "skills"
? renderSkills({ ? renderSkills({
loading: state.skillsLoading, loading: state.skillsLoading,
report: state.skillsReport, report: state.skillsReport,
error: state.skillsError, error: state.skillsError,
filter: state.skillsFilter, filter: state.skillsFilter,
edits: state.skillEdits, edits: state.skillEdits,
messages: state.skillMessages, messages: state.skillMessages,
busyKey: state.skillsBusyKey, busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next), onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }), onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value), onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key), onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) => onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId), installSkill(state, skillKey, name, installId),
}) })
: nothing} : nothing}
${state.tab === "nodes" ${state.tab === "nodes"
? renderNodes({ ? renderNodes({
loading: state.nodesLoading, loading: state.nodesLoading,
nodes: state.nodes, nodes: state.nodes,
devicesLoading: state.devicesLoading, devicesLoading: state.devicesLoading,
devicesError: state.devicesError, devicesError: state.devicesError,
devicesList: state.devicesList, devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null), configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading, configLoading: state.configLoading,
configSaving: state.configSaving, configSaving: state.configSaving,
configDirty: state.configFormDirty, configDirty: state.configFormDirty,
configFormMode: state.configFormMode, configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading, execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving, execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty, execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot, execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm, execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget, execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state), onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state), onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) => onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }), rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }), revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state), onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => { onLoadExecApprovals: () => {
const target = const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const }; : { kind: "gateway" as const };
return loadExecApprovals(state, target); return loadExecApprovals(state, target);
}, },
onBindDefault: (nodeId) => { onBindDefault: (nodeId) => {
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else { } else {
removeConfigFormValue(state, ["tools", "exec", "node"]); removeConfigFormValue(state, ["tools", "exec", "node"]);
} }
}, },
onBindAgent: (agentIndex, nodeId) => { onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, basePath, nodeId); updateConfigFormValue(state, basePath, nodeId);
} else { } else {
removeConfigFormValue(state, basePath); removeConfigFormValue(state, basePath);
} }
}, },
onSaveBindings: () => saveConfig(state), onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => { onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind; state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId; state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null; state.execApprovalsSnapshot = null;
state.execApprovalsForm = null; state.execApprovalsForm = null;
state.execApprovalsDirty = false; state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null; state.execApprovalsSelectedAgent = null;
}, },
onExecApprovalsSelectAgent: (agentId) => { onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId; state.execApprovalsSelectedAgent = agentId;
}, },
onExecApprovalsPatch: (path, value) => onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value), updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path), removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => { onSaveExecApprovals: () => {
const target = const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const }; : { kind: "gateway" as const };
return saveExecApprovals(state, target); return saveExecApprovals(state, target);
}, },
}) })
: nothing} : nothing}
${state.tab === "chat" ${state.tab === "chat"
? renderChat({ ? renderChat({
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.chatAttachments = []; state.chatAttachments = [];
state.chatStream = null; state.chatStream = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.chatRunId = null; state.chatRunId = null;
state.chatQueue = []; state.chatQueue = [];
state.resetToolStream(); state.resetToolStream();
state.resetChatScroll(); state.resetChatScroll();
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
sessionKey: next, sessionKey: next,
lastActiveSessionKey: next, lastActiveSessionKey: next,
}); });
void state.loadAssistantIdentity(); void state.loadAssistantIdentity();
void loadChatHistory(state); void loadChatHistory(state);
void refreshChatAvatar(state); void refreshChatAvatar(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
showThinking, showThinking,
loading: state.chatLoading, loading: state.chatLoading,
sending: state.chatSending, sending: state.chatSending,
compactionStatus: state.compactionStatus, compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl, assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages, messages: state.chatMessages,
toolMessages: state.chatToolMessages, toolMessages: state.chatToolMessages,
stream: state.chatStream, stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt, streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage, draft: state.chatMessage,
queue: state.chatQueue, queue: state.chatQueue,
connected: state.connected, connected: state.connected,
canSend: state.connected, canSend: state.connected,
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
error: state.lastError, error: state.lastError,
sessions: state.sessionsResult, sessions: state.sessionsResult,
focusMode: chatFocus, focusMode: chatFocus,
onRefresh: () => { onRefresh: () => {
state.resetToolStream(); state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
}, },
onToggleFocusMode: () => { onToggleFocusMode: () => {
if (state.onboarding) return; if (state.onboarding) return;
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
chatFocusMode: !state.settings.chatFocusMode, chatFocusMode: !state.settings.chatFocusMode,
}); });
}, },
onChatScroll: (event) => state.handleChatScroll(event), onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments, attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next), onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId), canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(), onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id), onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }), state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing // Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent, sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError, sidebarError: state.sidebarError,
splitRatio: state.splitRatio, splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content), onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(), onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName, assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar, assistantAvatar: state.assistantAvatar,
}) userName,
: nothing} userAvatar,
})
: nothing}
${state.tab === "config" ${state.tab === "config"
? renderConfig({ ? renderConfig({
raw: state.configRaw, raw: state.configRaw,
originalRaw: state.configRawOriginal, originalRaw: state.configRawOriginal,
valid: state.configValid, valid: state.configValid,
issues: state.configIssues, issues: state.configIssues,
loading: state.configLoading, loading: state.configLoading,
saving: state.configSaving, saving: state.configSaving,
applying: state.configApplying, applying: state.configApplying,
updating: state.updateRunning, updating: state.updateRunning,
connected: state.connected, connected: state.connected,
schema: state.configSchema, schema: state.configSchema,
schemaLoading: state.configSchemaLoading, schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints, uiHints: state.configUiHints,
formMode: state.configFormMode, formMode: state.configFormMode,
formValue: state.configForm, formValue: state.configForm,
originalValue: state.configFormOriginal, originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery, searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection, activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection, activeSubsection: state.configActiveSubsection,
onRawChange: (next) => { onRawChange: (next) => {
state.configRaw = next; state.configRaw = next;
}, },
onFormModeChange: (mode) => (state.configFormMode = mode), onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query), onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => { onSectionChange: (section) => {
state.configActiveSection = section; state.configActiveSection = section;
state.configActiveSubsection = null; state.configActiveSubsection = null;
}, },
onSubsectionChange: (section) => (state.configActiveSubsection = section), onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state), onReload: () => loadConfig(state),
onSave: () => saveConfig(state), onSave: () => saveConfig(state),
onApply: () => applyConfig(state), onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state), onUpdate: () => runUpdate(state),
}) })
: nothing} : nothing}
${state.tab === "debug" ${state.tab === "debug"
? renderDebug({ ? renderDebug({
loading: state.debugLoading, loading: state.debugLoading,
status: state.debugStatus, status: state.debugStatus,
health: state.debugHealth, health: state.debugHealth,
models: state.debugModels, models: state.debugModels,
heartbeat: state.debugHeartbeat, heartbeat: state.debugHeartbeat,
eventLog: state.eventLog, eventLog: state.eventLog,
callMethod: state.debugCallMethod, callMethod: state.debugCallMethod,
callParams: state.debugCallParams, callParams: state.debugCallParams,
callResult: state.debugCallResult, callResult: state.debugCallResult,
callError: state.debugCallError, callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next), onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next), onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state), onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state), onCall: () => callDebugMethod(state),
}) })
: nothing} : nothing}
${state.tab === "logs" ${state.tab === "logs"
? renderLogs({ ? renderLogs({
loading: state.logsLoading, loading: state.logsLoading,
error: state.logsError, error: state.logsError,
file: state.logsFile, file: state.logsFile,
entries: state.logsEntries, entries: state.logsEntries,
filterText: state.logsFilterText, filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters, levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow, autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated, truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next), onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => { onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
}, },
onToggleAutoFollow: (next) => (state.logsAutoFollow = next), onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }), onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label), onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event), onScroll: (event) => state.handleLogsScroll(event),
}) })
: nothing} : nothing}
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
</div> </div>

View File

@ -87,14 +87,14 @@ export function renderStreamingGroup(
${renderAvatar("assistant", assistant)} ${renderAvatar("assistant", assistant)}
<div class="chat-group-messages"> <div class="chat-group-messages">
${renderGroupedMessage( ${renderGroupedMessage(
{ {
role: "assistant", role: "assistant",
content: [{ type: "text", text }], content: [{ type: "text", text }],
timestamp: startedAt, timestamp: startedAt,
}, },
{ isStreaming: true, showReasoning: false }, { isStreaming: true, showReasoning: false },
onOpenSidebar, onOpenSidebar,
)} )}
<div class="chat-group-footer"> <div class="chat-group-footer">
<span class="chat-sender-name">${name}</span> <span class="chat-sender-name">${name}</span>
<span class="chat-group-timestamp">${timestamp}</span> <span class="chat-group-timestamp">${timestamp}</span>
@ -111,13 +111,15 @@ export function renderMessageGroup(
showReasoning: boolean; showReasoning: boolean;
assistantName?: string; assistantName?: string;
assistantAvatar?: string | null; assistantAvatar?: string | null;
userName?: string;
userAvatar?: string | null;
}, },
) { ) {
const normalizedRole = normalizeRoleForGrouping(group.role); const normalizedRole = normalizeRoleForGrouping(group.role);
const assistantName = opts.assistantName ?? "Assistant"; const assistantName = opts.assistantName ?? "Assistant";
const who = const who =
normalizedRole === "user" normalizedRole === "user"
? "You" ? (opts.userName || "You")
: normalizedRole === "assistant" : normalizedRole === "assistant"
? assistantName ? assistantName
: normalizedRole; : normalizedRole;
@ -135,21 +137,24 @@ export function renderMessageGroup(
return html` return html`
<div class="chat-group ${roleClass}"> <div class="chat-group ${roleClass}">
${renderAvatar(group.role, { ${renderAvatar(group.role, {
name: assistantName, name: assistantName,
avatar: opts.assistantAvatar ?? null, avatar: opts.assistantAvatar ?? null,
})} }, {
name: opts.userName,
avatar: opts.userAvatar,
})}
<div class="chat-group-messages"> <div class="chat-group-messages">
${group.messages.map((item, index) => ${group.messages.map((item, index) =>
renderGroupedMessage( renderGroupedMessage(
item.message, item.message,
{ {
isStreaming: isStreaming:
group.isStreaming && index === group.messages.length - 1, group.isStreaming && index === group.messages.length - 1,
showReasoning: opts.showReasoning, showReasoning: opts.showReasoning,
}, },
opts.onOpenSidebar, opts.onOpenSidebar,
), ),
)} )}
<div class="chat-group-footer"> <div class="chat-group-footer">
<span class="chat-sender-name">${who}</span> <span class="chat-sender-name">${who}</span>
<span class="chat-group-timestamp">${timestamp}</span> <span class="chat-group-timestamp">${timestamp}</span>
@ -162,13 +167,17 @@ export function renderMessageGroup(
function renderAvatar( function renderAvatar(
role: string, role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">, assistant?: Pick<AssistantIdentity, "name" | "avatar">,
user?: { name?: string; avatar?: string | null },
) { ) {
const normalized = normalizeRoleForGrouping(role); const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant"; const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || ""; const assistantAvatar = assistant?.avatar?.trim() || "";
const userName = user?.name?.trim() || "You";
const userAvatar = user?.avatar?.trim() || "";
const initial = const initial =
normalized === "user" normalized === "user"
? "U" ? (userName === "You" ? "U" : userName.charAt(0).toUpperCase())
: normalized === "assistant" : normalized === "assistant"
? assistantName.charAt(0).toUpperCase() || "A" ? assistantName.charAt(0).toUpperCase() || "A"
: normalized === "tool" : normalized === "tool"
@ -179,7 +188,7 @@ function renderAvatar(
? "user" ? "user"
: normalized === "assistant" : normalized === "assistant"
? "assistant" ? "assistant"
: normalized === "tool" : normalized === "tool"
? "tool" ? "tool"
: "other"; : "other";
@ -194,6 +203,16 @@ function renderAvatar(
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`; return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
} }
if (userAvatar && normalized === "user") {
if (isAvatarUrl(userAvatar)) {
return html`<img
class="chat-avatar ${className}"
src="${userAvatar}"
alt="${userName}"
/>`;
}
}
return html`<div class="chat-avatar ${className}">${initial}</div>`; return html`<div class="chat-avatar ${className}">${initial}</div>`;
} }
@ -211,7 +230,7 @@ function renderMessageImages(images: ImageBlock[]) {
return html` return html`
<div class="chat-message-images"> <div class="chat-message-images">
${images.map( ${images.map(
(img) => html` (img) => html`
<img <img
src=${img.url} src=${img.url}
alt=${img.alt ?? "Attached image"} alt=${img.alt ?? "Attached image"}
@ -219,7 +238,7 @@ function renderMessageImages(images: ImageBlock[]) {
@click=${() => window.open(img.url, "_blank")} @click=${() => window.open(img.url, "_blank")}
/> />
`, `,
)} )}
</div> </div>
`; `;
} }
@ -277,13 +296,13 @@ function renderGroupedMessage(
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)} ${renderMessageImages(images)}
${reasoningMarkdown ${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML( ? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown), toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>` )}</div>`
: nothing} : nothing}
${markdown ${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>` ? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing} : nothing}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div> </div>
`; `;

View File

@ -52,6 +52,8 @@ export type ChatProps = {
splitRatio?: number; splitRatio?: number;
assistantName: string; assistantName: string;
assistantAvatar: string | null; assistantAvatar: string | null;
userName?: string;
userAvatar?: string | null;
// Image attachments // Image attachments
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
onAttachmentsChange?: (attachments: ChatAttachment[]) => void; onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
@ -147,7 +149,7 @@ function renderAttachmentPreview(props: ChatProps) {
return html` return html`
<div class="chat-attachments"> <div class="chat-attachments">
${attachments.map( ${attachments.map(
(att) => html` (att) => html`
<div class="chat-attachment"> <div class="chat-attachment">
<img <img
src=${att.dataUrl} src=${att.dataUrl}
@ -159,17 +161,17 @@ function renderAttachmentPreview(props: ChatProps) {
type="button" type="button"
aria-label="Remove attachment" aria-label="Remove attachment"
@click=${() => { @click=${() => {
const next = (props.attachments ?? []).filter( const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id, (a) => a.id !== att.id,
); );
props.onAttachmentsChange?.(next); props.onAttachmentsChange?.(next);
}} }}
> >
${icons.x} ${icons.x}
</button> </button>
</div> </div>
`, `,
)} )}
</div> </div>
`; `;
} }
@ -206,47 +208,49 @@ export function renderChat(props: ChatProps) {
> >
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing} ${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => { ${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") { if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity); return renderReadingIndicatorGroup(assistantIdentity);
} }
if (item.kind === "stream") { if (item.kind === "stream") {
return renderStreamingGroup( return renderStreamingGroup(
item.text, item.text,
item.startedAt, item.startedAt,
props.onOpenSidebar, props.onOpenSidebar,
assistantIdentity, assistantIdentity,
); );
} }
if (item.kind === "group") { if (item.kind === "group") {
return renderMessageGroup(item, { return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar, onOpenSidebar: props.onOpenSidebar,
showReasoning, showReasoning,
assistantName: props.assistantName, assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar, assistantAvatar: assistantIdentity.avatar,
}); userName: props.userName,
} userAvatar: props.userAvatar,
});
}
return nothing; return nothing;
})} })}
</div> </div>
`; `;
return html` return html`
<section class="card chat"> <section class="card chat">
${props.disabledReason ${props.disabledReason
? html`<div class="callout">${props.disabledReason}</div>` ? html`<div class="callout">${props.disabledReason}</div>`
: nothing} : nothing}
${props.error ${props.error
? html`<div class="callout danger">${props.error}</div>` ? html`<div class="callout danger">${props.error}</div>`
: nothing} : nothing}
${renderCompactionIndicator(props.compactionStatus)} ${renderCompactionIndicator(props.compactionStatus)}
${props.focusMode ${props.focusMode
? html` ? html`
<button <button
class="chat-focus-exit" class="chat-focus-exit"
type="button" type="button"
@ -257,7 +261,7 @@ export function renderChat(props: ChatProps) {
${icons.x} ${icons.x}
</button> </button>
` `
: nothing} : nothing}
<div <div
class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}" class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}"
@ -270,40 +274,40 @@ export function renderChat(props: ChatProps) {
</div> </div>
${sidebarOpen ${sidebarOpen
? html` ? html`
<resizable-divider <resizable-divider
.splitRatio=${splitRatio} .splitRatio=${splitRatio}
@resize=${(e: CustomEvent) => @resize=${(e: CustomEvent) =>
props.onSplitRatioChange?.(e.detail.splitRatio)} props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider> ></resizable-divider>
<div class="chat-sidebar"> <div class="chat-sidebar">
${renderMarkdownSidebar({ ${renderMarkdownSidebar({
content: props.sidebarContent ?? null, content: props.sidebarContent ?? null,
error: props.sidebarError ?? null, error: props.sidebarError ?? null,
onClose: props.onCloseSidebar!, onClose: props.onCloseSidebar!,
onViewRawText: () => { onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) return; if (!props.sidebarContent || !props.onOpenSidebar) return;
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``); props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``);
}, },
})} })}
</div> </div>
` `
: nothing} : nothing}
</div> </div>
${props.queue.length ${props.queue.length
? html` ? html`
<div class="chat-queue" role="status" aria-live="polite"> <div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div> <div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list"> <div class="chat-queue__list">
${props.queue.map( ${props.queue.map(
(item) => html` (item) => html`
<div class="chat-queue__item"> <div class="chat-queue__item">
<div class="chat-queue__text"> <div class="chat-queue__text">
${item.text || ${item.text ||
(item.attachments?.length (item.attachments?.length
? `Image (${item.attachments.length})` ? `Image (${item.attachments.length})`
: "")} : "")}
</div> </div>
<button <button
class="btn chat-queue__remove" class="btn chat-queue__remove"
@ -315,11 +319,11 @@ export function renderChat(props: ChatProps) {
</button> </button>
</div> </div>
`, `,
)} )}
</div> </div>
</div> </div>
` `
: nothing} : nothing}
<div class="chat-compose"> <div class="chat-compose">
${renderAttachmentPreview(props)} ${renderAttachmentPreview(props)}
@ -330,15 +334,15 @@ export function renderChat(props: ChatProps) {
.value=${props.draft} .value=${props.draft}
?disabled=${!props.connected} ?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
if (e.isComposing || e.keyCode === 229) return; if (e.isComposing || e.keyCode === 229) return;
if (e.shiftKey) return; // Allow Shift+Enter for line breaks if (e.shiftKey) return; // Allow Shift+Enter for line breaks
if (!props.connected) return; if (!props.connected) return;
e.preventDefault(); e.preventDefault();
if (canCompose) props.onSend(); if (canCompose) props.onSend();
}} }}
@input=${(e: Event) => @input=${(e: Event) =>
props.onDraftChange((e.target as HTMLTextAreaElement).value)} props.onDraftChange((e.target as HTMLTextAreaElement).value)}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)} @paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${composePlaceholder} placeholder=${composePlaceholder}
></textarea> ></textarea>