mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
perf: split chat session controls
This commit is contained in:
@@ -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" },
|
||||
|
||||
623
ui/src/ui/chat/session-controls.ts
Normal file
623
ui/src/ui/chat/session-controls.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user