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";