|
|
|
|
Quelle app-render.helpers.ts
Sprache: JAVA
|
|
Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
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: -5px;
right: -6px;
background: var(--color-accent, #6366f1);
color: #fff;
border-radius: var(--radius-full);
font-size: 9px;
line-height: 1;
padding: 1px 3px;
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>
`;
}
¤ Dauer der Verarbeitung: 0.20 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|
2026-05-26
|
|
|
|
|