|
|
|
|
Quelle sessions.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 { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import { normalizeThinkLevel } from "../thinking.ts";
import type {
GatewaySessionRow,
GatewayThinkingLevelOption,
SessionCompactionCheckpoint,
SessionsListResult,
} from "../types.ts";
export type SessionsProps = {
loading: boolean;
result: SessionsListResult | null;
error: string | null;
activeMinutes: string;
limit: string;
includeGlobal: boolean;
includeUnknown: boolean;
basePath: string;
searchQuery: string;
sortColumn: "key" | "kind" | "updated" | "tokens";
sortDir: "asc" | "desc";
page: number;
pageSize: number;
selectedKeys: Set<string>;
expandedCheckpointKey: string | null;
checkpointItemsByKey: Record<string, SessionCompactionCheckpoint[]>;
checkpointLoadingKey: string | null;
checkpointBusyKey: string | null;
checkpointErrorByKey: Record<string, string>;
onFiltersChange: (next: {
activeMinutes: string;
limit: string;
includeGlobal: boolean;
includeUnknown: boolean;
}) => void;
onSearchChange: (query: string) => void;
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onRefresh: () => void;
onPatch: (
key: string,
patch: {
label?: string | null;
thinkingLevel?: string | null;
fastMode?: boolean | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
},
) => void;
onToggleSelect: (key: string) => void;
onSelectPage: (keys: string[]) => void;
onDeselectPage: (keys: string[]) => void;
onDeselectAll: () => void;
onDeleteSelected: () => void;
onNavigateToChat?: (sessionKey: string) => void;
onToggleCheckpointDetails: (sessionKey: string) => void;
onBranchFromCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise<void>;
onRestoreCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise<void>;
};
const DEFAULT_THINK_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
const VERBOSE_LEVELS = [
{ value: "", label: "inherit" },
{ value: "off", label: "off (explicit)" },
{ value: "on", label: "on" },
{ value: "full", label: "full" },
] as const;
const FAST_LEVELS = [
{ value: "", label: "inherit" },
{ value: "on", label: "on" },
{ value: "off", label: "off" },
] as const;
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
const PAGE_SIZES = [10, 25, 50, 100] as const;
function normalizeThinkingOptionValue(raw: string): string {
return normalizeThinkLevel(raw) ?? normalizeLowercaseStringOrEmpty(raw);
}
function resolveThinkLevelOptions(
row: GatewaySessionRow,
): readonly { value: string; label: string }[] {
const options: readonly GatewayThinkingLevelOption[] = row.thinkingLevels?.length
? row.thinkingLevels
: (row.thinkingOptions?.length ? row.thinkingOptions : DEFAULT_THINK_LEVELS).map((label) => ({
id: normalizeThinkingOptionValue(label),
label,
}));
return [
{ value: "", label: "inherit" },
...options.map((option) => ({
value: normalizeThinkingOptionValue(option.id),
label: option.label,
})),
];
}
function withCurrentOption(options: readonly string[], current: string): string[] {
if (!current) {
return [...options];
}
if (options.includes(current)) {
return [...options];
}
return [...options, current];
}
function withCurrentLabeledOption(
options: readonly { value: string; label: string }[],
current: string,
): Array<{ value: string; label: string }> {
if (!current) {
return [...options];
}
if (options.some((option) => option.value === current)) {
return [...options];
}
return [...options, { value: current, label: `${current} (custom)` }];
}
function resolveThinkLevelPatchValue(value: string): string | null {
if (!value) {
return null;
}
return value;
}
function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] {
const q = normalizeLowercaseStringOrEmpty(query);
if (!q) {
return rows;
}
return rows.filter((row) => {
const key = normalizeLowercaseStringOrEmpty(row.key);
const label = normalizeLowercaseStringOrEmpty(row.label);
const kind = normalizeLowercaseStringOrEmpty(row.kind);
const displayName = normalizeLowercaseStringOrEmpty(row.displayName);
return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q);
});
}
function sortRows(
rows: GatewaySessionRow[],
column: "key" | "kind" | "updated" | "tokens",
dir: "asc" | "desc",
): GatewaySessionRow[] {
const cmp = dir === "asc" ? 1 : -1;
return [...rows].toSorted((a, b) => {
let diff = 0;
switch (column) {
case "key":
diff = (a.key ?? "").localeCompare(b.key ?? "");
break;
case "kind":
diff = (a.kind ?? "").localeCompare(b.kind ?? "");
break;
case "updated": {
const au = a.updatedAt ?? 0;
const bu = b.updatedAt ?? 0;
diff = au - bu;
break;
}
case "tokens": {
const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0;
const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0;
diff = at - bt;
break;
}
}
return diff * cmp;
});
}
function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
const start = page * pageSize;
return rows.slice(start, start + pageSize);
}
function formatCheckpointReason(reason: SessionCompactionCheckpoint["reason"]): string {
switch (reason) {
case "manual":
return "manual";
case "auto-threshold":
return "auto-threshold";
case "overflow-retry":
return "overflow retry";
case "timeout-retry":
return "timeout retry";
default:
return reason;
}
}
function formatCheckpointDelta(checkpoint: SessionCompactionCheckpoint): string {
if (
typeof checkpoint.tokensBefore === "number" &&
typeof checkpoint.tokensAfter === "number" &&
Number.isFinite(checkpoint.tokensBefore) &&
Number.isFinite(checkpoint.tokensAfter)
) {
return `${checkpoint.tokensBefore.toLocaleString()} → ${checkpoint.tokensAfter.toLocaleString()} tokens`;
}
if (typeof checkpoint.tokensBefore === "number" && Number.isFinite(checkpoint.tokensBefore)) {
return `${checkpoint.tokensBefore.toLocaleString()} tokens before`;
}
return "token delta unavailable";
}
export function renderSessions(props: SessionsProps) {
const rawRows = props.result?.sessions ?? [];
const filtered = filterRows(rawRows, props.searchQuery);
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
const totalRows = sorted.length;
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
const page = Math.min(props.page, totalPages - 1);
const paginated = paginateRows(sorted, page, props.pageSize);
const sortHeader = (
col: "key" | "kind" | "updated" | "tokens",
label: string,
extraClass = "",
) => {
const isActive = props.sortColumn === col;
const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const);
return html`
<th
class=${extraClass}
data-sortable
data-sort-dir=${isActive ? props.sortDir : ""}
@click=${() => props.onSortChange(col, isActive ? nextDir : "desc")}
>
${label}
<span class="data-table-sort-icon">${icons.arrowUpDown}</span>
</th>
`;
};
return html`
<section class="card">
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
<div>
<div class="card-title">Sessions</div>
<div class="card-sub">
${props.result
? `Store: ${props.result.path}`
: "Active session keys and per-session overrides."}
</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="filters" style="margin-bottom: 12px;">
<label class="field-inline">
<span>Active</span>
<input
style="width: 72px;"
placeholder="min"
.value=${props.activeMinutes}
@input=${(e: Event) =>
props.onFiltersChange({
activeMinutes: (e.target as HTMLInputElement).value,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field-inline">
<span>Limit</span>
<input
style="width: 64px;"
.value=${props.limit}
@input=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: (e.target as HTMLInputElement).value,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field-inline checkbox">
<input
type="checkbox"
.checked=${props.includeGlobal}
@change=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: (e.target as HTMLInputElement).checked,
includeUnknown: props.includeUnknown,
})}
/>
<span>Global</span>
</label>
<label class="field-inline checkbox">
<input
type="checkbox"
.checked=${props.includeUnknown}
@change=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: (e.target as HTMLInputElement).checked,
})}
/>
<span>Unknown</span>
</label>
</div>
${props.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
: nothing}
<div class="data-table-wrapper">
<div class="data-table-toolbar">
<div class="data-table-search">
<input
type="text"
placeholder="Filter by key, label, kind…"
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
</div>
</div>
${props.selectedKeys.size > 0
? html`
<div class="data-table-bulk-bar">
<span>${props.selectedKeys.size} selected</span>
<button class="btn btn--sm" @click=${props.onDeselectAll}>
${t("common.unselect")}
</button>
<button
class="btn btn--sm danger"
?disabled=${props.loading}
@click=${props.onDeleteSelected}
>
${icons.trash} Delete
</button>
</div>
`
: nothing}
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th class="data-table-checkbox-col">
${paginated.length > 0
? html`<input
type="checkbox"
.checked=${paginated.length > 0 &&
paginated.every((r) => props.selectedKeys.has(r.key))}
.indeterminate=${paginated.some((r) => props.selectedKeys.has(r.key)) &&
!paginated.every((r) => props.selectedKeys.has(r.key))}
@change=${() => {
const allSelected = paginated.every((r) => props.selectedKeys.has(r.key));
if (allSelected) {
props.onDeselectPage(paginated.map((r) => r.key));
} else {
props.onSelectPage(paginated.map((r) => r.key));
}
}}
aria-label="Select all on page"
/>`
: nothing}
</th>
${sortHeader("key", "Key", "data-table-key-col")}
<th>Label</th>
${sortHeader("kind", "Kind")} ${sortHeader("updated", "Updated")}
${sortHeader("tokens", "Tokens")}
<th>Compaction</th>
<th>Thinking</th>
<th>Fast</th>
<th>Verbose</th>
<th>Reasoning</th>
</tr>
</thead>
<tbody>
${paginated.length === 0
? html`
<tr>
<td
colspan="11"
style="text-align: center; padding: 48px 16px; color: var(--muted)"
>
No sessions found.
</td>
</tr>
`
: paginated.flatMap((row) => renderRows(row, props))}
</tbody>
</table>
</div>
${totalRows > 0
? html`
<div class="data-table-pagination">
<div class="data-table-pagination__info">
${page * props.pageSize + 1}-${Math.min((page + 1) * props.pageSize, totalRows)}
of ${totalRows} row${totalRows === 1 ? "" : "s"}
</div>
<div class="data-table-pagination__controls">
<select
style="height: 32px; padding: 0 8px; font-size: 13px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card);"
.value=${String(props.pageSize)}
@change=${(e: Event) =>
props.onPageSizeChange(Number((e.target as HTMLSelectElement).value))}
>
${PAGE_SIZES.map((s) => html`<option value=${s}>${s} per page</option>`)}
</select>
<button ?disabled=${page <= 0} @click=${() => props.onPageChange(page - 1)}>
Previous
</button>
<button
?disabled=${page >= totalPages - 1}
@click=${() => props.onPageChange(page + 1)}
>
Next
</button>
</div>
</div>
`
: nothing}
</div>
</section>
`;
}
function renderRows(row: GatewaySessionRow, props: SessionsProps) {
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : t("common.na");
const rawThinking = row.thinkingLevel ?? "";
const thinking = rawThinking ? normalizeThinkingOptionValue(rawThinking) : "";
const thinkLevels = withCurrentLabeledOption(resolveThinkLevelOptions(row), thinking);
const fastMode = row.fastMode === true ? "on" : row.fastMode === false ? "off" : "";
const fastLevels = withCurrentLabeledOption(FAST_LEVELS, fastMode);
const verbose = row.verboseLevel ?? "";
const verboseLevels = withCurrentLabeledOption(VERBOSE_LEVELS, verbose);
const reasoning = row.reasoningLevel ?? "";
const reasoningLevels = withCurrentOption(REASONING_LEVELS, reasoning);
const latestCheckpoint = row.latestCompactionCheckpoint;
const checkpointCount = row.compactionCheckpointCount ?? 0;
const isExpanded = props.expandedCheckpointKey === row.key;
const checkpointItems = props.checkpointItemsByKey[row.key] ?? [];
const checkpointError = props.checkpointErrorByKey[row.key];
const displayName = normalizeOptionalString(row.displayName) ?? null;
const trimmedLabel = normalizeOptionalString(row.label) ?? "";
const showDisplayName = Boolean(
displayName && displayName !== row.key && displayName !== trimmedLabel,
);
const canLink = row.kind !== "global";
const chatUrl = canLink
? `${pathForTab("chat", props.basePath)}?session=${encodeURIComponent(row.key)}`
: null;
const badgeClass =
row.kind === "direct"
? "data-table-badge--direct"
: row.kind === "group"
? "data-table-badge--group"
: row.kind === "global"
? "data-table-badge--global"
: "data-table-badge--unknown";
return [
html`<tr>
<td class="data-table-checkbox-col">
<input
type="checkbox"
.checked=${props.selectedKeys.has(row.key)}
@change=${() => props.onToggleSelect(row.key)}
aria-label="Select session"
/>
</td>
<td class="data-table-key-col">
<div class="mono session-key-cell">
${canLink
? html`<a
href=${chatUrl}
class="session-link"
@click=${(e: MouseEvent) => {
if (
e.defaultPrevented ||
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
return;
}
if (props.onNavigateToChat) {
e.preventDefault();
props.onNavigateToChat(row.key);
}
}}
>${row.key}</a
>`
: row.key}
${showDisplayName
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing}
</div>
</td>
<td>
<input
.value=${row.label ?? ""}
?disabled=${props.loading}
placeholder="(optional)"
style="width: 100%; max-width: 140px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm);"
@change=${(e: Event) => {
const value = normalizeOptionalString((e.target as HTMLInputElement).value) ?? null;
props.onPatch(row.key, { label: value });
}}
/>
</td>
<td>
<span class="data-table-badge ${badgeClass}">${row.kind}</span>
</td>
<td>${updated}</td>
<td>${formatSessionTokens(row)}</td>
<td>
<div style="display: grid; gap: 6px;">
<span class="muted" style="font-size: 12px;">
${checkpointCount > 0
? `${checkpointCount} checkpoint${checkpointCount === 1 ? "" : "s"}`
: "none"}
</span>
${latestCheckpoint
? html`
<span style="font-size: 12px;">
${formatCheckpointReason(latestCheckpoint.reason)} ·
${formatRelativeTimestamp(latestCheckpoint.createdAt)}
</span>
`
: nothing}
<button
class="btn btn--sm"
?disabled=${props.checkpointLoadingKey === row.key}
@click=${() => props.onToggleCheckpointDetails(row.key)}
>
${isExpanded ? "Hide checkpoints" : "Show checkpoints"}
</button>
</div>
</td>
<td>
<select
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
props.onPatch(row.key, {
thinkingLevel: resolveThinkLevelPatchValue(value),
});
}}
>
${thinkLevels.map(
(level) =>
html`<option value=${level.value} ?selected=${thinking === level.value}>
${level.label}
</option>`,
)}
</select>
</td>
<td>
<select
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
props.onPatch(row.key, { fastMode: value === "" ? null : value === "on" });
}}
>
${fastLevels.map(
(level) =>
html`<option value=${level.value} ?selected=${fastMode === level.value}>
${level.label}
</option>`,
)}
</select>
</td>
<td>
<select
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
props.onPatch(row.key, { verboseLevel: value || null });
}}
>
${verboseLevels.map(
(level) =>
html`<option value=${level.value} ?selected=${verbose === level.value}>
${level.label}
</option>`,
)}
</select>
</td>
<td>
<select
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
props.onPatch(row.key, { reasoningLevel: value || null });
}}
>
${reasoningLevels.map(
(level) =>
html`<option value=${level} ?selected=${reasoning === level}>
${level || "inherit"}
</option>`,
)}
</select>
</td>
</tr>`,
...(isExpanded
? [
html`<tr>
<td colspan="11" style="padding: 0;">
<div
style="padding: 14px 16px; border-top: 1px solid var(--border); background: var(--surface-2, rgba(127, 127, 127, 0.05));"
>
${props.checkpointLoadingKey === row.key
? html`<div class="muted">Loading checkpoints…</div>`
: checkpointError
? html`<div class="callout danger">${checkpointError}</div>`
: checkpointItems.length === 0
? html`<div class="muted">
No compaction checkpoints recorded for this session.
</div>`
: html`
<div style="display: grid; gap: 10px;">
${checkpointItems.map(
(checkpoint) => html`
<div
style="border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px; display: grid; gap: 8px;"
>
<div
style="display: flex; gap: 8px; justify-content: space-between; align-items: center; flex-wrap: wrap;"
>
<strong>
${formatCheckpointReason(checkpoint.reason)} ·
${formatRelativeTimestamp(checkpoint.createdAt)}
</strong>
<span class="muted" style="font-size: 12px;">
${formatCheckpointDelta(checkpoint)}
</span>
</div>
${checkpoint.summary
? html`<div style="white-space: pre-wrap;">
${checkpoint.summary}
</div>`
: html`<div class="muted">No summary captured.</div>`}
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm"
?disabled=${props.checkpointBusyKey ===
checkpoint.checkpointId}
@click=${() =>
props.onBranchFromCheckpoint(
row.key,
checkpoint.checkpointId,
)}
>
Branch from checkpoint
</button>
<button
class="btn btn--sm"
?disabled=${props.checkpointBusyKey ===
checkpoint.checkpointId}
@click=${() =>
props.onRestoreCheckpoint(row.key, checkpoint.checkpointId)}
>
Restore
</button>
</div>
</div>
`,
)}
</div>
`}
</div>
</td>
</tr>`,
]
: []),
];
}
¤ Dauer der Verarbeitung: 0.18 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|
2026-05-26
|
|
|
|
|