diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 9ace7542120..a2d5736c33e 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -10,6 +10,7 @@ import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import { parseAgentSessionKey } from "./session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { SessionsListResult } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; @@ -51,7 +52,7 @@ export function isChatStopCommand(text: string) { if (!trimmed) { return false; } - const normalized = trimmed.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(trimmed); if (normalized === "/stop") { return true; } @@ -69,7 +70,7 @@ function isChatResetCommand(text: string) { if (!trimmed) { return false; } - const normalized = trimmed.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(trimmed); if (normalized === "/new" || normalized === "/reset") { return true; } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 7205db64fa1..3e9bd70f6a4 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -16,6 +16,7 @@ import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import { parseAgentSessionKey } from "./session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import type { ThemeMode } from "./theme.ts"; import { listThinkingLevelLabels, @@ -607,7 +608,7 @@ function buildThinkingOptions( if (!trimmed) { return; } - const key = trimmed.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(trimmed); if (seen.has(key)) { return; } @@ -624,7 +625,7 @@ function buildThinkingOptions( }; for (const label of listThinkingLevelLabels(provider)) { - const normalized = normalizeThinkLevel(label) ?? label.trim().toLowerCase(); + const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label); addOption(normalized); } if (currentOverride) { @@ -807,7 +808,7 @@ function capitalize(s: string): string { * fallback display name. Exported for testing. */ export function parseSessionKey(key: string): SessionKeyInfo { - const normalized = key.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(key); // ── Main session ───────────────────────────────── if (key === "main" || key === "agent:main:main") { @@ -878,7 +879,7 @@ export function resolveSessionDisplayName( } export function isCronSessionKey(key: string): boolean { - const normalized = key.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(key); if (!normalized) { return false; } @@ -946,7 +947,7 @@ export function resolveSessionOptionGroups( const parsed = parseAgentSessionKey(key); const group = parsed ? ensureGroup( - `agent:${parsed.agentId.toLowerCase()}`, + `agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`, resolveAgentGroupLabel(state, parsed.agentId), ) : ensureGroup("other", "Other Sessions"); @@ -1060,9 +1061,9 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul } function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string { - const normalized = agentIdRaw.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw); const agent = (state.agentsList?.agents ?? []).find( - (entry) => entry.id.trim().toLowerCase() === normalized, + (entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized, ); const name = agent?.identity?.name?.trim() || agent?.name?.trim() || ""; return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8bca6abc167..4151f47a64f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -101,14 +101,15 @@ import { updateSkillEnabled, } from "./controllers/skills.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; -import "./components/dashboard-header.ts"; import { icons } from "./icons.ts"; +import "./components/dashboard-header.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { buildAgentMainSessionKey, parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "./session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import { agentLogoUrl } from "./views/agents-utils.ts"; import { resolveAgentConfig, @@ -218,7 +219,7 @@ function uniquePreserveOrder(values: string[]): string[] { if (!normalized) { continue; } - const key = normalized.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(normalized); if (seen.has(key)) { continue; } diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index 007b4774e98..6727b00d824 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -1,4 +1,5 @@ import { formatUnknownText, truncateText } from "./format.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; const TOOL_STREAM_LIMIT = 50; const TOOL_STREAM_THROTTLE_MS = 80; @@ -53,7 +54,11 @@ function resolveModelLabel(provider: unknown, model: unknown): string | null { const providerValue = toTrimmedString(provider); if (providerValue) { const prefix = `${providerValue}/`; - if (modelValue.toLowerCase().startsWith(prefix.toLowerCase())) { + if ( + normalizeLowercaseStringOrEmpty(modelValue).startsWith( + normalizeLowercaseStringOrEmpty(prefix), + ) + ) { const trimmedModel = modelValue.slice(prefix.length).trim(); if (trimmedModel) { return `${providerValue}/${trimmedModel}`; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index a0959d27584..b52aadc953d 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -72,6 +72,7 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import { resolveAgentIdFromSessionKey } from "./session-key.ts"; import { loadSettings, type UiSettings } from "./storage.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts"; import type { AgentsListResult, @@ -118,7 +119,7 @@ function resolveOnboardingMode(): boolean { if (!raw) { return false; } - const normalized = raw.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(raw); return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } diff --git a/ui/src/ui/chat-event-reload.ts b/ui/src/ui/chat-event-reload.ts index 2eb211d01aa..91f5a141137 100644 --- a/ui/src/ui/chat-event-reload.ts +++ b/ui/src/ui/chat-event-reload.ts @@ -1,4 +1,5 @@ import type { ChatEventPayload } from "./controllers/chat.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; export function shouldReloadHistoryForFinalEvent(payload?: ChatEventPayload): boolean { if (!payload || payload.state !== "final") { @@ -8,7 +9,7 @@ export function shouldReloadHistoryForFinalEvent(payload?: ChatEventPayload): bo return true; } const message = payload.message as Record; - const role = typeof message.role === "string" ? message.role.toLowerCase() : ""; + const role = normalizeLowercaseStringOrEmpty(message.role); if (role && role !== "assistant") { return true; } diff --git a/ui/src/ui/chat-model-ref.ts b/ui/src/ui/chat-model-ref.ts index deb819fa8e3..849bb679704 100644 --- a/ui/src/ui/chat-model-ref.ts +++ b/ui/src/ui/chat-model-ref.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import type { ModelCatalogEntry } from "./types.ts"; export type ChatModelOverride = @@ -68,8 +69,9 @@ export function resolveChatModelOverride( } let matchedValue = ""; + const normalizedTrimmed = normalizeLowercaseStringOrEmpty(trimmed); for (const entry of catalog) { - if (entry.id.trim().toLowerCase() !== trimmed.toLowerCase()) { + if (normalizeLowercaseStringOrEmpty(entry.id) !== normalizedTrimmed) { continue; } const candidate = buildQualifiedChatModelValue(entry.id, entry.provider); @@ -77,7 +79,9 @@ export function resolveChatModelOverride( matchedValue = candidate; continue; } - if (matchedValue.toLowerCase() !== candidate.toLowerCase()) { + if ( + normalizeLowercaseStringOrEmpty(matchedValue) !== normalizeLowercaseStringOrEmpty(candidate) + ) { return { value: trimmed, source: "raw", reason: "ambiguous" }; } } diff --git a/ui/src/ui/chat-model-select-state.ts b/ui/src/ui/chat-model-select-state.ts index eede05c54c8..ab1deb3fd29 100644 --- a/ui/src/ui/chat-model-select-state.ts +++ b/ui/src/ui/chat-model-select-state.ts @@ -5,6 +5,7 @@ import { normalizeChatModelOverrideValue, resolvePreferredServerChatModelValue, } from "./chat-model-ref.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import type { ModelCatalogEntry } from "./types.ts"; type ChatModelSelectStateInput = Pick< @@ -66,7 +67,7 @@ function buildChatModelOptions( if (!trimmed) { return; } - const key = trimmed.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(trimmed); if (seen.has(key)) { return; } diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index f11fba066bb..4c4329c9c67 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -5,6 +5,7 @@ import type { AssistantIdentity } from "../assistant-identity.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { openExternalUrlSafe } from "../open-external-url.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import { detectTextDirection } from "../text-direction.ts"; import type { MessageGroup, ToolCard } from "../types/chat-types.ts"; import { agentLogoUrl } from "../views/agents-utils.ts"; @@ -726,10 +727,11 @@ function renderGroupedMessage( const m = message as Record; const role = typeof m.role === "string" ? m.role : "unknown"; const normalizedRole = normalizeRoleForGrouping(role); + const normalizedRawRole = normalizeLowercaseStringOrEmpty(role); const isToolResult = isToolResultMessage(message) || - role.toLowerCase() === "toolresult" || - role.toLowerCase() === "tool_result" || + normalizedRawRole === "toolresult" || + normalizedRawRole === "tool_result" || typeof m.toolCallId === "string" || typeof m.tool_call_id === "string"; @@ -752,7 +754,12 @@ function renderGroupedMessage( // Detect pure-JSON messages and render as collapsible block const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null; - const bubbleClasses = ["chat-bubble", opts.isStreaming ? "streaming" : "", "fade-in", canCopyMarkdown ? "has-copy" : ""] + const bubbleClasses = [ + "chat-bubble", + opts.isStreaming ? "streaming" : "", + "fade-in", + canCopyMarkdown ? "has-copy" : "", + ] .filter(Boolean) .join(" "); diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index 457918a3a45..e743d8c305a 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -2,12 +2,13 @@ import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inb import { stripEnvelope } from "../../../../src/shared/chat-envelope.js"; import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js"; import { stripThinkingTags } from "../format.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; const textCache = new WeakMap(); const thinkingCache = new WeakMap(); function processMessageText(text: string, role: string): string { - const shouldStripInboundMetadata = role.toLowerCase() === "user"; + const shouldStripInboundMetadata = normalizeLowercaseStringOrEmpty(role) === "user"; if (role === "assistant") { return stripThinkingTags(text); } diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index 308f0278700..94d53f30905 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -8,6 +8,7 @@ import { isToolResultContentType, resolveToolBlockArgs, } from "../../../../src/chat/tool-content.js"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { NormalizedMessage, MessageContentItem } from "../types/chat-types.ts"; /** @@ -74,7 +75,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { * Normalize role for grouping purposes. */ export function normalizeRoleForGrouping(role: string): string { - const lower = role.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(role); // Preserve original casing when it's already a core role. if (role === "user" || role === "User") { return role; @@ -102,6 +103,6 @@ export function normalizeRoleForGrouping(role: string): string { */ export function isToolResultMessage(message: unknown): boolean { const m = message as Record; - const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; + const role = normalizeLowercaseStringOrEmpty(m.role); return role === "toolresult" || role === "tool_result"; } diff --git a/ui/src/ui/chat/search-match.ts b/ui/src/ui/chat/search-match.ts index 501a4ce4785..3d25bf79cf0 100644 --- a/ui/src/ui/chat/search-match.ts +++ b/ui/src/ui/chat/search-match.ts @@ -1,10 +1,11 @@ +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import { extractTextCached } from "./message-extract.ts"; export function messageMatchesSearchQuery(message: unknown, query: string): boolean { - const normalizedQuery = query.trim().toLowerCase(); + const normalizedQuery = normalizeLowercaseStringOrEmpty(query); if (!normalizedQuery) { return true; } - const text = (extractTextCached(message) ?? "").toLowerCase(); + const text = normalizeLowercaseStringOrEmpty(extractTextCached(message)); return text.includes(normalizedQuery); } diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 7534ca43186..a3289340ff0 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -11,6 +11,10 @@ import { isSubagentSessionKey, parseAgentSessionKey, } from "../session-key.ts"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../string-coerce.ts"; import { formatThinkingLevels, normalizeThinkLevel, @@ -60,7 +64,7 @@ function normalizeVerboseLevel(raw?: string | null): "off" | "on" | "full" | und if (!raw) { return undefined; } - const key = raw.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(raw); if (["off", "false", "no", "0"].includes(key)) { return "off"; } @@ -313,7 +317,7 @@ async function executeFast( sessionKey: string, args: string, ): Promise { - const rawMode = args.trim().toLowerCase(); + const rawMode = normalizeLowercaseStringOrEmpty(args); if (!rawMode || rawMode === "status") { try { @@ -406,6 +410,7 @@ async function executeKill( args: string, ): Promise { const target = args.trim(); + const normalizedTarget = normalizeLowercaseStringOrEmpty(target); if (!target) { return { content: "Usage: `/kill `" }; } @@ -415,7 +420,7 @@ async function executeKill( if (matched.length === 0) { return { content: - target.toLowerCase() === "all" + normalizedTarget === "all" ? "No active sub-agent sessions found." : `No matching sub-agent sessions found for \`${target}\`.`, }; @@ -435,7 +440,7 @@ async function executeKill( if (rejected.length === 0) { return { content: - target.toLowerCase() === "all" + normalizedTarget === "all" ? "No active sub-agent runs to abort." : `No active runs matched \`${target}\`.`, }; @@ -443,7 +448,7 @@ async function executeKill( throw rejected[0]?.reason ?? new Error("abort failed"); } - if (target.toLowerCase() === "all") { + if (normalizedTarget === "all") { return { content: successCount === matched.length @@ -468,13 +473,13 @@ function resolveKillTargets( currentSessionKey: string, target: string, ): string[] { - const normalizedTarget = target.trim().toLowerCase(); + const normalizedTarget = normalizeLowercaseStringOrEmpty(target); if (!normalizedTarget) { return []; } const keys = new Set(); - const normalizedCurrentSessionKey = currentSessionKey.trim().toLowerCase(); + const normalizedCurrentSessionKey = normalizeLowercaseStringOrEmpty(currentSessionKey); const currentParsed = parseAgentSessionKey(normalizedCurrentSessionKey); const currentAgentId = currentParsed?.agentId ?? @@ -485,7 +490,7 @@ function resolveKillTargets( if (!key || !isSubagentSessionKey(key)) { continue; } - const normalizedKey = key.toLowerCase(); + const normalizedKey = normalizeLowercaseStringOrEmpty(key); const parsed = parseAgentSessionKey(normalizedKey); const belongsToCurrentSession = isWithinCurrentSessionSubtree( normalizedKey, @@ -550,8 +555,7 @@ function buildSessionIndex(sessions: GatewaySessionRow[]): Map("sessions.list", {})); const matched = resolveSteerSubagent(sessions?.sessions ?? [], sessionKey, maybeTarget); diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts index 3a48cf9a8b1..2ca61b759c7 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/ui/chat/slash-commands.ts @@ -4,6 +4,7 @@ import type { CommandArgChoice, } from "../../../../src/auto-reply/commands-registry.types.js"; import type { IconName } from "../icons.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; @@ -216,13 +217,13 @@ export const CATEGORY_LABELS: Record = { }; export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { - const lower = filter.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(filter); const commands = lower ? SLASH_COMMANDS.filter( (cmd) => cmd.name.startsWith(lower) || - cmd.aliases?.some((alias) => alias.toLowerCase().startsWith(lower)) || - cmd.description.toLowerCase().includes(lower), + cmd.aliases?.some((alias) => normalizeLowercaseStringOrEmpty(alias).startsWith(lower)) || + normalizeLowercaseStringOrEmpty(cmd.description).includes(lower), ) : SLASH_COMMANDS; return commands.toSorted((a, b) => { @@ -266,11 +267,11 @@ export function parseSlashCommand(text: string): ParsedSlashCommand | null { return null; } - const normalizedName = name.toLowerCase(); + const normalizedName = normalizeLowercaseStringOrEmpty(name); const command = SLASH_COMMANDS.find( (cmd) => cmd.name === normalizedName || - cmd.aliases?.some((alias) => alias.toLowerCase() === normalizedName), + cmd.aliases?.some((alias) => normalizeLowercaseStringOrEmpty(alias) === normalizedName), ); if (!command) { return null; diff --git a/ui/src/ui/connect-error.ts b/ui/src/ui/connect-error.ts index 0dffd77cf91..761e7e172cd 100644 --- a/ui/src/ui/connect-error.ts +++ b/ui/src/ui/connect-error.ts @@ -1,5 +1,6 @@ import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { resolveGatewayErrorDetailCode } from "./gateway.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; type ErrorWithMessageAndDetails = { message?: unknown; @@ -39,7 +40,7 @@ function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): st break; } - const normalized = message.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(message); if ( normalized === "fetch failed" || normalized === "failed to fetch" || diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 3fadb09a97e..4214bac4d49 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -2,6 +2,7 @@ import { resetToolStream } from "../app-tool-stream.ts"; import { extractText } from "../chat/message-extract.ts"; import { formatConnectError } from "../connect-error.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { ChatAttachment } from "../ui-types.ts"; import { generateUUID } from "../uuid.ts"; import { @@ -20,7 +21,7 @@ function isAssistantSilentReply(message: unknown): boolean { return false; } const entry = message as Record; - const role = typeof entry.role === "string" ? entry.role.toLowerCase() : ""; + const role = normalizeLowercaseStringOrEmpty(entry.role); if (role !== "assistant") { return false; } @@ -128,7 +129,7 @@ function normalizeAssistantMessage( const candidate = message as Record; const roleValue = candidate.role; if (typeof roleValue === "string") { - const role = options.roleCaseSensitive ? roleValue : roleValue.toLowerCase(); + const role = options.roleCaseSensitive ? roleValue : normalizeLowercaseStringOrEmpty(roleValue); if (role !== "assistant") { return null; } diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index f38f4c5b06d..6add0841d15 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -2,6 +2,7 @@ import { t } from "../../i18n/index.ts"; import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { CronJob, CronDeliveryStatus, @@ -900,13 +901,13 @@ export function startCronEdit(state: CronState, job: CronJob) { function buildCloneName(name: string, existingNames: Set) { const base = name.trim() || "Job"; const first = `${base} copy`; - if (!existingNames.has(first.toLowerCase())) { + if (!existingNames.has(normalizeLowercaseStringOrEmpty(first))) { return first; } let index = 2; while (index < 1000) { const next = `${base} copy ${index}`; - if (!existingNames.has(next.toLowerCase())) { + if (!existingNames.has(normalizeLowercaseStringOrEmpty(next))) { return next; } index += 1; @@ -917,7 +918,9 @@ function buildCloneName(name: string, existingNames: Set) { export function startCronClone(state: CronState, job: CronJob) { clearCronEditState(state); state.cronRunsJobId = job.id; - const existingNames = new Set(state.cronJobs.map((entry) => entry.name.trim().toLowerCase())); + const existingNames = new Set( + state.cronJobs.map((entry) => normalizeLowercaseStringOrEmpty(entry.name)), + ); const cloned = jobToForm(job, state.cronForm); cloned.name = buildCloneName(job.name, existingNames); state.cronForm = cloned; diff --git a/ui/src/ui/controllers/dreaming.ts b/ui/src/ui/controllers/dreaming.ts index 5f05cc4cb7d..44337d2464a 100644 --- a/ui/src/ui/controllers/dreaming.ts +++ b/ui/src/ui/controllers/dreaming.ts @@ -1,4 +1,5 @@ import type { GatewayBrowserClient } from "../gateway.ts"; +import { normalizeOptionalLowercaseString } from "../string-coerce.ts"; import type { ConfigSnapshot } from "../types.ts"; export type DreamingPhaseId = "light" | "deep" | "rem"; @@ -118,7 +119,7 @@ function normalizeFiniteScore(value: unknown, fallback = 0): number { } function normalizeStorageMode(value: unknown): DreamingStatus["storageMode"] { - const normalized = normalizeTrimmedString(value)?.toLowerCase(); + const normalized = normalizeOptionalLowercaseString(normalizeTrimmedString(value)); if (normalized === "inline" || normalized === "separate" || normalized === "both") { return normalized; } @@ -144,7 +145,7 @@ function resolveDreamingPluginId(configValue: Record | null): s const plugins = asRecord(configValue?.plugins); const slots = asRecord(plugins?.slots); const configuredSlot = normalizeTrimmedString(slots?.memory); - if (configuredSlot && configuredSlot.toLowerCase() !== "none") { + if (configuredSlot && normalizeOptionalLowercaseString(configuredSlot) !== "none") { return configuredSlot; } return DEFAULT_DREAMING_PLUGIN_ID; diff --git a/ui/src/ui/controllers/logs.ts b/ui/src/ui/controllers/logs.ts index 434ccbf7698..99d225e718c 100644 --- a/ui/src/ui/controllers/logs.ts +++ b/ui/src/ui/controllers/logs.ts @@ -1,4 +1,5 @@ import type { GatewayBrowserClient } from "../gateway.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { LogEntry, LogLevel } from "../types.ts"; import { formatMissingOperatorReadScopeMessage, @@ -45,7 +46,7 @@ function normalizeLevel(value: unknown): LogLevel | null { if (typeof value !== "string") { return null; } - const lowered = value.toLowerCase() as LogLevel; + const lowered = normalizeLowercaseStringOrEmpty(value) as LogLevel; return LEVELS.has(lowered) ? lowered : null; } diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 43bf8621066..11e1f78737a 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -1,5 +1,6 @@ import { getSafeLocalStorage } from "../../local-storage.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts"; import type { SessionLogEntry } from "../views/usage.ts"; import { @@ -102,9 +103,9 @@ function normalizeGatewayCompatibilityKey(gatewayUrl?: string): string { try { const parsed = new URL(trimmed); const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase(); + return normalizeLowercaseStringOrEmpty(`${parsed.protocol}//${parsed.host}${pathname}`); } catch { - return trimmed.toLowerCase(); + return normalizeLowercaseStringOrEmpty(trimmed); } } diff --git a/ui/src/ui/external-link.ts b/ui/src/ui/external-link.ts index 0922da638d0..e765d4404d3 100644 --- a/ui/src/ui/external-link.ts +++ b/ui/src/ui/external-link.ts @@ -1,3 +1,5 @@ +import { normalizeOptionalLowercaseString } from "./string-coerce.ts"; + const REQUIRED_EXTERNAL_REL_TOKENS = ["noopener", "noreferrer"] as const; export const EXTERNAL_LINK_TARGET = "_blank"; @@ -7,7 +9,7 @@ export function buildExternalLinkRel(currentRel?: string): string { const seen = new Set(REQUIRED_EXTERNAL_REL_TOKENS); for (const rawToken of (currentRel ?? "").split(/\s+/)) { - const token = rawToken.trim().toLowerCase(); + const token = normalizeOptionalLowercaseString(rawToken); if (!token || seen.has(token)) { continue; } diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index cb6bfce66ce..c3ac57b4a04 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -12,6 +12,7 @@ import { } from "../../../src/gateway/protocol/connect-error-details.js"; import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts"; import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; import { generateUUID } from "./uuid.ts"; export type GatewayEventFrame = { @@ -82,7 +83,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): function isTrustedRetryEndpoint(url: string): boolean { try { const gatewayUrl = new URL(url, window.location.href); - const host = gatewayUrl.hostname.trim().toLowerCase(); + const host = normalizeLowercaseStringOrEmpty(gatewayUrl.hostname); const isLoopbackHost = host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1"; const isLoopbackIPv4 = host.startsWith("127."); diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 6f5c69b2b30..660e51454c7 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -1,6 +1,7 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; import { truncateText } from "./format.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; const allowedTags = [ "a", @@ -116,7 +117,7 @@ function installHooks() { node.setAttribute("rel", "noreferrer noopener"); node.setAttribute("target", "_blank"); - if (href.toLowerCase().includes("tail")) { + if (normalizeLowercaseStringOrEmpty(href).includes("tail")) { node.classList.add(TAIL_LINK_BLUR_CLASS); } }); diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index c97b0259a7b..9f7ba9574ee 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -1,5 +1,6 @@ import { t } from "../i18n/index.ts"; import type { IconName } from "./icons.js"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; export const TAB_GROUPS = [ { label: "chat", tabs: ["chat"] }, @@ -122,7 +123,7 @@ export function tabFromPath(pathname: string, basePath = ""): Tab | null { path = path.slice(base.length); } } - let normalized = normalizePath(path).toLowerCase(); + let normalized = normalizeLowercaseStringOrEmpty(normalizePath(path)); if (normalized.endsWith("/index.html")) { normalized = "/"; } @@ -145,7 +146,7 @@ export function inferBasePathFromPathname(pathname: string): string { return ""; } for (let i = 0; i < segments.length; i++) { - const candidate = `/${segments.slice(i).join("/")}`.toLowerCase(); + const candidate = normalizeLowercaseStringOrEmpty(`/${segments.slice(i).join("/")}`); if (PATH_TO_TAB.has(candidate)) { const prefix = segments.slice(0, i); return prefix.length ? `/${prefix.join("/")}` : ""; diff --git a/ui/src/ui/open-external-url.ts b/ui/src/ui/open-external-url.ts index ed5a99c8678..c2bfa95978d 100644 --- a/ui/src/ui/open-external-url.ts +++ b/ui/src/ui/open-external-url.ts @@ -1,9 +1,11 @@ +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; + const DATA_URL_PREFIX = "data:"; const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "blob:"]); const BLOCKED_DATA_IMAGE_MIME_TYPES = new Set(["image/svg+xml"]); function isAllowedDataImageUrl(url: string): boolean { - if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { + if (!normalizeLowercaseStringOrEmpty(url).startsWith(DATA_URL_PREFIX)) { return false; } @@ -13,7 +15,7 @@ function isAllowedDataImageUrl(url: string): boolean { } const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); - const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; + const mimeType = normalizeLowercaseStringOrEmpty(metadata.split(";")[0]); if (!mimeType.startsWith("image/")) { return false; } @@ -39,13 +41,15 @@ export function resolveSafeExternalUrl( return candidate; } - if (candidate.toLowerCase().startsWith(DATA_URL_PREFIX)) { + if (normalizeLowercaseStringOrEmpty(candidate).startsWith(DATA_URL_PREFIX)) { return null; } try { const parsed = new URL(candidate, baseHref); - return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol.toLowerCase()) ? parsed.toString() : null; + return ALLOWED_EXTERNAL_PROTOCOLS.has(normalizeLowercaseStringOrEmpty(parsed.protocol)) + ? parsed.toString() + : null; } catch { return null; } diff --git a/ui/src/ui/session-key.ts b/ui/src/ui/session-key.ts index 44bdb79343b..d78d297dc38 100644 --- a/ui/src/ui/session-key.ts +++ b/ui/src/ui/session-key.ts @@ -1,3 +1,8 @@ +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "./string-coerce.ts"; + export type ParsedAgentSessionKey = { agentId: string; rest: string; @@ -14,7 +19,7 @@ const TRAILING_DASH_RE = /-+$/; export function parseAgentSessionKey( sessionKey: string | undefined | null, ): ParsedAgentSessionKey | null { - const raw = (sessionKey ?? "").trim().toLowerCase(); + const raw = normalizeLowercaseStringOrEmpty(sessionKey); if (!raw) { return null; } @@ -31,8 +36,7 @@ export function parseAgentSessionKey( } export function normalizeMainKey(value: string | undefined | null): string { - const trimmed = (value ?? "").trim(); - return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_KEY; + return normalizeOptionalLowercaseString(value) ?? DEFAULT_MAIN_KEY; } export function normalizeAgentId(value: string | undefined | null): string { @@ -41,11 +45,10 @@ export function normalizeAgentId(value: string | undefined | null): string { return DEFAULT_AGENT_ID; } if (VALID_ID_RE.test(trimmed)) { - return trimmed.toLowerCase(); + return normalizeLowercaseStringOrEmpty(trimmed); } return ( - trimmed - .toLowerCase() + normalizeLowercaseStringOrEmpty(trimmed) .replace(INVALID_CHARS_RE, "-") .replace(LEADING_DASH_RE, "") .replace(TRAILING_DASH_RE, "") @@ -72,9 +75,9 @@ export function isSubagentSessionKey(sessionKey: string | undefined | null): boo if (!raw) { return false; } - if (raw.toLowerCase().startsWith("subagent:")) { + if (normalizeLowercaseStringOrEmpty(raw).startsWith("subagent:")) { return true; } const parsed = parseAgentSessionKey(raw); - return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:")); + return normalizeLowercaseStringOrEmpty(parsed?.rest).startsWith("subagent:"); } diff --git a/ui/src/ui/string-coerce.ts b/ui/src/ui/string-coerce.ts new file mode 100644 index 00000000000..a43297b14ab --- /dev/null +++ b/ui/src/ui/string-coerce.ts @@ -0,0 +1,15 @@ +export function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeOptionalLowercaseString(value: unknown): string | undefined { + return normalizeOptionalString(value)?.toLowerCase(); +} + +export function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalLowercaseString(value) ?? ""; +} diff --git a/ui/src/ui/thinking.ts b/ui/src/ui/thinking.ts index 3aa12a5a55b..1e16c2bc1d0 100644 --- a/ui/src/ui/thinking.ts +++ b/ui/src/ui/thinking.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; + export type ThinkingCatalogEntry = { provider: string; id: string; @@ -13,7 +15,7 @@ export function normalizeThinkingProviderId(provider?: string | null): string { if (!provider) { return ""; } - const normalized = provider.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(provider); if (normalized === "z.ai" || normalized === "z-ai") { return "zai"; } @@ -31,7 +33,7 @@ export function normalizeThinkLevel(raw?: string | null): string | undefined { if (!raw) { return undefined; } - const key = raw.trim().toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(raw); const collapsed = key.replace(/[\s_-]+/g, ""); if (collapsed === "adaptive" || collapsed === "auto") { return "adaptive"; diff --git a/ui/src/ui/tool-display.ts b/ui/src/ui/tool-display.ts index 2b54d7d7c78..5773b94aa9f 100644 --- a/ui/src/ui/tool-display.ts +++ b/ui/src/ui/tool-display.ts @@ -7,6 +7,7 @@ import { type ToolDisplaySpec as ToolDisplaySpecBase, } from "../../../src/agents/tool-display-common.js"; import type { IconName } from "./icons.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; type ToolDisplaySpec = ToolDisplaySpecBase & { icon?: string; @@ -120,7 +121,7 @@ export function resolveToolDisplay(params: { meta?: string; }): ToolDisplay { const name = normalizeToolName(params.name); - const key = name.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(name); const spec = TOOL_MAP[key]; const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName; const title = spec?.title ?? defaultTitle(name); diff --git a/ui/src/ui/usage-helpers.ts b/ui/src/ui/usage-helpers.ts index 64d73d7f157..42c0686612e 100644 --- a/ui/src/ui/usage-helpers.ts +++ b/ui/src/ui/usage-helpers.ts @@ -51,7 +51,7 @@ const QUERY_KEYS = new Set([ "maxmessages", ]); -const normalizeQueryText = (value: string): string => value.trim().toLowerCase(); +const normalizeQueryText = (value: string): string => normalizeLowercaseStringOrEmpty(value); const globToRegex = (pattern: string): RegExp => { const escaped = pattern @@ -62,7 +62,7 @@ const globToRegex = (pattern: string): RegExp => { }; const parseQueryNumber = (value: string): number | null => { - let raw = value.trim().toLowerCase(); + let raw = normalizeLowercaseStringOrEmpty(value); if (!raw) { return null; } @@ -101,23 +101,25 @@ export const extractQueryTerms = (query: string): UsageQueryTerm[] => { const getSessionText = (session: UsageSessionQueryTarget): string[] => { const items: Array = [session.label, session.key, session.sessionId]; - return items.filter((item): item is string => Boolean(item)).map((item) => item.toLowerCase()); + return items + .filter((item): item is string => Boolean(item)) + .map((item) => normalizeLowercaseStringOrEmpty(item)); }; const getSessionProviders = (session: UsageSessionQueryTarget): string[] => { const providers = new Set(); if (session.modelProvider) { - providers.add(session.modelProvider.toLowerCase()); + providers.add(normalizeLowercaseStringOrEmpty(session.modelProvider)); } if (session.providerOverride) { - providers.add(session.providerOverride.toLowerCase()); + providers.add(normalizeLowercaseStringOrEmpty(session.providerOverride)); } if (session.origin?.provider) { - providers.add(session.origin.provider.toLowerCase()); + providers.add(normalizeLowercaseStringOrEmpty(session.origin.provider)); } for (const entry of session.usage?.modelUsage ?? []) { if (entry.provider) { - providers.add(entry.provider.toLowerCase()); + providers.add(normalizeLowercaseStringOrEmpty(entry.provider)); } } return Array.from(providers); @@ -126,18 +128,18 @@ const getSessionProviders = (session: UsageSessionQueryTarget): string[] => { const getSessionModels = (session: UsageSessionQueryTarget): string[] => { const models = new Set(); if (session.model) { - models.add(session.model.toLowerCase()); + models.add(normalizeLowercaseStringOrEmpty(session.model)); } for (const entry of session.usage?.modelUsage ?? []) { if (entry.model) { - models.add(entry.model.toLowerCase()); + models.add(normalizeLowercaseStringOrEmpty(entry.model)); } } return Array.from(models); }; const getSessionTools = (session: UsageSessionQueryTarget): string[] => - (session.usage?.toolUsage?.tools ?? []).map((tool) => tool.name.toLowerCase()); + (session.usage?.toolUsage?.tools ?? []).map((tool) => normalizeLowercaseStringOrEmpty(tool.name)); const matchesUsageQuery = (session: UsageSessionQueryTarget, term: UsageQueryTerm): boolean => { const value = normalizeQueryText(term.value ?? ""); @@ -151,11 +153,11 @@ const matchesUsageQuery = (session: UsageSessionQueryTarget, term: UsageQueryTer const key = normalizeQueryText(term.key); switch (key) { case "agent": - return session.agentId?.toLowerCase().includes(value) ?? false; + return normalizeLowercaseStringOrEmpty(session.agentId).includes(value); case "channel": - return session.channel?.toLowerCase().includes(value) ?? false; + return normalizeLowercaseStringOrEmpty(session.channel).includes(value); case "chat": - return session.chatType?.toLowerCase().includes(value) ?? false; + return normalizeLowercaseStringOrEmpty(session.chatType).includes(value); case "provider": return getSessionProviders(session).some((provider) => provider.includes(value)); case "model": @@ -163,7 +165,7 @@ const matchesUsageQuery = (session: UsageSessionQueryTarget, term: UsageQueryTer case "tool": return getSessionTools(session).some((tool) => tool.includes(value)); case "label": - return session.label?.toLowerCase().includes(value) ?? false; + return normalizeLowercaseStringOrEmpty(session.label).includes(value); case "key": case "session": case "id": @@ -174,8 +176,8 @@ const matchesUsageQuery = (session: UsageSessionQueryTarget, term: UsageQueryTer ); } return ( - session.key.toLowerCase().includes(value) || - (session.sessionId?.toLowerCase().includes(value) ?? false) + normalizeLowercaseStringOrEmpty(session.key).includes(value) || + normalizeLowercaseStringOrEmpty(session.sessionId).includes(value) ); case "has": switch (value) { @@ -316,3 +318,4 @@ export function parseToolSummary(content: string) { cleanContent: nonToolLines.join("\n").trim(), }; } +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 2d7b8aa9d89..566139953bb 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; import { t } from "../../i18n/index.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { SkillStatusEntry, SkillStatusReport, @@ -416,10 +417,12 @@ export function renderAgentSkills(params: { const usingAllowlist = allowlist !== undefined; const reportReady = Boolean(params.report && params.activeAgentId === params.agentId); const rawSkills = reportReady ? (params.report?.skills ?? []) : []; - const filter = params.filter.trim().toLowerCase(); + const filter = normalizeLowercaseStringOrEmpty(params.filter); const filtered = filter ? rawSkills.filter((skill) => - [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter), + normalizeLowercaseStringOrEmpty( + [skill.name, skill.description, skill.source].join(" "), + ).includes(filter), ) : rawSkills; const groups = groupSkills(filtered); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index e408e9af013..48c364efa08 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -4,6 +4,7 @@ import { normalizeToolName, resolveToolProfilePolicy, } from "../../../../src/agents/tool-policy-shared.js"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { AgentIdentityResult, AgentsFilesListResult, @@ -586,7 +587,7 @@ export function buildModelOptions( const seen = new Set(); const options: ConfiguredModelOption[] = []; const addOption = (value: string, label: string) => { - const key = value.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(value); if (seen.has(key)) { return; } @@ -607,7 +608,7 @@ export function buildModelOptions( } } - if (current && !seen.has(current.toLowerCase())) { + if (current && !seen.has(normalizeLowercaseStringOrEmpty(current))) { options.unshift({ value: current, label: `Current (${current})` }); } diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 2b832ad4b42..ec8154c7e96 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -31,6 +31,7 @@ import { } from "../chat/slash-commands.ts"; import { isSttSupported, startStt, stopStt } from "../chat/speech.ts"; import { icons } from "../icons.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import { detectTextDirection } from "../text-direction.ts"; import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; import type { ChatItem, MessageGroup } from "../types/chat-types.ts"; @@ -495,12 +496,12 @@ function updateSlashMenu(value: string, requestUpdate: () => void): void { // Arg mode: /command const argMatch = value.match(/^\/(\S+)\s(.*)$/); if (argMatch) { - const cmdName = argMatch[1].toLowerCase(); - const argFilter = argMatch[2].toLowerCase(); + const cmdName = normalizeLowercaseStringOrEmpty(argMatch[1]); + const argFilter = normalizeLowercaseStringOrEmpty(argMatch[2]); const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName); if (cmd?.argOptions?.length) { const filtered = argFilter - ? cmd.argOptions.filter((opt) => opt.toLowerCase().startsWith(argFilter)) + ? cmd.argOptions.filter((opt) => normalizeLowercaseStringOrEmpty(opt).startsWith(argFilter)) : cmd.argOptions; if (filtered.length > 0) { vs.slashMenuMode = "args"; @@ -1421,13 +1422,14 @@ function groupMessages(items: ChatItem[]): Array { const normalized = normalizeMessage(item.message); const role = normalizeRoleForGrouping(normalized.role); - const senderLabel = role.toLowerCase() === "user" ? (normalized.senderLabel ?? null) : null; + const senderLabel = + normalizeLowercaseStringOrEmpty(role) === "user" ? (normalized.senderLabel ?? null) : null; const timestamp = normalized.timestamp || Date.now(); if ( !currentGroup || currentGroup.role !== role || - (role.toLowerCase() === "user" && currentGroup.senderLabel !== senderLabel) + (normalizeLowercaseStringOrEmpty(role) === "user" && currentGroup.senderLabel !== senderLabel) ) { if (currentGroup) { result.push(currentGroup); @@ -1486,7 +1488,7 @@ function buildChatItems(props: ChatProps): Array { continue; } - if (!props.showToolCalls && normalized.role.toLowerCase() === "toolresult") { + if (!props.showToolCalls && normalizeLowercaseStringOrEmpty(normalized.role) === "toolresult") { continue; } diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/ui/views/command-palette.ts index 7da44710aea..d514b2a20de 100644 --- a/ui/src/ui/views/command-palette.ts +++ b/ui/src/ui/views/command-palette.ts @@ -3,6 +3,7 @@ import { ref } from "lit/directives/ref.js"; import { t } from "../../i18n/index.ts"; import { SLASH_COMMANDS } from "../chat/slash-commands.ts"; import { icons, type IconName } from "../icons.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; type PaletteItem = { id: string; @@ -97,11 +98,11 @@ function filteredItems(query: string): PaletteItem[] { if (!query) { return PALETTE_ITEMS; } - const q = query.toLowerCase(); + const q = normalizeLowercaseStringOrEmpty(query); return PALETTE_ITEMS.filter( (item) => - item.label.toLowerCase().includes(q) || - (item.description?.toLowerCase().includes(q) ?? false), + normalizeLowercaseStringOrEmpty(item.label).includes(q) || + normalizeLowercaseStringOrEmpty(item.description).includes(q), ); } diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index b92543eaa98..9b0cf4f5021 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -1,6 +1,10 @@ import { html, nothing, type TemplateResult } from "lit"; import { formatUnknownText } from "../format.ts"; import { icons as sharedIcons } from "../icons.ts"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../string-coerce.ts"; import type { ConfigUiHints } from "../types.ts"; import { defaultValue, @@ -218,7 +222,7 @@ export function parseConfigSearchQuery(query: string): ConfigSearchCriteria { const seen = new Set(); const raw = query.trim(); const stripped = raw.replace(/(^|\s)tag:([^\s]+)/gi, (_, leading: string, token: string) => { - const normalized = token.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(token); if (normalized && !seen.has(normalized)) { seen.add(normalized); tags.push(normalized); @@ -226,7 +230,7 @@ export function parseConfigSearchQuery(query: string): ConfigSearchCriteria { return leading; }); return { - text: stripped.trim().toLowerCase(), + text: normalizeLowercaseStringOrEmpty(stripped), tags, }; } @@ -245,7 +249,7 @@ function normalizeTags(raw: unknown): string[] { if (!tag) { continue; } - const key = tag.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(tag); if (seen.has(key)) { continue; } @@ -277,7 +281,7 @@ function matchesText(text: string, candidates: Array): boole return true; } for (const candidate of candidates) { - if (candidate && candidate.toLowerCase().includes(text)) { + if (normalizeOptionalLowercaseString(candidate)?.includes(text)) { return true; } } @@ -288,7 +292,7 @@ function matchesTags(filterTags: string[], fieldTags: string[]): boolean { if (filterTags.length === 0) { return true; } - const normalized = new Set(fieldTags.map((tag) => tag.toLowerCase())); + const normalized = new Set(fieldTags.map((tag) => normalizeLowercaseStringOrEmpty(tag))); return filterTags.every((tag) => normalized.has(tag)); } diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 75e07a14ec1..52e60e3918d 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { icons } from "../icons.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { ConfigUiHints } from "../types.ts"; import { matchesNodeSearch, parseConfigSearchQuery, renderNode } from "./config-form.node.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; @@ -342,9 +343,9 @@ function matchesSearch(params: { const meta = SECTION_META[params.key]; const sectionMetaMatches = q && - (params.key.toLowerCase().includes(q) || - (meta?.label ? meta.label.toLowerCase().includes(q) : false) || - (meta?.description ? meta.description.toLowerCase().includes(q) : false)); + (normalizeLowercaseStringOrEmpty(params.key).includes(q) || + (meta?.label ? normalizeLowercaseStringOrEmpty(meta.label).includes(q) : false) || + (meta?.description ? normalizeLowercaseStringOrEmpty(meta.description).includes(q) : false)); if (sectionMetaMatches && criteria.tags.length === 0) { return true; diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts index b535c49e25f..e3e65477913 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/ui/views/config-form.shared.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { ConfigUiHint, ConfigUiHints } from "../types.ts"; export type JsonSchema = { @@ -125,7 +126,7 @@ function isEnvVarPlaceholder(value: string): boolean { } export function isSensitiveConfigPath(path: string): boolean { - const lowerPath = path.toLowerCase(); + const lowerPath = normalizeLowercaseStringOrEmpty(path); const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix)); return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); } diff --git a/ui/src/ui/views/logs.ts b/ui/src/ui/views/logs.ts index 5c80be479ed..93aaf3b5ff1 100644 --- a/ui/src/ui/views/logs.ts +++ b/ui/src/ui/views/logs.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { LogEntry, LogLevel } from "../types.ts"; const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; @@ -36,15 +37,14 @@ function matchesFilter(entry: LogEntry, needle: string) { if (!needle) { return true; } - const haystack = [entry.message, entry.subsystem, entry.raw] - .filter(Boolean) - .join(" ") - .toLowerCase(); + const haystack = normalizeLowercaseStringOrEmpty( + [entry.message, entry.subsystem, entry.raw].filter(Boolean).join(" "), + ); return haystack.includes(needle); } export function renderLogs(props: LogsProps) { - const needle = props.filterText.trim().toLowerCase(); + const needle = normalizeLowercaseStringOrEmpty(props.filterText); const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]); const filtered = props.entries.filter((entry) => { if (entry.level && !props.levelFilters[entry.level]) { diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index d4599818c48..d0a2e54f5aa 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -1,4 +1,5 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; const AUTH_REQUIRED_CODES = new Set([ ConnectErrorDetailCodes.AUTH_REQUIRED, @@ -40,7 +41,7 @@ export function shouldShowPairingHint( if (lastErrorCode === ConnectErrorDetailCodes.PAIRING_REQUIRED) { return true; } - return lastError.toLowerCase().includes("pairing required"); + return normalizeLowercaseStringOrEmpty(lastError).includes("pairing required"); } /** @@ -66,7 +67,7 @@ export function resolveAuthHintKind(params: { return AUTH_REQUIRED_CODES.has(params.lastErrorCode) ? "required" : "failed"; } - const lower = params.lastError.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(params.lastError); if (!lower.includes("unauthorized")) { return null; } @@ -84,6 +85,6 @@ export function shouldShowInsecureContextHint( if (lastErrorCode) { return INSECURE_CONTEXT_CODES.has(lastErrorCode); } - const lower = lastError.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(lastError); return lower.includes("secure context") || lower.includes("device identity required"); } diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 58a20e475b9..d40735e5662 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -6,6 +6,7 @@ import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { GatewayHelloOk } from "../gateway.ts"; import { icons } from "../icons.ts"; import type { UiSettings } from "../storage.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { AttentionItem, CronJob, @@ -196,7 +197,7 @@ export function renderOverview(props: OverviewProps) { if (props.connected || !props.lastError || !props.warnQueryToken) { return null; } - const lower = props.lastError.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(props.lastError); const authFailed = lower.includes("unauthorized") || lower.includes("device identity required"); if (!authFailed) { return null; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 7b5f73368ba..28f7e4126ec 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -4,6 +4,7 @@ import { formatRelativeTimestamp } from "../format.ts"; import { icons } from "../icons.ts"; import { pathForTab } from "../navigation.ts"; import { formatSessionTokens } from "../presenter.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { GatewaySessionRow, SessionCompactionCheckpoint, @@ -82,7 +83,7 @@ function normalizeProviderId(provider?: string | null): string { if (!provider) { return ""; } - const normalized = provider.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(provider); if (normalized === "z.ai" || normalized === "z-ai") { return "zai"; } @@ -144,15 +145,15 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | } function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] { - const q = query.trim().toLowerCase(); + const q = normalizeLowercaseStringOrEmpty(query); if (!q) { return rows; } return rows.filter((row) => { - const key = (row.key ?? "").toLowerCase(); - const label = (row.label ?? "").toLowerCase(); - const kind = (row.kind ?? "").toLowerCase(); - const displayName = (row.displayName ?? "").toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(row.key); + const label = normalizeLowercaseStringOrEmpty(row.label); + const kind = normalizeLowercaseStringOrEmpty(row.kind); + const displayName = normalizeLowercaseStringOrEmpty(row.displayName); return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q); }); } diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 2724918934f..5e2eb10e560 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -8,6 +8,7 @@ import type { } from "../controllers/skills.ts"; import { clampText } from "../format.ts"; import { resolveSafeExternalUrl } from "../open-external-url.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; import { groupSkills } from "./skills-grouping.ts"; import { @@ -114,10 +115,12 @@ export function renderSkills(props: SkillsProps) { ? skills : skills.filter((s) => skillMatchesStatus(s, props.statusFilter)); - const filter = props.filter.trim().toLowerCase(); + const filter = normalizeLowercaseStringOrEmpty(props.filter); const filtered = filter ? afterStatus.filter((skill) => - [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter), + normalizeLowercaseStringOrEmpty( + [skill.name, skill.description, skill.source].join(" "), + ).includes(filter), ) : afterStatus; const groups = groupSkills(filtered); diff --git a/ui/src/ui/views/usage-query.ts b/ui/src/ui/views/usage-query.ts index 94dc927a564..ab8766256c4 100644 --- a/ui/src/ui/views/usage-query.ts +++ b/ui/src/ui/views/usage-query.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import { extractQueryTerms } from "../usage-helpers.ts"; import { CostDailyEntry, UsageAggregates, UsageSessionEntry } from "./usageTypes.ts"; @@ -138,8 +139,8 @@ const buildQuerySuggestions = ( ? [lastToken.slice(0, lastToken.indexOf(":")), lastToken.slice(lastToken.indexOf(":") + 1)] : ["", ""]; - const key = rawKey.toLowerCase(); - const value = rawValue.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(rawKey); + const value = normalizeLowercaseStringOrEmpty(rawValue); const unique = (items: Array): string[] => { const set = new Set(); @@ -181,7 +182,7 @@ const buildQuerySuggestions = ( const suggestions: QuerySuggestion[] = []; const addValues = (prefix: string, values: string[]) => { for (const val of values) { - if (!value || val.toLowerCase().includes(value)) { + if (!value || normalizeLowercaseStringOrEmpty(val).includes(value)) { suggestions.push({ label: `${prefix}:${val}`, value: `${prefix}:${val}` }); } } @@ -227,7 +228,7 @@ const applySuggestionToQuery = (query: string, suggestion: string): string => { return `${tokens.join(" ")} `; }; -const normalizeQueryText = (value: string): string => value.trim().toLowerCase(); +const normalizeQueryText = (value: string): string => normalizeLowercaseStringOrEmpty(value); const addQueryToken = (query: string, token: string): string => { const trimmed = query.trim();