UI: polish agent skills, chat images, and sidebar status

This commit is contained in:
Val Alexander
2026-03-13 15:55:46 -05:00
parent 655a871ab0
commit 1f974a4790
18 changed files with 331 additions and 27 deletions

View File

@@ -5,6 +5,7 @@ export const de: TranslationMap = {
version: "Version",
health: "Status",
ok: "OK",
online: "Online",
offline: "Offline",
connect: "Verbinden",
refresh: "Aktualisieren",

View File

@@ -4,6 +4,7 @@ export const en: TranslationMap = {
common: {
health: "Health",
ok: "OK",
online: "Online",
offline: "Offline",
connect: "Connect",
refresh: "Refresh",

View File

@@ -5,6 +5,7 @@ export const es: TranslationMap = {
version: "Versión",
health: "Estado",
ok: "Correcto",
online: "En línea",
offline: "Desconectado",
connect: "Conectar",
refresh: "Actualizar",

View File

@@ -4,6 +4,7 @@ export const pt_BR: TranslationMap = {
common: {
health: "Saúde",
ok: "OK",
online: "Online",
offline: "Offline",
connect: "Conectar",
refresh: "Atualizar",

View File

@@ -4,6 +4,7 @@ export const zh_CN: TranslationMap = {
common: {
health: "健康状况",
ok: "正常",
online: "在线",
offline: "离线",
connect: "连接",
refresh: "刷新",

View File

@@ -4,6 +4,7 @@ export const zh_TW: TranslationMap = {
common: {
health: "健康狀況",
ok: "正常",
online: "在線",
offline: "離線",
connect: "連接",
refresh: "刷新",

View File

@@ -65,7 +65,7 @@
background: transparent;
}
.chat-thread-inner> :first-child {
.chat-thread-inner > :first-child {
margin-top: 0 !important;
}
@@ -291,7 +291,7 @@
}
/* Hide the "Message" label - keep textarea only */
.chat-compose__field>span {
.chat-compose__field > span {
display: none;
}
@@ -362,7 +362,7 @@
}
}
.agent-chat__input>textarea {
.agent-chat__input > textarea {
width: 100%;
min-height: 40px;
max-height: 150px;
@@ -378,7 +378,7 @@
box-sizing: border-box;
}
.agent-chat__input>textarea::placeholder {
.agent-chat__input > textarea::placeholder {
color: var(--muted);
}
@@ -520,7 +520,7 @@
scrollbar-width: thin;
}
.slash-menu-group+.slash-menu-group {
.slash-menu-group + .slash-menu-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);

View File

@@ -82,6 +82,18 @@
line-height: 1.5;
}
.sidebar-markdown .markdown-inline-image {
display: block;
max-width: 100%;
max-height: 420px;
width: auto;
height: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 70%, transparent);
object-fit: contain;
}
.sidebar-markdown pre {
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;

View File

@@ -56,6 +56,19 @@
font-size: 0.9em;
}
.chat-text :where(.markdown-inline-image) {
display: block;
max-width: min(100%, 420px);
max-height: 320px;
width: auto;
height: auto;
margin-top: 0.75em;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 70%, transparent);
object-fit: contain;
}
.chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.15);
padding: 0.15em 0.4em;

View File

@@ -2416,6 +2416,19 @@
font-size: 0.9em;
}
.chat-text :where(.markdown-inline-image) {
display: block;
max-width: min(100%, 420px);
max-height: 320px;
width: auto;
height: auto;
margin-top: 0.75em;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 70%, transparent);
object-fit: contain;
}
.chat-text :where(:not(pre) > code) {
padding: 0.15em 0.35em;
border-radius: var(--radius-sm);

View File

@@ -5,7 +5,7 @@
.shell {
--shell-pad: 16px;
--shell-gap: 16px;
--shell-nav-width: 288px;
--shell-nav-width: 258px;
--shell-nav-rail-width: 78px;
--shell-topbar-height: 52px;
--shell-focus-duration: 200ms;
@@ -70,7 +70,7 @@
padding-top: 0;
}
.shell--chat-focus .content>*+* {
.shell--chat-focus .content > * + * {
margin-top: 0;
}
@@ -340,7 +340,7 @@
flex-direction: column;
min-height: 0;
flex: 1;
padding: 14px 14px 12px;
padding: 14px 10px 12px;
border: none;
border-radius: 0;
background: transparent;
@@ -503,7 +503,7 @@
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 0 12px;
padding: 0 10px;
min-height: 28px;
background: transparent;
border: none;
@@ -522,9 +522,9 @@
}
.nav-section__label-text {
font-size: 11px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -555,9 +555,9 @@
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 38px;
padding: 0 12px;
gap: 8px;
min-height: 40px;
padding: 0 9px;
border-radius: 12px;
border: 1px solid transparent;
background: transparent;
@@ -595,8 +595,8 @@
}
.nav-item__text {
font-size: 13px;
font-weight: 550;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
@@ -688,9 +688,11 @@
.sidebar--collapsed .nav-item.active,
.sidebar--collapsed .nav-item--active {
background: linear-gradient(180deg,
background: linear-gradient(
180deg,
color-mix(in srgb, #0b2f34 84%, var(--bg-elevated) 16%) 0%,
color-mix(in srgb, #081f25 90%, var(--bg) 10%) 100%);
color-mix(in srgb, #081f25 90%, var(--bg) 10%) 100%
);
border-color: color-mix(in srgb, #1ed2c2 18%, var(--border) 82%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 8%, transparent),
@@ -761,6 +763,24 @@
margin: 0 auto;
}
.sidebar-version__status {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
flex-shrink: 0;
margin-left: auto;
}
.sidebar-version__status.sidebar-connection-status--online {
background: var(--ok);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 14%, transparent);
}
.sidebar-version__status.sidebar-connection-status--offline {
background: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent);
}
.sidebar--collapsed .sidebar-shell__footer {
padding: 8px 0 2px;
}
@@ -778,6 +798,10 @@
border-radius: 16px;
}
.sidebar--collapsed .sidebar-version__status {
margin-left: 0;
}
.shell--nav-collapsed .shell-nav {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
@@ -831,7 +855,7 @@
overflow-x: hidden;
}
.content>*+* {
.content > * + * {
margin-top: 20px;
}
@@ -847,7 +871,7 @@
padding-bottom: 0;
}
.content--chat>*+* {
.content--chat > * + * {
margin-top: 0;
}
@@ -906,7 +930,7 @@
padding-bottom: 0;
}
.content--chat .content-header>div:first-child {
.content--chat .content-header > div:first-child {
text-align: left;
}

View File

@@ -743,6 +743,23 @@ export function renderTopbarThemeModeToggle(state: AppViewState) {
`;
}
export function renderSidebarConnectionStatus(state: AppViewState) {
const label = state.connected ? t("common.online") : t("common.offline");
const toneClass = state.connected
? "sidebar-connection-status--online"
: "sidebar-connection-status--offline";
return html`
<span
class="sidebar-version__status ${toneClass}"
role="img"
aria-live="polite"
aria-label="Gateway status: ${label}"
title="Gateway status: ${label}"
></span>
`;
}
export function renderThemeToggle(state: AppViewState) {
const setOpen = (orb: HTMLElement, nextOpen: boolean) => {
orb.classList.toggle("theme-orb--open", nextOpen);

View File

@@ -10,6 +10,7 @@ import {
renderChatControls,
renderChatSessionSelect,
renderTab,
renderSidebarConnectionStatus,
renderTopbarThemeModeToggle,
} from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts";
@@ -437,9 +438,7 @@ export function renderApp(state: AppViewState) {
<span class="topbar-search__label">${t("common.search")}</span>
<kbd class="topbar-search__kbd">⌘K</kbd>
</button>
<div class="topbar-status">
${renderTopbarThemeModeToggle(state)}
</div>
<div class="topbar-status">${renderTopbarThemeModeToggle(state)}</div>
</div>
</div>
</header>
@@ -543,9 +542,10 @@ export function renderApp(state: AppViewState) {
? html`
<span class="sidebar-version__label">${t("common.version")}</span>
<span class="sidebar-version__text">v${version}</span>
${renderSidebarConnectionStatus(state)}
`
: html`
<span class="sidebar-version__dot"></span>
${renderSidebarConnectionStatus(state)}
`
}
</div>
@@ -915,6 +915,7 @@ export function renderApp(state: AppViewState) {
},
onRefresh: async () => {
await loadAgents(state);
await loadConfig(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
void loadAgentIdentities(state, agentIds);
@@ -924,9 +925,24 @@ export function renderApp(state: AppViewState) {
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
if (refreshedAgentId) {
void loadAgentIdentity(state, refreshedAgentId);
}
if (state.agentsPanel === "files" && refreshedAgentId) {
void loadAgentFiles(state, refreshedAgentId);
}
if (state.agentsPanel === "skills" && refreshedAgentId) {
void loadAgentSkills(state, refreshedAgentId);
}
if (state.agentsPanel === "tools" && refreshedAgentId) {
void loadToolsCatalog(state, refreshedAgentId);
}
if (state.agentsPanel === "channels") {
void loadChannels(state, false);
}
if (state.agentsPanel === "cron") {
void state.loadCron();
}
},
onSelectAgent: (agentId) => {
if (state.agentsSelectedId === agentId) {

View File

@@ -39,6 +39,7 @@ describe("toSanitizedMarkdownHtml", () => {
it("preserves base64 data URI images (#15437)", () => {
const html = toSanitizedMarkdownHtml("![Chart](data:image/png;base64,iVBORw0KGgo=)");
expect(html).toContain("<img");
expect(html).toContain('class="markdown-inline-image"');
expect(html).toContain("data:image/png;base64,");
});

View File

@@ -165,7 +165,7 @@ htmlEscapeRenderer.image = (token: { href?: string | null; text?: string | null
if (!INLINE_DATA_IMAGE_RE.test(href)) {
return escapeHtml(label);
}
return `<img src="${escapeHtml(href)}" alt="${escapeHtml(label)}">`;
return `<img class="markdown-inline-image" src="${escapeHtml(href)}" alt="${escapeHtml(label)}">`;
};
function normalizeMarkdownImageLabel(text?: string | null): string {

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
registerAppMountHooks();
describe("sidebar connection status", () => {
it("shows a single online status dot next to the version", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.hello = {
ok: true,
server: { version: "1.2.3" },
} as never;
app.requestUpdate();
await app.updateComplete;
const version = app.querySelector<HTMLElement>(".sidebar-version");
const statusDot = app.querySelector<HTMLElement>(".sidebar-version__status");
expect(version).not.toBeNull();
expect(statusDot).not.toBeNull();
expect(statusDot?.getAttribute("aria-label")).toContain("Online");
});
});

View File

@@ -0,0 +1,174 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderAgents, type AgentsProps } from "./agents.ts";
function createSkill() {
return {
name: "Repo Skill",
description: "Skill description",
source: "workspace",
filePath: "/tmp/skill",
baseDir: "/tmp",
skillKey: "repo-skill",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: {
bins: [],
env: [],
config: [],
os: [],
},
missing: {
bins: [],
env: [],
config: [],
os: [],
},
configChecks: [],
install: [],
};
}
function createProps(overrides: Partial<AgentsProps> = {}): AgentsProps {
return {
basePath: "",
loading: false,
error: null,
agentsList: {
defaultId: "alpha",
mainKey: "main",
scope: "workspace",
agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never],
},
selectedAgentId: "beta",
activePanel: "overview",
config: {
form: null,
loading: false,
saving: false,
dirty: false,
},
channels: {
snapshot: null,
loading: false,
error: null,
lastSuccess: null,
},
cron: {
status: null,
jobs: [],
loading: false,
error: null,
},
agentFiles: {
list: null,
loading: false,
error: null,
active: null,
contents: {},
drafts: {},
saving: false,
},
agentIdentityLoading: false,
agentIdentityError: null,
agentIdentityById: {},
agentSkills: {
report: null,
loading: false,
error: null,
agentId: null,
filter: "",
},
toolsCatalog: {
loading: false,
error: null,
result: null,
},
onRefresh: () => undefined,
onSelectAgent: () => undefined,
onSelectPanel: () => undefined,
onLoadFiles: () => undefined,
onSelectFile: () => undefined,
onFileDraftChange: () => undefined,
onFileReset: () => undefined,
onFileSave: () => undefined,
onToolsProfileChange: () => undefined,
onToolsOverridesChange: () => undefined,
onConfigReload: () => undefined,
onConfigSave: () => undefined,
onModelChange: () => undefined,
onModelFallbacksChange: () => undefined,
onChannelsRefresh: () => undefined,
onCronRefresh: () => undefined,
onCronRunNow: () => undefined,
onSkillsFilterChange: () => undefined,
onSkillsRefresh: () => undefined,
onAgentSkillToggle: () => undefined,
onAgentSkillsClear: () => undefined,
onAgentSkillsDisableAll: () => undefined,
onSetDefault: () => undefined,
...overrides,
};
}
describe("renderAgents", () => {
it("shows the skills count only for the selected agent's report", async () => {
const container = document.createElement("div");
render(
renderAgents(
createProps({
agentSkills: {
report: {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [createSkill()],
},
loading: false,
error: null,
agentId: "alpha",
filter: "",
},
}),
),
container,
);
await Promise.resolve();
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
(button) => button.textContent?.includes("Skills"),
);
expect(skillsTab?.textContent?.trim()).toBe("Skills");
});
it("shows the selected agent's skills count when the report matches", async () => {
const container = document.createElement("div");
render(
renderAgents(
createProps({
agentSkills: {
report: {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [createSkill()],
},
loading: false,
error: null,
agentId: "beta",
filter: "",
},
}),
),
container,
);
await Promise.resolve();
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
(button) => button.textContent?.includes("Skills"),
);
expect(skillsTab?.textContent?.trim()).toContain("1");
});
});

View File

@@ -113,6 +113,10 @@ export function renderAgents(props: AgentsProps) {
const selectedAgent = selectedId
? (agents.find((agent) => agent.id === selectedId) ?? null)
: null;
const selectedSkillCount =
selectedId && props.agentSkills.agentId === selectedId
? (props.agentSkills.report?.skills?.length ?? null)
: null;
const channelEntryCount = props.channels.snapshot
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
@@ -122,7 +126,7 @@ export function renderAgents(props: AgentsProps) {
: null;
const tabCounts: Record<string, number | null> = {
files: props.agentFiles.list?.files?.length ?? null,
skills: props.agentSkills.report?.skills?.length ?? null,
skills: selectedSkillCount,
channels: channelEntryCount,
cron: cronJobCount || null,
};