mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 00:31:22 +00:00
- Session names in the Sessions table are now clickable links - Clicking navigates to /chat?session=<key> with that session loaded - Global sessions excluded (not real conversations) - Added .session-link CSS styling (accent color, underline on hover) - Chat page reads 'session' query param and cleans URL after applying
669 lines
23 KiB
TypeScript
669 lines
23 KiB
TypeScript
import { html, nothing } from "lit";
|
|
|
|
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
|
import {
|
|
TAB_GROUPS,
|
|
pathForTab,
|
|
subtitleForTab,
|
|
titleForTab,
|
|
type Tab,
|
|
} from "./navigation";
|
|
import type { UiSettings } from "./storage";
|
|
import type { ThemeMode } from "./theme";
|
|
import type { ThemeTransitionContext } from "./theme-transition";
|
|
import type {
|
|
ConfigSnapshot,
|
|
CronJob,
|
|
CronRunLogEntry,
|
|
CronStatus,
|
|
HealthSnapshot,
|
|
LogEntry,
|
|
LogLevel,
|
|
PresenceEntry,
|
|
ProvidersStatusSnapshot,
|
|
SessionsListResult,
|
|
SkillStatusReport,
|
|
StatusSummary,
|
|
} from "./types";
|
|
import type {
|
|
ChatQueueItem,
|
|
CronFormState,
|
|
DiscordForm,
|
|
IMessageForm,
|
|
SlackForm,
|
|
SignalForm,
|
|
TelegramForm,
|
|
} from "./ui-types";
|
|
import { renderChat } from "./views/chat";
|
|
import { renderConfig } from "./views/config";
|
|
import { renderConnections } from "./views/connections";
|
|
import { renderCron } from "./views/cron";
|
|
import { renderDebug } from "./views/debug";
|
|
import { renderInstances } from "./views/instances";
|
|
import { renderLogs } from "./views/logs";
|
|
import { renderNodes } from "./views/nodes";
|
|
import { renderOverview } from "./views/overview";
|
|
import { renderSessions } from "./views/sessions";
|
|
import { renderSkills } from "./views/skills";
|
|
import {
|
|
loadProviders,
|
|
updateDiscordForm,
|
|
updateIMessageForm,
|
|
updateSlackForm,
|
|
updateSignalForm,
|
|
updateTelegramForm,
|
|
} from "./controllers/connections";
|
|
import { loadPresence } from "./controllers/presence";
|
|
import { loadSessions, patchSession } from "./controllers/sessions";
|
|
import {
|
|
installSkill,
|
|
loadSkills,
|
|
saveSkillApiKey,
|
|
updateSkillEdit,
|
|
updateSkillEnabled,
|
|
type SkillMessage,
|
|
} from "./controllers/skills";
|
|
import { loadNodes } from "./controllers/nodes";
|
|
import { loadChatHistory } from "./controllers/chat";
|
|
import {
|
|
applyConfig,
|
|
loadConfig,
|
|
runUpdate,
|
|
saveConfig,
|
|
updateConfigFormValue,
|
|
} from "./controllers/config";
|
|
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
|
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
|
import { loadLogs } from "./controllers/logs";
|
|
|
|
export type EventLogEntry = {
|
|
ts: number;
|
|
event: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
export type AppViewState = {
|
|
settings: UiSettings;
|
|
password: string;
|
|
tab: Tab;
|
|
basePath: string;
|
|
connected: boolean;
|
|
theme: ThemeMode;
|
|
themeResolved: "light" | "dark";
|
|
hello: GatewayHelloOk | null;
|
|
lastError: string | null;
|
|
eventLog: EventLogEntry[];
|
|
sessionKey: string;
|
|
chatLoading: boolean;
|
|
chatSending: boolean;
|
|
chatMessage: string;
|
|
chatMessages: unknown[];
|
|
chatToolMessages: unknown[];
|
|
chatStream: string | null;
|
|
chatRunId: string | null;
|
|
chatThinkingLevel: string | null;
|
|
chatQueue: ChatQueueItem[];
|
|
nodesLoading: boolean;
|
|
nodes: Array<Record<string, unknown>>;
|
|
configLoading: boolean;
|
|
configRaw: string;
|
|
configValid: boolean | null;
|
|
configIssues: unknown[];
|
|
configSaving: boolean;
|
|
configApplying: boolean;
|
|
updateRunning: boolean;
|
|
configSnapshot: ConfigSnapshot | null;
|
|
configSchema: unknown | null;
|
|
configSchemaLoading: boolean;
|
|
configUiHints: Record<string, unknown>;
|
|
configForm: Record<string, unknown> | null;
|
|
configFormMode: "form" | "raw";
|
|
providersLoading: boolean;
|
|
providersSnapshot: ProvidersStatusSnapshot | null;
|
|
providersError: string | null;
|
|
providersLastSuccess: number | null;
|
|
whatsappLoginMessage: string | null;
|
|
whatsappLoginQrDataUrl: string | null;
|
|
whatsappLoginConnected: boolean | null;
|
|
whatsappBusy: boolean;
|
|
telegramForm: TelegramForm;
|
|
telegramSaving: boolean;
|
|
telegramTokenLocked: boolean;
|
|
telegramConfigStatus: string | null;
|
|
discordForm: DiscordForm;
|
|
discordSaving: boolean;
|
|
discordTokenLocked: boolean;
|
|
discordConfigStatus: string | null;
|
|
slackForm: SlackForm;
|
|
slackSaving: boolean;
|
|
slackTokenLocked: boolean;
|
|
slackAppTokenLocked: boolean;
|
|
slackConfigStatus: string | null;
|
|
signalForm: SignalForm;
|
|
signalSaving: boolean;
|
|
signalConfigStatus: string | null;
|
|
imessageForm: IMessageForm;
|
|
imessageSaving: boolean;
|
|
imessageConfigStatus: string | null;
|
|
presenceLoading: boolean;
|
|
presenceEntries: PresenceEntry[];
|
|
presenceError: string | null;
|
|
presenceStatus: string | null;
|
|
sessionsLoading: boolean;
|
|
sessionsResult: SessionsListResult | null;
|
|
sessionsError: string | null;
|
|
sessionsFilterActive: string;
|
|
sessionsFilterLimit: string;
|
|
sessionsIncludeGlobal: boolean;
|
|
sessionsIncludeUnknown: boolean;
|
|
cronLoading: boolean;
|
|
cronJobs: CronJob[];
|
|
cronStatus: CronStatus | null;
|
|
cronError: string | null;
|
|
cronForm: CronFormState;
|
|
cronRunsJobId: string | null;
|
|
cronRuns: CronRunLogEntry[];
|
|
cronBusy: boolean;
|
|
skillsLoading: boolean;
|
|
skillsReport: SkillStatusReport | null;
|
|
skillsError: string | null;
|
|
skillsFilter: string;
|
|
skillEdits: Record<string, string>;
|
|
skillMessages: Record<string, SkillMessage>;
|
|
skillsBusyKey: string | null;
|
|
debugLoading: boolean;
|
|
debugStatus: StatusSummary | null;
|
|
debugHealth: HealthSnapshot | null;
|
|
debugModels: unknown[];
|
|
debugHeartbeat: unknown | null;
|
|
debugCallMethod: string;
|
|
debugCallParams: string;
|
|
debugCallResult: string | null;
|
|
debugCallError: string | null;
|
|
logsLoading: boolean;
|
|
logsError: string | null;
|
|
logsFile: string | null;
|
|
logsEntries: LogEntry[];
|
|
logsFilterText: string;
|
|
logsLevelFilters: Record<LogLevel, boolean>;
|
|
logsAutoFollow: boolean;
|
|
logsTruncated: boolean;
|
|
client: GatewayBrowserClient | null;
|
|
connect: () => void;
|
|
setTab: (tab: Tab) => void;
|
|
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
|
applySettings: (next: UiSettings) => void;
|
|
loadOverview: () => Promise<void>;
|
|
loadCron: () => Promise<void>;
|
|
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
|
handleWhatsAppWait: () => Promise<void>;
|
|
handleWhatsAppLogout: () => Promise<void>;
|
|
handleTelegramSave: () => Promise<void>;
|
|
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
|
removeQueuedMessage: (id: string) => void;
|
|
resetToolStream: () => void;
|
|
handleLogsScroll: (event: Event) => void;
|
|
exportLogs: (lines: string[], label: string) => void;
|
|
};
|
|
|
|
export function renderApp(state: AppViewState) {
|
|
const presenceCount = state.presenceEntries.length;
|
|
const sessionsCount = state.sessionsResult?.count ?? null;
|
|
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
|
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
|
|
const isChat = state.tab === "chat";
|
|
const chatFocus = isChat && state.settings.chatFocusMode;
|
|
|
|
return html`
|
|
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""}">
|
|
<header class="topbar">
|
|
<div class="brand">
|
|
<div class="brand-title">Clawdbot Control</div>
|
|
<div class="brand-sub">Gateway dashboard</div>
|
|
</div>
|
|
<div class="topbar-status">
|
|
<div class="pill">
|
|
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
|
|
<span>Health</span>
|
|
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
|
</div>
|
|
${isChat
|
|
? renderChatFocusToggle(
|
|
state.settings.chatFocusMode,
|
|
() =>
|
|
state.applySettings({
|
|
...state.settings,
|
|
chatFocusMode: !state.settings.chatFocusMode,
|
|
}),
|
|
)
|
|
: nothing}
|
|
${renderThemeToggle(state)}
|
|
</div>
|
|
</header>
|
|
<aside class="nav">
|
|
${TAB_GROUPS.map(
|
|
(group) => html`
|
|
<div class="nav-group">
|
|
<div class="nav-label">${group.label}</div>
|
|
${group.tabs.map((tab) => renderTab(state, tab))}
|
|
</div>
|
|
`,
|
|
)}
|
|
</aside>
|
|
<main class="content ${isChat ? "content--chat" : ""}">
|
|
<section class="content-header">
|
|
<div>
|
|
<div class="page-title">${titleForTab(state.tab)}</div>
|
|
<div class="page-sub">${subtitleForTab(state.tab)}</div>
|
|
</div>
|
|
<div class="page-meta">
|
|
${state.lastError
|
|
? html`<div class="pill danger">${state.lastError}</div>`
|
|
: nothing}
|
|
</div>
|
|
</section>
|
|
|
|
${state.tab === "overview"
|
|
? renderOverview({
|
|
connected: state.connected,
|
|
hello: state.hello,
|
|
settings: state.settings,
|
|
password: state.password,
|
|
lastError: state.lastError,
|
|
presenceCount,
|
|
sessionsCount,
|
|
cronEnabled: state.cronStatus?.enabled ?? null,
|
|
cronNext,
|
|
lastProvidersRefresh: state.providersLastSuccess,
|
|
onSettingsChange: (next) => state.applySettings(next),
|
|
onPasswordChange: (next) => (state.password = next),
|
|
onSessionKeyChange: (next) => {
|
|
state.sessionKey = next;
|
|
state.chatMessage = "";
|
|
state.resetToolStream();
|
|
state.applySettings({
|
|
...state.settings,
|
|
sessionKey: next,
|
|
lastActiveSessionKey: next,
|
|
});
|
|
},
|
|
onConnect: () => state.connect(),
|
|
onRefresh: () => state.loadOverview(),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "connections"
|
|
? renderConnections({
|
|
connected: state.connected,
|
|
loading: state.providersLoading,
|
|
snapshot: state.providersSnapshot,
|
|
lastError: state.providersError,
|
|
lastSuccessAt: state.providersLastSuccess,
|
|
whatsappMessage: state.whatsappLoginMessage,
|
|
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
|
whatsappConnected: state.whatsappLoginConnected,
|
|
whatsappBusy: state.whatsappBusy,
|
|
telegramForm: state.telegramForm,
|
|
telegramTokenLocked: state.telegramTokenLocked,
|
|
telegramSaving: state.telegramSaving,
|
|
telegramStatus: state.telegramConfigStatus,
|
|
discordForm: state.discordForm,
|
|
discordTokenLocked: state.discordTokenLocked,
|
|
discordSaving: state.discordSaving,
|
|
discordStatus: state.discordConfigStatus,
|
|
slackForm: state.slackForm,
|
|
slackTokenLocked: state.slackTokenLocked,
|
|
slackAppTokenLocked: state.slackAppTokenLocked,
|
|
slackSaving: state.slackSaving,
|
|
slackStatus: state.slackConfigStatus,
|
|
signalForm: state.signalForm,
|
|
signalSaving: state.signalSaving,
|
|
signalStatus: state.signalConfigStatus,
|
|
imessageForm: state.imessageForm,
|
|
imessageSaving: state.imessageSaving,
|
|
imessageStatus: state.imessageConfigStatus,
|
|
onRefresh: (probe) => loadProviders(state, probe),
|
|
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
|
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
|
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
|
onTelegramChange: (patch) => updateTelegramForm(state, patch),
|
|
onTelegramSave: () => state.handleTelegramSave(),
|
|
onDiscordChange: (patch) => updateDiscordForm(state, patch),
|
|
onDiscordSave: () => state.handleDiscordSave(),
|
|
onSlackChange: (patch) => updateSlackForm(state, patch),
|
|
onSlackSave: () => state.handleSlackSave(),
|
|
onSignalChange: (patch) => updateSignalForm(state, patch),
|
|
onSignalSave: () => state.handleSignalSave(),
|
|
onIMessageChange: (patch) => updateIMessageForm(state, patch),
|
|
onIMessageSave: () => state.handleIMessageSave(),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "instances"
|
|
? renderInstances({
|
|
loading: state.presenceLoading,
|
|
entries: state.presenceEntries,
|
|
lastError: state.presenceError,
|
|
statusMessage: state.presenceStatus,
|
|
onRefresh: () => loadPresence(state),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "sessions"
|
|
? renderSessions({
|
|
loading: state.sessionsLoading,
|
|
result: state.sessionsResult,
|
|
error: state.sessionsError,
|
|
activeMinutes: state.sessionsFilterActive,
|
|
limit: state.sessionsFilterLimit,
|
|
includeGlobal: state.sessionsIncludeGlobal,
|
|
includeUnknown: state.sessionsIncludeUnknown,
|
|
basePath: state.basePath,
|
|
onFiltersChange: (next) => {
|
|
state.sessionsFilterActive = next.activeMinutes;
|
|
state.sessionsFilterLimit = next.limit;
|
|
state.sessionsIncludeGlobal = next.includeGlobal;
|
|
state.sessionsIncludeUnknown = next.includeUnknown;
|
|
},
|
|
onRefresh: () => loadSessions(state),
|
|
onPatch: (key, patch) => patchSession(state, key, patch),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "cron"
|
|
? renderCron({
|
|
loading: state.cronLoading,
|
|
status: state.cronStatus,
|
|
jobs: state.cronJobs,
|
|
error: state.cronError,
|
|
busy: state.cronBusy,
|
|
form: state.cronForm,
|
|
runsJobId: state.cronRunsJobId,
|
|
runs: state.cronRuns,
|
|
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
|
onRefresh: () => state.loadCron(),
|
|
onAdd: () => addCronJob(state),
|
|
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
|
onRun: (job) => runCronJob(state, job),
|
|
onRemove: (job) => removeCronJob(state, job),
|
|
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "skills"
|
|
? renderSkills({
|
|
loading: state.skillsLoading,
|
|
report: state.skillsReport,
|
|
error: state.skillsError,
|
|
filter: state.skillsFilter,
|
|
edits: state.skillEdits,
|
|
messages: state.skillMessages,
|
|
busyKey: state.skillsBusyKey,
|
|
onFilterChange: (next) => (state.skillsFilter = next),
|
|
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
|
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
|
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
|
onSaveKey: (key) => saveSkillApiKey(state, key),
|
|
onInstall: (skillKey, name, installId) =>
|
|
installSkill(state, skillKey, name, installId),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "nodes"
|
|
? renderNodes({
|
|
loading: state.nodesLoading,
|
|
nodes: state.nodes,
|
|
onRefresh: () => loadNodes(state),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "chat"
|
|
? renderChat({
|
|
sessionKey: state.sessionKey,
|
|
onSessionKeyChange: (next) => {
|
|
state.sessionKey = next;
|
|
state.chatMessage = "";
|
|
state.chatStream = null;
|
|
state.chatStreamStartedAt = null;
|
|
state.chatRunId = null;
|
|
state.chatQueue = [];
|
|
state.resetToolStream();
|
|
state.resetChatScroll();
|
|
state.applySettings({
|
|
...state.settings,
|
|
sessionKey: next,
|
|
lastActiveSessionKey: next,
|
|
});
|
|
void loadChatHistory(state);
|
|
},
|
|
thinkingLevel: state.chatThinkingLevel,
|
|
loading: state.chatLoading,
|
|
sending: state.chatSending,
|
|
messages: state.chatMessages,
|
|
toolMessages: state.chatToolMessages,
|
|
stream: state.chatStream,
|
|
streamStartedAt: state.chatStreamStartedAt,
|
|
draft: state.chatMessage,
|
|
queue: state.chatQueue,
|
|
connected: state.connected,
|
|
canSend: state.connected,
|
|
disabledReason: chatDisabledReason,
|
|
error: state.lastError,
|
|
sessions: state.sessionsResult,
|
|
isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id),
|
|
onToolOutputToggle: (id, expanded) =>
|
|
state.toggleToolOutput(id, expanded),
|
|
onRefresh: () => {
|
|
state.resetToolStream();
|
|
return loadChatHistory(state);
|
|
},
|
|
onDraftChange: (next) => (state.chatMessage = next),
|
|
onSend: () => state.handleSendChat(),
|
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
|
onNewSession: () =>
|
|
state.handleSendChat("/new", { restoreDraft: true }),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "config"
|
|
? renderConfig({
|
|
raw: state.configRaw,
|
|
valid: state.configValid,
|
|
issues: state.configIssues,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
applying: state.configApplying,
|
|
updating: state.updateRunning,
|
|
connected: state.connected,
|
|
schema: state.configSchema,
|
|
schemaLoading: state.configSchemaLoading,
|
|
uiHints: state.configUiHints,
|
|
formMode: state.configFormMode,
|
|
formValue: state.configForm,
|
|
onRawChange: (next) => (state.configRaw = next),
|
|
onFormModeChange: (mode) => (state.configFormMode = mode),
|
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onReload: () => loadConfig(state),
|
|
onSave: () => saveConfig(state),
|
|
onApply: () => applyConfig(state),
|
|
onUpdate: () => runUpdate(state),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "debug"
|
|
? renderDebug({
|
|
loading: state.debugLoading,
|
|
status: state.debugStatus,
|
|
health: state.debugHealth,
|
|
models: state.debugModels,
|
|
heartbeat: state.debugHeartbeat,
|
|
eventLog: state.eventLog,
|
|
callMethod: state.debugCallMethod,
|
|
callParams: state.debugCallParams,
|
|
callResult: state.debugCallResult,
|
|
callError: state.debugCallError,
|
|
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
|
onCallParamsChange: (next) => (state.debugCallParams = next),
|
|
onRefresh: () => loadDebug(state),
|
|
onCall: () => callDebugMethod(state),
|
|
})
|
|
: nothing}
|
|
|
|
${state.tab === "logs"
|
|
? renderLogs({
|
|
loading: state.logsLoading,
|
|
error: state.logsError,
|
|
file: state.logsFile,
|
|
entries: state.logsEntries,
|
|
filterText: state.logsFilterText,
|
|
levelFilters: state.logsLevelFilters,
|
|
autoFollow: state.logsAutoFollow,
|
|
truncated: state.logsTruncated,
|
|
onFilterTextChange: (next) => (state.logsFilterText = next),
|
|
onLevelToggle: (level, enabled) => {
|
|
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
|
},
|
|
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
|
onRefresh: () => loadLogs(state, { reset: true }),
|
|
onExport: (lines, label) => state.exportLogs(lines, label),
|
|
onScroll: (event) => state.handleLogsScroll(event),
|
|
})
|
|
: nothing}
|
|
</main>
|
|
<a
|
|
class="docs-link"
|
|
href="https://docs.clawd.bot"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Docs
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTab(state: AppViewState, tab: Tab) {
|
|
const href = pathForTab(tab, state.basePath);
|
|
return html`
|
|
<a
|
|
href=${href}
|
|
class="nav-item ${state.tab === tab ? "active" : ""}"
|
|
@click=${(event: MouseEvent) => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.button !== 0 ||
|
|
event.metaKey ||
|
|
event.ctrlKey ||
|
|
event.shiftKey ||
|
|
event.altKey
|
|
) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
state.setTab(tab);
|
|
}}
|
|
>
|
|
<span>${titleForTab(tab)}</span>
|
|
</a>
|
|
`;
|
|
}
|
|
|
|
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
|
|
|
|
function renderThemeToggle(state: AppViewState) {
|
|
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
|
|
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
|
const element = event.currentTarget as HTMLElement;
|
|
const context: ThemeTransitionContext = { element };
|
|
if (event.clientX || event.clientY) {
|
|
context.pointerClientX = event.clientX;
|
|
context.pointerClientY = event.clientY;
|
|
}
|
|
state.setTheme(next, context);
|
|
};
|
|
|
|
return html`
|
|
<div class="theme-toggle" style="--theme-index: ${index};">
|
|
<div class="theme-toggle__track" role="group" aria-label="Theme">
|
|
<span class="theme-toggle__indicator"></span>
|
|
<button
|
|
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
|
|
@click=${applyTheme("system")}
|
|
aria-pressed=${state.theme === "system"}
|
|
aria-label="System theme"
|
|
title="System"
|
|
>
|
|
${renderMonitorIcon()}
|
|
</button>
|
|
<button
|
|
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
|
|
@click=${applyTheme("light")}
|
|
aria-pressed=${state.theme === "light"}
|
|
aria-label="Light theme"
|
|
title="Light"
|
|
>
|
|
${renderSunIcon()}
|
|
</button>
|
|
<button
|
|
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
|
|
@click=${applyTheme("dark")}
|
|
aria-pressed=${state.theme === "dark"}
|
|
aria-label="Dark theme"
|
|
title="Dark"
|
|
>
|
|
${renderMoonIcon()}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderChatFocusToggle(focusMode: boolean, onToggle: () => void) {
|
|
return html`
|
|
<button
|
|
class="btn ${focusMode ? "active" : ""}"
|
|
@click=${onToggle}
|
|
aria-pressed=${focusMode}
|
|
title="Toggle focus mode (hide sidebar + page header)"
|
|
>
|
|
Focus
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
function renderSunIcon() {
|
|
return html`
|
|
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="4"></circle>
|
|
<path d="M12 2v2"></path>
|
|
<path d="M12 20v2"></path>
|
|
<path d="m4.93 4.93 1.41 1.41"></path>
|
|
<path d="m17.66 17.66 1.41 1.41"></path>
|
|
<path d="M2 12h2"></path>
|
|
<path d="M20 12h2"></path>
|
|
<path d="m6.34 17.66-1.41 1.41"></path>
|
|
<path d="m19.07 4.93-1.41 1.41"></path>
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
function renderMoonIcon() {
|
|
return html`
|
|
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
|
|
></path>
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
function renderMonitorIcon() {
|
|
return html`
|
|
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
|
|
<line x1="8" x2="16" y1="21" y2="21"></line>
|
|
<line x1="12" x2="12" y1="17" y2="21"></line>
|
|
</svg>
|
|
`;
|
|
}
|