diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts
index 3fc87913a0e..f0424cb1d3a 100644
--- a/ui/src/ui/app-render.helpers.ts
+++ b/ui/src/ui/app-render.helpers.ts
@@ -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`
-
-
- ${modelSelect} ${thinkingSelect}
-
- `;
+ 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`
-
- `;
-}
-
-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();
- 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`
-
- `;
-}
-
-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 = {
- 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:::direct:) ──
- 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:::group:) ────
- 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();
- for (const row of rows) {
- byKey.set(row.key, row);
- }
-
- const seenKeys = new Set();
- const groups = new Map();
- 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();
- 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();
- 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" },
diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts
new file mode 100644
index 00000000000..8afda9f3aef
--- /dev/null
+++ b/ui/src/ui/chat/session-controls.ts
@@ -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`
+
+
+ ${modelSelect} ${thinkingSelect}
+
+ `;
+}
+
+async function refreshSessionOptions(state: AppViewState) {
+ await loadSessions(state as unknown as Parameters[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`
+
+ `;
+}
+
+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();
+ 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`
+
+ `;
+}
+
+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 = {
+ 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:::direct:.
+ 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:::group:.
+ 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();
+ for (const row of rows) {
+ byKey.set(row.key, row);
+ }
+
+ const seenKeys = new Set();
+ const groups = new Map();
+ 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();
+ 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();
+ 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;
+}
diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts
index 83f038942a1..376d520f957 100644
--- a/ui/src/ui/views/chat.test.ts
+++ b/ui/src/ui/views/chat.test.ts
@@ -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";