perf: split chat session controls

This commit is contained in:
Peter Steinberger
2026-04-17 16:45:37 +01:00
parent 7b27d08e56
commit 1fad8efa12
3 changed files with 635 additions and 601 deletions

View File

@@ -1,30 +1,28 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../i18n/index.ts";
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import { createChatModelOverride } from "./chat-model-ref.ts";
import {
resolveChatModelOverrideValue,
resolveChatModelSelectState,
} from "./chat-model-select-state.ts";
isCronSessionKey,
parseSessionKey,
renderChatSessionSelect as renderChatSessionSelectBase,
renderChatThinkingSelect,
resolveSessionDisplayName,
resolveSessionOptionGroups,
} from "./chat/session-controls.ts";
import { refreshSlashCommands } from "./chat/slash-commands.ts";
import { refreshVisibleToolsEffectiveForCurrentSession } from "./controllers/agents.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
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, normalizeOptionalString } from "./string-coerce.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import type { ThemeMode } from "./theme.ts";
import {
listThinkingLevelLabels,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "./thinking.ts";
import type { SessionsListResult } from "./types.ts";
export { isCronSessionKey, parseSessionKey, resolveSessionDisplayName, resolveSessionOptionGroups };
type SessionDefaultsSnapshot = {
mainSessionKey?: string;
mainKey?: string;
@@ -174,51 +172,7 @@ function renderCronFilterIcon(hiddenCount: number) {
}
export function renderChatSessionSelect(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
title=${selectedSessionLabel}
?disabled=${!state.connected || sessionGroups.length === 0}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
if (state.sessionKey === next) {
return;
}
switchChatSession(state, next);
}}
>
${repeat(
sessionGroups,
(group) => group.id,
(group) =>
html`<optgroup label=${group.label}>
${repeat(
group.options,
(entry) => entry.key,
(entry) =>
html`<option
value=${entry.key}
title=${entry.title}
?selected=${entry.key === state.sessionKey}
>
${entry.label}
</option>`,
)}
</optgroup>`,
)}
</select>
</label>
${modelSelect} ${thinkingSelect}
</div>
`;
return renderChatSessionSelectBase(state, switchChatSession);
}
export function renderChatControls(state: AppViewState) {
@@ -579,520 +533,6 @@ async function refreshSessionOptions(state: AppViewState) {
});
}
function renderChatModelSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__model">
<select
data-chat-model-select="true"
aria-label="Chat model"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatModel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
type ChatThinkingSelectOption = {
value: string;
label: string;
};
type ChatThinkingSelectState = {
currentOverride: string;
defaultLabel: string;
options: ChatThinkingSelectOption[];
};
function resolveThinkingTargetModel(state: AppViewState): {
provider: string | null;
model: string | null;
} {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
return {
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
};
}
function buildThinkingOptions(
provider: string | null,
model: string | null,
currentOverride: string,
): ChatThinkingSelectOption[] {
const seen = new Set<string>();
const options: ChatThinkingSelectOption[] = [];
const addOption = (value: string, label?: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}
const key = normalizeLowercaseStringOrEmpty(trimmed);
if (seen.has(key)) {
return;
}
seen.add(key);
options.push({
value: trimmed,
label:
label ??
trimmed
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
});
};
for (const label of listThinkingLevelLabels(provider)) {
const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label);
addOption(normalized);
}
if (currentOverride) {
addOption(currentOverride);
}
return options;
}
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
const persisted = activeRow?.thinkingLevel;
const currentOverride =
typeof persisted === "string" && persisted.trim()
? (normalizeThinkLevel(persisted) ?? persisted.trim())
: "";
const { provider, model } = resolveThinkingTargetModel(state);
const defaultLevel =
provider && model
? resolveThinkingDefaultForModel({
provider,
model,
catalog: state.chatModelCatalog ?? [],
})
: "off";
return {
currentOverride,
defaultLabel: `Default (${defaultLevel})`,
options: buildThinkingOptions(provider, model, currentOverride),
};
}
function renderChatThinkingSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled = !state.connected || busy || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__thinking-select">
<select
data-chat-thinking-select="true"
aria-label="Chat thinking level"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
async function switchChatModel(state: AppViewState, nextModel: string) {
if (!state.client || !state.connected) {
return;
}
const currentOverride = resolveChatModelOverrideValue(state);
if (currentOverride === nextModel) {
return;
}
const targetSessionKey = state.sessionKey;
const prevOverride = state.chatModelOverrides[targetSessionKey];
state.lastError = null;
// Write the override cache immediately so the picker stays in sync during the RPC round-trip.
state.chatModelOverrides = {
...state.chatModelOverrides,
[targetSessionKey]: createChatModelOverride(nextModel),
};
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
model: nextModel || null,
});
void refreshVisibleToolsEffectiveForCurrentSession(state);
await refreshSessionOptions(state);
} catch (err) {
// Roll back so the picker reflects the actual server model.
state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride };
state.lastError = `Failed to set model: ${String(err)}`;
}
}
function patchSessionThinkingLevel(
state: AppViewState,
sessionKey: string,
thinkingLevel: string | undefined,
) {
const current = state.sessionsResult;
if (!current) {
return;
}
state.sessionsResult = {
...current,
sessions: current.sessions.map((row) =>
row.key === sessionKey
? {
...row,
thinkingLevel,
}
: row,
),
};
}
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
if (!state.client || !state.connected) {
return;
}
const targetSessionKey = state.sessionKey;
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
const previousThinkingLevel = activeRow?.thinkingLevel;
const normalizedNext =
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
const normalizedPrev =
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
: undefined;
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
return;
}
state.lastError = null;
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
state.chatThinkingLevel = normalizedNext ?? null;
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
thinkingLevel: normalizedNext ?? null,
});
await refreshSessionOptions(state);
} catch (err) {
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
state.chatThinkingLevel = normalizedPrev ?? null;
state.lastError = `Failed to set thinking level: ${String(err)}`;
}
}
/* ── Channel display labels ────────────────────────────── */
const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage",
telegram: "Telegram",
discord: "Discord",
signal: "Signal",
slack: "Slack",
whatsapp: "WhatsApp",
matrix: "Matrix",
email: "Email",
sms: "SMS",
};
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
/** Parsed type / context extracted from a session key. */
export type SessionKeyInfo = {
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
prefix: string;
/** Human-readable fallback when no label / displayName is available. */
fallbackName: string;
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Parse a session key to extract type information and a human-readable
* fallback display name. Exported for testing.
*/
export function parseSessionKey(key: string): SessionKeyInfo {
const normalized = normalizeLowercaseStringOrEmpty(key);
// ── Main session ─────────────────────────────────
if (key === "main" || key === "agent:main:main") {
return { prefix: "", fallbackName: "Main Session" };
}
// ── Subagent ─────────────────────────────────────
if (key.includes(":subagent:")) {
return { prefix: "Subagent:", fallbackName: "Subagent:" };
}
// ── Cron job ─────────────────────────────────────
if (normalized.startsWith("cron:") || key.includes(":cron:")) {
return { prefix: "Cron:", fallbackName: "Cron Job:" };
}
// ── Direct chat (agent:<x>:<channel>:direct:<id>) ──
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
if (directMatch) {
const channel = directMatch[1];
const identifier = directMatch[2];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
}
// ── Group chat (agent:<x>:<channel>:group:<id>) ────
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
if (groupMatch) {
const channel = groupMatch[1];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} Group` };
}
// ── Channel-prefixed legacy keys (e.g. "bluebubbles:g-…") ──
for (const ch of KNOWN_CHANNEL_KEYS) {
if (key === ch || key.startsWith(`${ch}:`)) {
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
}
}
// ── Unknown — return key as-is ───────────────────
return { prefix: "", fallbackName: key };
}
export function resolveSessionDisplayName(
key: string,
row?: SessionsListResult["sessions"][number],
): string {
const label = normalizeOptionalString(row?.label) ?? "";
const displayName = normalizeOptionalString(row?.displayName) ?? "";
const { prefix, fallbackName } = parseSessionKey(key);
const applyTypedPrefix = (name: string): string => {
if (!prefix) {
return name;
}
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
};
if (label && label !== key) {
return applyTypedPrefix(label);
}
if (displayName && displayName !== key) {
return applyTypedPrefix(displayName);
}
return fallbackName;
}
export function isCronSessionKey(key: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(key);
if (!normalized) {
return false;
}
if (normalized.startsWith("cron:")) {
return true;
}
if (!normalized.startsWith("agent:")) {
return false;
}
const parts = normalized.split(":").filter(Boolean);
if (parts.length < 3) {
return false;
}
const rest = parts.slice(2).join(":");
return rest.startsWith("cron:");
}
type SessionOptionEntry = {
key: string;
label: string;
scopeLabel: string;
title: string;
};
type SessionOptionGroup = {
id: string;
label: string;
options: SessionOptionEntry[];
};
export function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string,
sessions: SessionsListResult | null,
): SessionOptionGroup[] {
const rows = sessions?.sessions ?? [];
const hideCron = state.sessionsHideCron ?? true;
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
for (const row of rows) {
byKey.set(row.key, row);
}
const seenKeys = new Set<string>();
const groups = new Map<string, SessionOptionGroup>();
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
const existing = groups.get(groupId);
if (existing) {
return existing;
}
const created: SessionOptionGroup = {
id: groupId,
label,
options: [],
};
groups.set(groupId, created);
return created;
};
const addOption = (key: string) => {
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
const row = byKey.get(key);
const parsed = parseAgentSessionKey(key);
const group = parsed
? ensureGroup(
`agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`,
resolveAgentGroupLabel(state, parsed.agentId),
)
: ensureGroup("other", "Other Sessions");
const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key;
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
group.options.push({
key,
label,
scopeLabel,
title: key,
});
};
for (const row of rows) {
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
continue;
}
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}
addOption(row.key);
}
addOption(sessionKey);
for (const group of groups.values()) {
const counts = new Map<string, number>();
for (const option of group.options) {
counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
}
for (const option of group.options) {
if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) {
option.label = `${option.label} · ${option.scopeLabel}`;
}
}
}
const allOptions = Array.from(groups.values()).flatMap((group) =>
group.options.map((option) => ({ groupLabel: group.label, option })),
);
const labels = new Map(allOptions.map(({ option }) => [option, option.label]));
const countAssignedLabels = () => {
const counts = new Map<string, number>();
for (const { option } of allOptions) {
const label = labels.get(option) ?? option.label;
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
};
const labelIncludesScopeLabel = (label: string, scopeLabel: string) => {
const trimmedScope = scopeLabel.trim();
if (!trimmedScope) {
return false;
}
return (
label === trimmedScope ||
label.endsWith(` · ${trimmedScope}`) ||
label.endsWith(` / ${trimmedScope}`)
);
};
const globalCounts = countAssignedLabels();
for (const { groupLabel, option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((globalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
const scopedPrefix = `${groupLabel} / `;
if (currentLabel.startsWith(scopedPrefix)) {
continue;
}
// Keep the agent visible once the native select collapses to a single chosen label.
labels.set(option, `${groupLabel} / ${currentLabel}`);
}
const scopedCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((scopedCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) {
continue;
}
labels.set(option, `${currentLabel} · ${option.scopeLabel}`);
}
const finalCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((finalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
// Fall back to the full key only when every friendlier disambiguator still collides.
labels.set(option, `${currentLabel} · ${option.key}`);
}
for (const { option } of allOptions) {
option.label = labels.get(option) ?? option.label;
}
return Array.from(groups.values());
}
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResult | null): number {
if (!sessions?.sessions) {
@@ -1102,35 +542,6 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
}
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw);
const agent = (state.agentsList?.agents ?? []).find(
(entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized,
);
const name =
normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? "";
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
}
function resolveSessionScopedOptionLabel(
key: string,
row?: SessionsListResult["sessions"][number],
rest?: string,
) {
const base = normalizeOptionalString(rest) ?? key;
if (!row) {
return base;
}
const label = normalizeOptionalString(row.label) ?? "";
const displayName = normalizeOptionalString(row.displayName) ?? "";
if ((label && label !== key) || (displayName && displayName !== key)) {
return resolveSessionDisplayName(key, row);
}
return base;
}
type ThemeModeOption = { id: ThemeMode; label: string; short: string };
const THEME_MODE_OPTIONS: ThemeModeOption[] = [
{ id: "system", label: "System", short: "SYS" },

View File

@@ -0,0 +1,623 @@
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "../app-view-state.ts";
import { createChatModelOverride } from "../chat-model-ref.ts";
import {
resolveChatModelOverrideValue,
resolveChatModelSelectState,
} from "../chat-model-select-state.ts";
import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts";
import { loadSessions } from "../controllers/sessions.ts";
import { parseAgentSessionKey } from "../session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import {
listThinkingLevelLabels,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "../thinking.ts";
import type { SessionsListResult } from "../types.ts";
type ChatSessionSwitchHandler = (state: AppViewState, nextSessionKey: string) => void;
export function renderChatSessionSelect(
state: AppViewState,
onSwitchSession: ChatSessionSwitchHandler = () => undefined,
) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
title=${selectedSessionLabel}
?disabled=${!state.connected || sessionGroups.length === 0}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
if (state.sessionKey === next) {
return;
}
onSwitchSession(state, next);
}}
>
${repeat(
sessionGroups,
(group) => group.id,
(group) =>
html`<optgroup label=${group.label}>
${repeat(
group.options,
(entry) => entry.key,
(entry) =>
html`<option
value=${entry.key}
title=${entry.title}
?selected=${entry.key === state.sessionKey}
>
${entry.label}
</option>`,
)}
</optgroup>`,
)}
</select>
</label>
${modelSelect} ${thinkingSelect}
</div>
`;
}
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,
includeGlobal: true,
includeUnknown: true,
});
}
function renderChatModelSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__model">
<select
data-chat-model-select="true"
aria-label="Chat model"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatModel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
type ChatThinkingSelectOption = {
value: string;
label: string;
};
type ChatThinkingSelectState = {
currentOverride: string;
defaultLabel: string;
options: ChatThinkingSelectOption[];
};
function resolveThinkingTargetModel(state: AppViewState): {
provider: string | null;
model: string | null;
} {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
return {
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
};
}
function buildThinkingOptions(
provider: string | null,
model: string | null,
currentOverride: string,
): ChatThinkingSelectOption[] {
const seen = new Set<string>();
const options: ChatThinkingSelectOption[] = [];
const addOption = (value: string, label?: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}
const key = normalizeLowercaseStringOrEmpty(trimmed);
if (seen.has(key)) {
return;
}
seen.add(key);
options.push({
value: trimmed,
label:
label ??
trimmed
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
});
};
for (const label of listThinkingLevelLabels(provider)) {
const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label);
addOption(normalized);
}
if (currentOverride) {
addOption(currentOverride);
}
return options;
}
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
const persisted = activeRow?.thinkingLevel;
const currentOverride =
typeof persisted === "string" && persisted.trim()
? (normalizeThinkLevel(persisted) ?? persisted.trim())
: "";
const { provider, model } = resolveThinkingTargetModel(state);
const defaultLevel =
provider && model
? resolveThinkingDefaultForModel({
provider,
model,
catalog: state.chatModelCatalog ?? [],
})
: "off";
return {
currentOverride,
defaultLabel: `Default (${defaultLevel})`,
options: buildThinkingOptions(provider, model, currentOverride),
};
}
export function renderChatThinkingSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled = !state.connected || busy || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__thinking-select">
<select
data-chat-thinking-select="true"
aria-label="Chat thinking level"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
async function switchChatModel(state: AppViewState, nextModel: string) {
if (!state.client || !state.connected) {
return;
}
const currentOverride = resolveChatModelOverrideValue(state);
if (currentOverride === nextModel) {
return;
}
const targetSessionKey = state.sessionKey;
const prevOverride = state.chatModelOverrides[targetSessionKey];
state.lastError = null;
// Write the override cache immediately so the picker stays in sync during the RPC round-trip.
state.chatModelOverrides = {
...state.chatModelOverrides,
[targetSessionKey]: createChatModelOverride(nextModel),
};
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
model: nextModel || null,
});
void refreshVisibleToolsEffectiveForCurrentSession(state);
await refreshSessionOptions(state);
} catch (err) {
// Roll back so the picker reflects the actual server model.
state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride };
state.lastError = `Failed to set model: ${String(err)}`;
}
}
function patchSessionThinkingLevel(
state: AppViewState,
sessionKey: string,
thinkingLevel: string | undefined,
) {
const current = state.sessionsResult;
if (!current) {
return;
}
state.sessionsResult = {
...current,
sessions: current.sessions.map((row) =>
row.key === sessionKey
? {
...row,
thinkingLevel,
}
: row,
),
};
}
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
if (!state.client || !state.connected) {
return;
}
const targetSessionKey = state.sessionKey;
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
const previousThinkingLevel = activeRow?.thinkingLevel;
const normalizedNext =
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
const normalizedPrev =
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
: undefined;
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
return;
}
state.lastError = null;
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
state.chatThinkingLevel = normalizedNext ?? null;
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
thinkingLevel: normalizedNext ?? null,
});
await refreshSessionOptions(state);
} catch (err) {
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
state.chatThinkingLevel = normalizedPrev ?? null;
state.lastError = `Failed to set thinking level: ${String(err)}`;
}
}
/* Channel display labels. */
const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage",
telegram: "Telegram",
discord: "Discord",
signal: "Signal",
slack: "Slack",
whatsapp: "WhatsApp",
matrix: "Matrix",
email: "Email",
sms: "SMS",
};
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
/** Parsed type / context extracted from a session key. */
export type SessionKeyInfo = {
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
prefix: string;
/** Human-readable fallback when no label / displayName is available. */
fallbackName: string;
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Parse a session key to extract type information and a human-readable
* fallback display name. Exported for testing.
*/
export function parseSessionKey(key: string): SessionKeyInfo {
const normalized = normalizeLowercaseStringOrEmpty(key);
// Main session.
if (key === "main" || key === "agent:main:main") {
return { prefix: "", fallbackName: "Main Session" };
}
// Subagent.
if (key.includes(":subagent:")) {
return { prefix: "Subagent:", fallbackName: "Subagent:" };
}
// Cron job.
if (normalized.startsWith("cron:") || key.includes(":cron:")) {
return { prefix: "Cron:", fallbackName: "Cron Job:" };
}
// Direct chat: agent:<x>:<channel>:direct:<id>.
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
if (directMatch) {
const channel = directMatch[1];
const identifier = directMatch[2];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
}
// Group chat: agent:<x>:<channel>:group:<id>.
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
if (groupMatch) {
const channel = groupMatch[1];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} Group` };
}
// Channel-prefixed legacy keys, for example "bluebubbles:g-...".
for (const ch of KNOWN_CHANNEL_KEYS) {
if (key === ch || key.startsWith(`${ch}:`)) {
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
}
}
// Unknown: return key as-is.
return { prefix: "", fallbackName: key };
}
export function resolveSessionDisplayName(
key: string,
row?: SessionsListResult["sessions"][number],
): string {
const label = normalizeOptionalString(row?.label) ?? "";
const displayName = normalizeOptionalString(row?.displayName) ?? "";
const { prefix, fallbackName } = parseSessionKey(key);
const applyTypedPrefix = (name: string): string => {
if (!prefix) {
return name;
}
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
};
if (label && label !== key) {
return applyTypedPrefix(label);
}
if (displayName && displayName !== key) {
return applyTypedPrefix(displayName);
}
return fallbackName;
}
export function isCronSessionKey(key: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(key);
if (!normalized) {
return false;
}
if (normalized.startsWith("cron:")) {
return true;
}
if (!normalized.startsWith("agent:")) {
return false;
}
const parts = normalized.split(":").filter(Boolean);
if (parts.length < 3) {
return false;
}
const rest = parts.slice(2).join(":");
return rest.startsWith("cron:");
}
type SessionOptionEntry = {
key: string;
label: string;
scopeLabel: string;
title: string;
};
export type SessionOptionGroup = {
id: string;
label: string;
options: SessionOptionEntry[];
};
export function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string,
sessions: SessionsListResult | null,
): SessionOptionGroup[] {
const rows = sessions?.sessions ?? [];
const hideCron = state.sessionsHideCron ?? true;
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
for (const row of rows) {
byKey.set(row.key, row);
}
const seenKeys = new Set<string>();
const groups = new Map<string, SessionOptionGroup>();
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
const existing = groups.get(groupId);
if (existing) {
return existing;
}
const created: SessionOptionGroup = {
id: groupId,
label,
options: [],
};
groups.set(groupId, created);
return created;
};
const addOption = (key: string) => {
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
const row = byKey.get(key);
const parsed = parseAgentSessionKey(key);
const group = parsed
? ensureGroup(
`agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`,
resolveAgentGroupLabel(state, parsed.agentId),
)
: ensureGroup("other", "Other Sessions");
const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key;
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
group.options.push({
key,
label,
scopeLabel,
title: key,
});
};
for (const row of rows) {
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
continue;
}
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}
addOption(row.key);
}
addOption(sessionKey);
for (const group of groups.values()) {
const counts = new Map<string, number>();
for (const option of group.options) {
counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
}
for (const option of group.options) {
if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) {
option.label = `${option.label} · ${option.scopeLabel}`;
}
}
}
const allOptions = Array.from(groups.values()).flatMap((group) =>
group.options.map((option) => ({ groupLabel: group.label, option })),
);
const labels = new Map(allOptions.map(({ option }) => [option, option.label]));
const countAssignedLabels = () => {
const counts = new Map<string, number>();
for (const { option } of allOptions) {
const label = labels.get(option) ?? option.label;
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
};
const labelIncludesScopeLabel = (label: string, scopeLabel: string) => {
const trimmedScope = scopeLabel.trim();
if (!trimmedScope) {
return false;
}
return (
label === trimmedScope ||
label.endsWith(` · ${trimmedScope}`) ||
label.endsWith(` / ${trimmedScope}`)
);
};
const globalCounts = countAssignedLabels();
for (const { groupLabel, option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((globalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
const scopedPrefix = `${groupLabel} / `;
if (currentLabel.startsWith(scopedPrefix)) {
continue;
}
// Keep the agent visible once the native select collapses to a single chosen label.
labels.set(option, `${groupLabel} / ${currentLabel}`);
}
const scopedCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((scopedCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) {
continue;
}
labels.set(option, `${currentLabel} · ${option.scopeLabel}`);
}
const finalCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((finalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
// Fall back to the full key only when every friendlier disambiguator still collides.
labels.set(option, `${currentLabel} · ${option.key}`);
}
for (const { option } of allOptions) {
option.label = labels.get(option) ?? option.label;
}
return Array.from(groups.values());
}
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw);
const agent = (state.agentsList?.agents ?? []).find(
(entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized,
);
const name =
normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? "";
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
}
function resolveSessionScopedOptionLabel(
key: string,
row?: SessionsListResult["sessions"][number],
rest?: string,
) {
const base = normalizeOptionalString(rest) ?? key;
if (!row) {
return base;
}
const label = normalizeOptionalString(row.label) ?? "";
const displayName = normalizeOptionalString(row.displayName) ?? "";
if ((label && label !== key) || (displayName && displayName !== key)) {
return resolveSessionDisplayName(key, row);
}
return base;
}

View File

@@ -3,7 +3,6 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { getSafeLocalStorage } from "../../local-storage.ts";
import { renderChatSessionSelect } from "../app-render.helpers.ts";
import type { AppViewState } from "../app-view-state.ts";
import {
createModelCatalog,
@@ -12,6 +11,7 @@ import {
} from "../chat-model.test-helpers.ts";
import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts";
import { normalizeMessage } from "../chat/message-normalizer.ts";
import { renderChatSessionSelect } from "../chat/session-controls.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
import type { SessionsListResult } from "../types.ts";