This commit is contained in:
Victor Jiao 2026-03-15 22:49:32 +00:00 committed by GitHub
commit 9f5d41763d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 29012 additions and 33327 deletions

View File

@ -1213,6 +1213,7 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let includearchived: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
@ -1225,6 +1226,7 @@ public struct SessionsListParams: Codable, Sendable {
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
includearchived: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
@ -1236,6 +1238,7 @@ public struct SessionsListParams: Codable, Sendable {
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.includearchived = includearchived
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
@ -1249,6 +1252,7 @@ public struct SessionsListParams: Codable, Sendable {
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case includearchived = "includeArchived"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
@ -1339,6 +1343,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
public let archivedat: AnyCodable?
public init(
key: String,
@ -1360,7 +1365,8 @@ public struct SessionsPatchParams: Codable, Sendable {
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
groupactivation: AnyCodable?,
archivedat: AnyCodable?)
{
self.key = key
self.label = label
@ -1382,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
self.archivedat = archivedat
}
private enum CodingKeys: String, CodingKey {
@ -1405,6 +1412,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
case archivedat = "archivedAt"
}
}

View File

@ -1213,6 +1213,7 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let includearchived: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
@ -1225,6 +1226,7 @@ public struct SessionsListParams: Codable, Sendable {
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
includearchived: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
@ -1236,6 +1238,7 @@ public struct SessionsListParams: Codable, Sendable {
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.includearchived = includearchived
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
@ -1249,6 +1252,7 @@ public struct SessionsListParams: Codable, Sendable {
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case includearchived = "includeArchived"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
@ -1339,6 +1343,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
public let archivedat: AnyCodable?
public init(
key: String,
@ -1360,7 +1365,8 @@ public struct SessionsPatchParams: Codable, Sendable {
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
groupactivation: AnyCodable?,
archivedat: AnyCodable?)
{
self.key = key
self.label = label
@ -1382,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
self.archivedat = archivedat
}
private enum CodingKeys: String, CodingKey {
@ -1405,6 +1412,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
case archivedat = "archivedAt"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,7 @@ Text + native (when enabled):
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
- `/dock-discord` (alias: `/dock_discord`) (switch replies to Discord)
- `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack)
- `/set_topic_name <name>` (Telegram topics only)
- `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only)
- `/reset` or `/new [model]` (optional model hint; remainder is passed through)

View File

@ -1,10 +1,12 @@
import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings.route.js";
import { resolveDefaultAgentId } from "../../../src/agents/agent-scope.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { logVerbose } from "../../../src/globals.js";
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
import {
buildAgentSessionKey,
deriveLastRoutePolicy,
pickFirstExistingAgentId,
resolveAgentRoute,
} from "../../../src/routing/resolve-route.js";
import {
@ -58,7 +60,12 @@ export function resolveTelegramConversationRoute(params: {
if (rawTopicAgentId) {
// Preserve the configured topic agent ID so topic-bound sessions stay stable
// even when that agent is not present in the current config snapshot.
const topicAgentId = sanitizeAgentId(rawTopicAgentId);
const normalizedRawTopicAgentId = sanitizeAgentId(rawTopicAgentId);
const defaultAgentId = sanitizeAgentId(resolveDefaultAgentId(params.cfg));
let topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId);
if (topicAgentId === defaultAgentId && normalizedRawTopicAgentId !== defaultAgentId) {
topicAgentId = normalizedRawTopicAgentId;
}
route = {
...route,
agentId: topicAgentId,

View File

@ -382,6 +382,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/unfocus",
category: "management",
}),
defineChatCommand({
key: "set_topic_name",
nativeName: "set_topic_name",
description: "Set the display label for the current Telegram topic.",
textAlias: "/set_topic_name",
category: "management",
args: [
{
name: "name",
description: "Topic label",
type: "string",
captureRemaining: true,
},
],
}),
defineChatCommand({
key: "agents",
nativeName: "agents",

View File

@ -35,6 +35,7 @@ import {
handleUsageCommand,
} from "./commands-session.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleSetTopicNameCommand } from "./commands-topic-name.js";
import { handleTtsCommands } from "./commands-tts.js";
import type {
CommandHandler,
@ -184,6 +185,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleSessionCommand,
handleRestartCommand,
handleTtsCommands,
handleSetTopicNameCommand,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,

View File

@ -0,0 +1,128 @@
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import { isTelegramSurface } from "./channel-context.js";
import type { CommandHandler } from "./commands-types.js";
const COMMAND_REGEX = /^\/set_topic_name(?:\s|$)/i;
const MAX_NAME_LEN = 64;
const MAX_LABEL_LEN = 64;
function parseTopicNameCommand(
raw: string,
): { ok: true; name: string } | { ok: false; error: string } | null {
const trimmed = raw.trim();
const match = trimmed.match(COMMAND_REGEX);
if (!match) {
return null;
}
const rest = trimmed.slice(match[0].length).trim();
const name = rest.replace(/·/g, " ").slice(0, MAX_NAME_LEN).trim();
if (!name) {
return { ok: false, error: "Usage: /set_topic_name <name>" };
}
return { ok: true, name };
}
function resolveConversationLabel(params: Parameters<CommandHandler>[0]): string {
return (
(typeof params.ctx.ConversationLabel === "string" ? params.ctx.ConversationLabel.trim() : "") ||
(typeof params.ctx.GroupSubject === "string" ? params.ctx.GroupSubject.trim() : "")
);
}
function normalizeBaseLabel(raw: string): string {
return raw.replace(/^telegram\s*·\s*/i, "").trim();
}
function buildTopicLabel(baseLabel: string, name: string): string {
const prefix = "telegram";
const separator = " · ";
let normalizedBase = baseLabel ? normalizeBaseLabel(baseLabel) : "";
const maxNameWithoutBase = Math.max(1, MAX_LABEL_LEN - (prefix + separator).length);
let nextName = name.slice(0, maxNameWithoutBase).trim();
if (normalizedBase) {
const maxBaseLen = MAX_LABEL_LEN - (prefix + separator + separator + nextName).length;
if (maxBaseLen > 0) {
normalizedBase = normalizedBase.slice(0, maxBaseLen).trim();
} else {
normalizedBase = "";
}
}
const maxNameLen = Math.max(
1,
MAX_LABEL_LEN -
(prefix + separator + (normalizedBase ? normalizedBase + separator : "")).length,
);
nextName = name.slice(0, maxNameLen).trim();
return normalizedBase
? `${prefix}${separator}${normalizedBase}${separator}${nextName}`
: `${prefix}${separator}${nextName}`;
}
export const handleSetTopicNameCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const parsed = parseTopicNameCommand(params.command.commandBodyNormalized);
if (!parsed) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /set_topic_name from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!isTelegramSurface(params)) {
return {
shouldContinue: false,
reply: { text: "⚙️ /set_topic_name only works for Telegram topics." },
};
}
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
}
const threadId = params.ctx.MessageThreadId;
if (threadId == null || `${threadId}`.trim() === "") {
return {
shouldContinue: false,
reply: { text: "⚙️ /set_topic_name only works inside a Telegram topic." },
};
}
// Telegram topics are thread-scoped; sessionKey already includes the thread context.
// threadId is only used to enforce topic usage, not to disambiguate sessions.patch.
if (!params.sessionKey) {
return {
shouldContinue: false,
reply: { text: "⚙️ Could not resolve the current session key." },
};
}
const baseLabel = resolveConversationLabel(params);
const label = buildTopicLabel(baseLabel, parsed.name);
try {
await callGateway({
method: "sessions.patch",
params: {
key: params.sessionKey,
label,
},
timeoutMs: 10_000,
});
} catch (err) {
logVerbose(`/set_topic_name gateway error: ${String(err)}`);
return {
shouldContinue: false,
reply: { text: "❌ Failed to set topic name. Please try again later." },
};
}
return {
shouldContinue: false,
reply: { text: `✅ Topic label set to ${label}.` },
};
};

View File

@ -439,7 +439,11 @@ export async function initSessionState(params: {
lastTo,
lastAccountId,
lastThreadId,
archivedAt: baseEntry?.archivedAt,
};
if (typeof sessionEntry.archivedAt === "number") {
sessionEntry.archivedAt = undefined;
}
const metaPatch = deriveSessionMetaPatch({
ctx: sessionCtxForState,
sessionKey,

View File

@ -157,6 +157,7 @@ export type SessionEntry = {
claudeCliSessionId?: string;
label?: string;
displayName?: string;
archivedAt?: number;
channel?: string;
groupId?: string;
subject?: string;

View File

@ -7,6 +7,7 @@ export const SessionsListParamsSchema = Type.Object(
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
includeArchived: Type.Optional(Type.Boolean()),
/**
* Read first 8KB of each session transcript to derive title from first user message.
* Performs a file read per session - use `limit` to bound result set on large stores.
@ -86,6 +87,7 @@ export const SessionsPatchParamsSchema = Type.Object(
groupActivation: Type.Optional(
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
),
archivedAt: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])),
},
{ additionalProperties: false },
);

View File

@ -8,6 +8,7 @@ import {
updateSessionStore,
} from "../../config/sessions.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { writeSessionArchiveSummary } from "../../sessions/archive-summary.js";
import { GATEWAY_CLIENT_IDS } from "../protocol/client-info.js";
import {
ErrorCodes,
@ -212,6 +213,23 @@ export const sessionsHandlers: GatewayRequestHandlers = {
respond(false, undefined, applied.error);
return;
}
const wasArchived = typeof applied.previous?.archivedAt === "number";
const isArchived = typeof applied.entry.archivedAt === "number";
if (!wasArchived && isArchived) {
setImmediate(() => {
void writeSessionArchiveSummary({
cfg,
key: target.canonicalKey ?? key,
entry: applied.entry,
storePath,
archivedAt: applied.entry.archivedAt ?? undefined,
}).catch((err) => {
console.warn(`sessions.patch archive summary failed: ${String(err)}`);
});
});
}
const parsed = parseAgentSessionKey(target.canonicalKey ?? key);
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const resolved = resolveSessionModelRef(cfg, applied.entry, agentId);

View File

@ -854,6 +854,7 @@ export function listSessionsFromStore(params: {
const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true;
const includeArchived = opts.includeArchived === true;
const includeDerivedTitles = opts.includeDerivedTitles === true;
const includeLastMessage = opts.includeLastMessage === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
@ -889,6 +890,9 @@ export function listSessionsFromStore(params: {
return true;
})
.filter(([key, entry]) => {
if (!includeArchived && typeof entry?.archivedAt === "number") {
return false;
}
if (!spawnedBy) {
return true;
}
@ -971,6 +975,7 @@ export function listSessionsFromStore(params: {
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
archivedAt: entry?.archivedAt ?? null,
};
})
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));

View File

@ -49,6 +49,7 @@ export type GatewaySessionRow = {
lastChannel?: SessionEntry["lastChannel"];
lastTo?: string;
lastAccountId?: string;
archivedAt?: number | null;
};
export type GatewayAgentRow = SharedGatewayAgentRow;

View File

@ -90,7 +90,9 @@ export async function applySessionsPatchToStore(params: {
storeKey: string;
patch: SessionsPatchParams;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
}): Promise<
{ ok: true; entry: SessionEntry; previous?: SessionEntry } | { ok: false; error: ErrorShape }
> {
const { cfg, store, storeKey, patch } = params;
const now = Date.now();
const parsedAgent = parseAgentSessionKey(storeKey);
@ -235,6 +237,19 @@ export async function applySessionsPatchToStore(params: {
}
}
if ("archivedAt" in patch) {
const raw = patch.archivedAt;
if (raw === null) {
delete next.archivedAt;
} else if (raw !== undefined) {
const numeric = Number(raw);
if (!Number.isFinite(numeric) || numeric < 0) {
return invalid("invalid archivedAt (use epoch ms >= 0)");
}
next.archivedAt = Math.floor(numeric);
}
}
if ("thinkingLevel" in patch) {
const raw = patch.thinkingLevel;
if (raw === null) {
@ -458,5 +473,5 @@ export async function applySessionsPatchToStore(params: {
}
store[storeKey] = next;
return { ok: true, entry: next };
return { ok: true, entry: next, previous: existing };
}

View File

@ -75,6 +75,7 @@ export async function resolveSessionKeyFromResolveParams(params: {
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
includeArchived: true,
spawnedBy: p.spawnedBy,
agentId: p.agentId,
search: sessionId,
@ -119,6 +120,7 @@ export async function resolveSessionKeyFromResolveParams(params: {
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
includeArchived: true,
label: parsedLabel.label,
agentId: p.agentId,
spawnedBy: p.spawnedBy,

View File

@ -0,0 +1,134 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import { readSessionMessages } from "../gateway/session-utils.fs.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
const log = createSubsystemLogger("sessions-archive");
function extractMessageText(message: unknown): string {
if (!message || typeof message !== "object") {
return "";
}
const msg = message as { content?: unknown; text?: unknown; message?: unknown };
if (typeof msg.text === "string") {
return msg.text;
}
const content = msg.content;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
const parts = content
.map((part) => {
if (!part || typeof part !== "object") {
return "";
}
const entry = part as { type?: string; text?: string };
if (entry.type === "text" && typeof entry.text === "string") {
return entry.text;
}
return "";
})
.filter(Boolean);
return parts.join(" ").trim();
}
return "";
}
function truncate(text: string, max = 200): string {
if (text.length <= max) {
return text;
}
return `${text.slice(0, max - 1).trim()}`;
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
async function resolveUniquePath(dir: string, baseName: string): Promise<string> {
let candidate = path.join(dir, baseName);
if (!(await fileExists(candidate))) {
return candidate;
}
for (let i = 2; i < 1000; i += 1) {
candidate = path.join(dir, baseName.replace(/\.md$/i, `-${i}.md`));
if (!(await fileExists(candidate))) {
return candidate;
}
}
return path.join(dir, `${Date.now()}-${baseName}`);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.stat(filePath);
return true;
} catch {
return false;
}
}
export async function writeSessionArchiveSummary(params: {
cfg: OpenClawConfig;
key: string;
entry: SessionEntry;
storePath?: string;
archivedAt?: number | null;
}): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
const { cfg, key, entry, storePath } = params;
const archivedAt = typeof params.archivedAt === "number" ? params.archivedAt : Date.now();
const sessionId = entry.sessionId;
if (!sessionId) {
return { ok: false, reason: "missing sessionId" };
}
const messages = readSessionMessages(sessionId, storePath, entry.sessionFile);
if (!messages.length) {
return { ok: false, reason: "no transcript" };
}
const userMessages = messages
.filter((msg) => (msg as { role?: string })?.role === "user")
.map((msg) => extractMessageText(msg))
.filter((text) => text);
const assistantMessages = messages
.filter((msg) => (msg as { role?: string })?.role === "assistant")
.map((msg) => extractMessageText(msg))
.filter((text) => text);
const firstUser = userMessages.slice(0, 3).map((text) => truncate(text));
const lastUser = userMessages.slice(-3).map((text) => truncate(text));
const label = entry.label?.trim() || entry.displayName?.trim() || entry.origin?.label?.trim();
const displayLabel = label || key;
const parsed = parseAgentSessionKey(key);
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const date = new Date(archivedAt).toISOString().slice(0, 10);
const dir = path.join(workspaceDir, "memory");
const baseSlug = slugify(displayLabel) || "session";
const keySlug = slugify(key) || "session";
const baseName = `${date}-${baseSlug}--${keySlug}.md`;
await fs.mkdir(dir, { recursive: true });
const filePath = await resolveUniquePath(dir, baseName);
const summary = `# Session Summary\n\n- Session: ${displayLabel}\n- Key: \`${key}\`\n- Archived at: ${new Date(archivedAt).toISOString()}\n- Messages: ${messages.length} total (${userMessages.length} user, ${assistantMessages.length} assistant)\n\n## First user messages\n${
firstUser.length ? firstUser.map((line) => `- ${line}`).join("\n") : "- (none)"
}\n\n## Recent user messages\n${lastUser.length ? lastUser.map((line) => `- ${line}`).join("\n") : "- (none)"}\n`;
await fs.writeFile(filePath, summary, "utf-8");
log.info(`wrote archive summary to ${filePath}`);
return { ok: true, path: filePath };
}

View File

@ -726,6 +726,7 @@ export function renderApp(state: AppViewState) {
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
includeArchived: state.sessionsIncludeArchived,
basePath: state.basePath,
searchQuery: state.sessionsSearchQuery,
sortColumn: state.sessionsSortColumn,
@ -738,6 +739,18 @@ export function renderApp(state: AppViewState) {
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
state.sessionsIncludeArchived = next.includeArchived;
const activeMinutes = Number(next.activeMinutes);
const limit = Number(next.limit);
// Reload immediately so filter changes take effect without waiting for polling.
// Empty/invalid inputs fall back to 0 to disable that filter.
void loadSessions(state, {
activeMinutes: Number.isFinite(activeMinutes) ? activeMinutes : 0,
limit: Number.isFinite(limit) ? limit : 0,
includeGlobal: next.includeGlobal,
includeUnknown: next.includeUnknown,
includeArchived: next.includeArchived,
});
},
onSearchChange: (q) => {
state.sessionsSearchQuery = q;

View File

@ -184,6 +184,7 @@ export type AppViewState = {
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
sessionsIncludeArchived: boolean;
sessionsHideCron: boolean;
sessionsSearchQuery: string;
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";

View File

@ -282,6 +282,7 @@ export class OpenClawApp extends LitElement {
@state() sessionsFilterLimit = "120";
@state() sessionsIncludeGlobal = true;
@state() sessionsIncludeUnknown = false;
@state() sessionsIncludeArchived = false;
@state() sessionsHideCron = true;
@state() sessionsSearchQuery = "";
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";

View File

@ -14,6 +14,7 @@ function createState(request: RequestFn, overrides: Partial<SessionsState> = {})
sessionsFilterLimit: "0",
sessionsIncludeGlobal: true,
sessionsIncludeUnknown: true,
sessionsIncludeArchived: false,
...overrides,
};
}

View File

@ -12,6 +12,7 @@ export type SessionsState = {
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
sessionsIncludeArchived: boolean;
};
export async function loadSessions(
@ -21,6 +22,7 @@ export async function loadSessions(
limit?: number;
includeGlobal?: boolean;
includeUnknown?: boolean;
includeArchived?: boolean;
},
) {
if (!state.client || !state.connected) {
@ -34,12 +36,16 @@ export async function loadSessions(
try {
const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal;
const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown;
const includeArchived = overrides?.includeArchived ?? state.sessionsIncludeArchived;
const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0);
const params: Record<string, unknown> = {
includeGlobal,
includeUnknown,
};
if (includeArchived) {
params.includeArchived = true;
}
if (activeMinutes > 0) {
params.activeMinutes = activeMinutes;
}
@ -66,6 +72,7 @@ export async function patchSession(
fastMode?: boolean | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
archivedAt?: number | null;
},
) {
if (!state.client || !state.connected) {
@ -87,6 +94,9 @@ export async function patchSession(
if ("reasoningLevel" in patch) {
params.reasoningLevel = patch.reasoningLevel;
}
if ("archivedAt" in patch) {
params.archivedAt = patch.archivedAt;
}
try {
await state.client.request("sessions.patch", params);
await loadSessions(state);

View File

@ -389,6 +389,7 @@ export type GatewaySessionRow = {
model?: string;
modelProvider?: string;
contextTokens?: number;
archivedAt?: number | null;
};
export type SessionsListResult = SessionsListResultBase<GatewaySessionsDefaults, GatewaySessionRow>;

View File

@ -22,6 +22,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
limit: "120",
includeGlobal: false,
includeUnknown: false,
includeArchived: false,
basePath: "",
searchQuery: "",
sortColumn: "updated",

View File

@ -13,6 +13,7 @@ export type SessionsProps = {
limit: string;
includeGlobal: boolean;
includeUnknown: boolean;
includeArchived: boolean;
basePath: string;
searchQuery: string;
sortColumn: "key" | "kind" | "updated" | "tokens";
@ -25,6 +26,7 @@ export type SessionsProps = {
limit: string;
includeGlobal: boolean;
includeUnknown: boolean;
includeArchived: boolean;
}) => void;
onSearchChange: (query: string) => void;
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
@ -40,6 +42,7 @@ export type SessionsProps = {
fastMode?: boolean | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
archivedAt?: number | null;
},
) => void;
onDelete: (key: string) => void;
@ -179,7 +182,12 @@ function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
export function renderSessions(props: SessionsProps) {
const rawRows = props.result?.sessions ?? [];
const filtered = filterRows(rawRows, props.searchQuery);
// Client-side guard: hides archived rows immediately on toggle while the
// server request (triggered by onFiltersChange) is in-flight.
const visibleRows = props.includeArchived
? rawRows
: rawRows.filter((row) => typeof row.archivedAt !== "number");
const filtered = filterRows(visibleRows, props.searchQuery);
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
const totalRows = sorted.length;
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
@ -237,6 +245,7 @@ export function renderSessions(props: SessionsProps) {
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
includeArchived: props.includeArchived,
})}
/>
</label>
@ -251,6 +260,7 @@ export function renderSessions(props: SessionsProps) {
limit: (e.target as HTMLInputElement).value,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
includeArchived: props.includeArchived,
})}
/>
</label>
@ -264,6 +274,7 @@ export function renderSessions(props: SessionsProps) {
limit: props.limit,
includeGlobal: (e.target as HTMLInputElement).checked,
includeUnknown: props.includeUnknown,
includeArchived: props.includeArchived,
})}
/>
<span>Global</span>
@ -278,10 +289,26 @@ export function renderSessions(props: SessionsProps) {
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: (e.target as HTMLInputElement).checked,
includeArchived: props.includeArchived,
})}
/>
<span>Unknown</span>
</label>
<label class="field-inline checkbox">
<input
type="checkbox"
.checked=${props.includeArchived}
@change=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
includeArchived: (e.target as HTMLInputElement).checked,
})}
/>
<span>Archived</span>
</label>
</div>
${
@ -425,6 +452,7 @@ function renderRow(
: row.kind === "global"
? "data-table-badge--global"
: "data-table-badge--unknown";
const isArchived = typeof row.archivedAt === "number";
return html`
<tr>
@ -436,6 +464,13 @@ function renderRow(
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing
}
${
isArchived
? html`
<span class="data-table-badge" style="margin-left: 6px">Archived</span>
`
: nothing
}
</div>
</td>
<td>
@ -555,6 +590,15 @@ function renderRow(
`
: nothing
}
<button
type="button"
@click=${() => {
onActionsOpenChange(null);
onPatch(row.key, { archivedAt: isArchived ? null : Date.now() });
}}
>
${isArchived ? "Unarchive" : "Archive"}
</button>
<button
type="button"
class="danger"