import { html, nothing } from "lit" ;
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 {
isCronSessionKey,
parseSessionKey,
renderChatSessionSelect as renderChatSessionSelectBase,
renderChatThinkingSelect,
resolveSessionDisplayName,
resolveSessionOptionGroups,
} from "./chat/session-controls.ts" ;
import { refreshSlashCommands } from "./chat/slash-commands.ts" ;
import { resolveControlUiAuthToken } from "./control-ui-auth.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 { normalizeOptionalString } from "./string-coerce.ts" ;
import type { ThemeMode } from "./theme.ts" ;
import type { SessionsListResult } from "./types.ts" ;
export { isCronSessionKey, parseSessionKey, resolveSessionDisplayName, resolveSessionOptionGroups };
type SessionDefaultsSnapshot = {
mainSessionKey?: string;
mainKey?: string;
};
type SessionSwitchHost = AppViewState & {
chatStreamStartedAt: number | null ;
chatSideResultTerminalRuns: Set<string>;
resetToolStream(): void ;
resetChatScroll(): void ;
};
type ChatRefreshHost = AppViewState & {
chatManualRefreshInFlight: boolean ;
chatNewMessagesBelow: boolean ;
resetToolStream(): void ;
scrollToBottom(opts?: { smooth?: boolean }): void ;
updateComplete?: Promise<unknown>;
};
export function resolveAssistantAttachmentAuthToken(
state: Pick<AppViewState, "hello" | "settings" | "password" >,
) {
return resolveControlUiAuthToken(state);
}
function resolveSidebarChatSessionKey(state: AppViewState): string {
const snapshot = state.hello?.snapshot as
| { sessionDefaults?: SessionDefaultsSnapshot }
| undefined;
const mainSessionKey = normalizeOptionalString(snapshot?.sessionDefaults?.mainSessionKey);
if (mainSessionKey) {
return mainSessionKey;
}
const mainKey = normalizeOptionalString(snapshot?.sessionDefaults?.mainKey);
if (mainKey) {
return mainKey;
}
return "main" ;
}
function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) {
const host = state as unknown as SessionSwitchHost;
state.sessionKey = sessionKey;
state.chatMessage = "" ;
state.chatAttachments = [];
state.chatMessages = [];
state.chatToolMessages = [];
state.chatStreamSegments = [];
state.chatThinkingLevel = null ;
state.chatStream = null ;
state.chatSideResult = null ;
state.lastError = null ;
state.compactionStatus = null ;
state.fallbackStatus = null ;
state.chatAvatarUrl = null ;
state.chatQueue = [];
host.chatStreamStartedAt = null ;
state.chatRunId = null ;
host.chatSideResultTerminalRuns.clear();
host.resetToolStream();
host.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey,
lastActiveSessionKey: sessionKey,
});
}
export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) {
const href = pathForTab(tab, state.basePath);
const isActive = state.tab === tab;
const collapsed = opts?.collapsed ?? state.settings.navCollapsed;
return html`
<a
href=${href}
class ="nav-item ${isActive ? " nav-item--active" : " "}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return ;
}
event.preventDefault();
if (tab === "chat" ) {
if (!state.sessionKey) {
const mainSessionKey = resolveSidebarChatSessionKey(state);
resetChatStateForSessionSwitch(state, mainSessionKey);
}
if (state.tab !== "chat" ) {
void state.loadAssistantIdentity();
}
}
state.setTab(tab);
}}
title=${titleForTab(tab)}
>
<span class ="nav-item__icon" aria-hidden="true" >${icons[iconForTab(tab)]}</span>
${!collapsed ? html`<span class ="nav-item__text" >${titleForTab(tab)}</span>` : nothing}
</a>
`;
}
function renderCronFilterIcon(hiddenCount: number) {
return html`
<span style="position: relative; display: inline-flex; align-items: center;" >
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" ></circle>
<polyline points="12 6 12 12 16 14" ></polyline>
</svg>
${hiddenCount > 0
? html`<span
style="
position: absolute;
top: -5 px;
right: -6 px;
background: var (--color-accent, #6366 f1);
color: #fff;
border-radius: var (--radius-full);
font-size: 9 px;
line-height: 1 ;
padding: 1 px 3 px;
pointer-events: none;
"
>${hiddenCount}</span
>`
: "" }
</span>
`;
}
export function renderChatSessionSelect(state: AppViewState) {
return renderChatSessionSelectBase(state, switchChatSession);
}
export function renderChatControls(state: AppViewState) {
const hideCron = state.sessionsHideCron ?? true ;
const hiddenCronCount = hideCron
? countHiddenCronSessions(state.sessionKey, state.sessionsResult)
: 0 ;
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const refreshLabel = t("chat.refreshTitle" );
const thinkingLabel = disableThinkingToggle
? t("chat.onboardingDisabled" )
: t("chat.thinkingToggle" );
const toolCallsLabel = disableThinkingToggle
? t("chat.onboardingDisabled" )
: t("chat.toolCallsToggle" );
const focusLabel = disableFocusToggle ? t("chat.onboardingDisabled" ) : t("chat.focusToggle" );
const cronLabel = hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden" , { count: String(hiddenCronCount) })
: t("chat.showCronSessions" )
: t("chat.hideCronSessions" );
const toolCallsIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`;
const refreshIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" ></path>
<path d="M21 3v5h-5" ></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3" ></path>
<path d="M20 7V4h-3" ></path>
<path d="M4 17v3h3" ></path>
<path d="M20 17v3h-3" ></path>
<circle cx="12" cy="12" r="3" ></circle>
</svg>
`;
return html`
<div class ="chat-controls" >
<button
class ="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected}
@click=${async () => {
const app = state as unknown as ChatRefreshHost;
app.chatManualRefreshInFlight = true ;
app.chatNewMessagesBelow = false ;
await app.updateComplete;
app.resetToolStream();
try {
await refreshChat(state as unknown as Parameters<typeof refreshChat>[0 ], {
scheduleScroll: false ,
});
app.scrollToBottom({ smooth: true });
} finally {
requestAnimationFrame(() => {
app.chatManualRefreshInFlight = false ;
app.chatNewMessagesBelow = false ;
});
}
}}
title=${refreshLabel}
aria-label=${refreshLabel}
data-tooltip=${refreshLabel}
>
${refreshIcon}
</button>
<span class ="chat-controls__separator" >|</span>
<button
class ="btn btn--sm btn--icon ${showThinking ? " active" : " "}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return ;
}
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
});
}}
aria-pressed=${showThinking}
title=${thinkingLabel}
aria-label=${thinkingLabel}
data-tooltip=${thinkingLabel}
>
${icons.brain}
</button>
<button
class ="btn btn--sm btn--icon ${showToolCalls ? " active" : " "}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return ;
}
state.applySettings({
...state.settings,
chatShowToolCalls: !state.settings.chatShowToolCalls,
});
}}
aria-pressed=${showToolCalls}
title=${toolCallsLabel}
aria-label=${toolCallsLabel}
data-tooltip=${toolCallsLabel}
>
${toolCallsIcon}
</button>
<button
class ="btn btn--sm btn--icon ${focusActive ? " active" : " "}"
?disabled=${disableFocusToggle}
@click=${() => {
if (disableFocusToggle) {
return ;
}
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
}}
aria-pressed=${focusActive}
title=${focusLabel}
aria-label=${focusLabel}
data-tooltip=${focusLabel}
>
${focusIcon}
</button>
<button
class ="btn btn--sm btn--icon ${hideCron ? " active" : " "}"
@click=${() => {
state.sessionsHideCron = !hideCron;
}}
aria-pressed=${hideCron}
title=${cronLabel}
aria-label=${cronLabel}
data-tooltip=${cronLabel}
>
${renderCronFilterIcon(hiddenCronCount)}
</button>
</div>
`;
}
/**
* Mobile-only gear toggle + dropdown for chat controls.
* Rendered in the topbar so it doesn't consume content-header space.
* Hidden on desktop via CSS.
*/
export function renderChatMobileToggle(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const toolCallsIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3" ></path>
<path d="M20 7V4h-3" ></path>
<path d="M4 17v3h3" ></path>
<path d="M20 17v3h-3" ></path>
<circle cx="12" cy="12" r="3" ></circle>
</svg>
`;
return html`
<div class ="chat-mobile-controls-wrapper" >
<button
class ="btn btn--sm btn--icon chat-controls-mobile-toggle"
@click=${(e: Event) => {
e.stopPropagation();
const btn = e.currentTarget as HTMLElement;
const dropdown = btn.nextElementSibling as HTMLElement;
if (dropdown) {
const isOpen = dropdown.classList.toggle("open" );
if (isOpen) {
const close = () => {
dropdown.classList.remove("open" );
document.removeEventListener("click" , close);
};
setTimeout(() => document.addEventListener("click" , close, { once: true }), 0 );
}
}
}}
title="Chat settings"
aria-label="Chat settings"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" ></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
<div
class ="chat-controls-dropdown"
@click=${(e: Event) => {
e.stopPropagation();
}}
>
<div class ="chat-controls" >
<label class ="field chat-controls__session" >
<select
.value=${state.sessionKey}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
switchChatSession(state, next);
}}
>
${sessionGroups.map(
(group) => html`
<optgroup label=${group.label}>
${group.options.map(
(opt) => html`
<option
value=${opt.key}
title=${opt.title}
?selected=${opt.key === state.sessionKey}
>
${opt.label}
</option>
`,
)}
</optgroup>
`,
)}
</select>
</label>
${renderChatThinkingSelect(state)}
<div class ="chat-controls__thinking" >
<button
class ="btn btn--sm btn--icon ${showThinking ? " active" : " "}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (!disableThinkingToggle) {
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
});
}
}}
aria-pressed=${showThinking}
title=${t("chat.thinkingToggle" )}
>
${icons.brain}
</button>
<button
class ="btn btn--sm btn--icon ${showToolCalls ? " active" : " "}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (!disableThinkingToggle) {
state.applySettings({
...state.settings,
chatShowToolCalls: !state.settings.chatShowToolCalls,
});
}
}}
aria-pressed=${showToolCalls}
title=${t("chat.toolCallsToggle" )}
>
${toolCallsIcon}
</button>
<button
class ="btn btn--sm btn--icon ${focusActive ? " active" : " "}"
?disabled=${disableFocusToggle}
@click=${() => {
if (!disableFocusToggle) {
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
}
}}
aria-pressed=${focusActive}
title=${t("chat.focusToggle" )}
>
${focusIcon}
</button>
</div>
</div>
</div>
</div>
`;
}
export function switchChatSession(state: AppViewState, nextSessionKey: string) {
resetChatStateForSessionSwitch(state, nextSessionKey);
void state.loadAssistantIdentity();
void refreshChatAvatar(state);
void refreshSlashCommands({
client: state.client,
agentId: parseAgentSessionKey(nextSessionKey)?.agentId,
});
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0 ],
nextSessionKey,
true ,
);
void loadChatHistory(state as unknown as ChatState);
void refreshSessionOptions(state);
}
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0 ], {
activeMinutes: 0 ,
limit: 0 ,
includeGlobal: true ,
includeUnknown: true ,
});
}
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResult | null ): number {
if (!sessions?.sessions) {
return 0 ;
}
// Don't count the currently active session even if it's a cron.
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
}
type ThemeModeOption = { id: ThemeMode; label: string; short : string };
const THEME_MODE_OPTIONS: ThemeModeOption[] = [
{ id: "system" , label: "System" , short : "SYS" },
{ id: "light" , label: "Light" , short : "LIGHT" },
{ id: "dark" , label: "Dark" , short : "DARK" },
];
export function renderTopbarThemeModeToggle(state: AppViewState) {
const modeIcon = (mode: ThemeMode) => {
if (mode === "system" ) {
return icons.monitor;
}
if (mode === "light" ) {
return icons.sun;
}
return icons.moon;
};
const applyMode = (mode: ThemeMode, e: Event) => {
if (mode === state.themeMode) {
return ;
}
state.setThemeMode(mode, { element: e.currentTarget as HTMLElement });
};
return html`
<div class ="topbar-theme-mode" role="group" aria-label="Color mode" >
${THEME_MODE_OPTIONS.map(
(opt) => html`
<button
type="button"
class ="topbar-theme-mode__btn ${opt.id === state.themeMode
? "topbar-theme-mode__btn--active"
: "" }"
title=${opt.label}
aria-label="Color mode: ${opt.label}"
aria-pressed=${opt.id === state.themeMode}
@click=${(e: Event) => applyMode(opt.id, e)}
>
${modeIcon(opt.id)}
</button>
`,
)}
</div>
`;
}
export function renderSidebarConnectionStatus(state: AppViewState) {
const label = state.connected ? t("common.online" ) : t("common.offline" );
const toneClass = state.connected
? "sidebar-connection-status--online"
: "sidebar-connection-status--offline" ;
return html`
<span
class ="sidebar-version__status ${toneClass}"
role="img"
aria-live="polite"
aria-label="Gateway status: ${label}"
title="Gateway status: ${label}"
></span>
`;
}
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland