Merge pull request #570 from azade-c/feat/sessions-label

feat(sessions): expose label in sessions.list and support label lookup in sessions_send
This commit is contained in:
Peter Steinberger
2026-01-09 14:44:23 +00:00
committed by GitHub
24 changed files with 396 additions and 58 deletions

View File

@@ -45,6 +45,7 @@
- Control UI: logs tab opens at the newest entries (bottom).
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).

View File

@@ -426,6 +426,8 @@ public struct AgentParams: Codable, Sendable {
public let lane: String?
public let extrasystemprompt: String?
public let idempotencykey: String
public let label: String?
public let spawnedby: String?
public init(
message: String,
@@ -438,7 +440,9 @@ public struct AgentParams: Codable, Sendable {
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
idempotencykey: String
idempotencykey: String,
label: String?,
spawnedby: String?
) {
self.message = message
self.to = to
@@ -451,6 +455,8 @@ public struct AgentParams: Codable, Sendable {
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.idempotencykey = idempotencykey
self.label = label
self.spawnedby = spawnedby
}
private enum CodingKeys: String, CodingKey {
case message
@@ -464,6 +470,8 @@ public struct AgentParams: Codable, Sendable {
case lane
case extrasystemprompt = "extraSystemPrompt"
case idempotencykey = "idempotencyKey"
case label
case spawnedby = "spawnedBy"
}
}
@@ -693,6 +701,7 @@ public struct SessionsListParams: Codable, Sendable {
public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
@@ -705,6 +714,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public init(
key: String,
label: AnyCodable?,
thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
@@ -716,6 +726,7 @@ public struct SessionsPatchParams: Codable, Sendable {
groupactivation: AnyCodable?
) {
self.key = key
self.label = label
self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
@@ -728,6 +739,7 @@ public struct SessionsPatchParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case label
case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"

View File

@@ -196,6 +196,7 @@ export async function runSubagentAnnounceFlow(params: {
waitForCompletion?: boolean;
startedAt?: number;
endedAt?: number;
label?: string;
}) {
try {
let reply = params.roundOneReply;
@@ -273,6 +274,18 @@ export async function runSubagentAnnounceFlow(params: {
} catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
} finally {
// Patch label after all writes complete
if (params.label) {
try {
await callGateway({
method: "sessions.patch",
params: { key: params.childSessionKey, label: params.label },
timeoutMs: 10_000,
});
} catch {
// Best-effort
}
}
if (params.cleanup === "delete") {
try {
await callGateway({

View File

@@ -11,6 +11,7 @@ export type SubagentRunRecord = {
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
label?: string;
createdAt: number;
startedAt?: number;
endedAt?: number;
@@ -83,6 +84,7 @@ function ensureListener() {
? (evt.data.endedAt as number)
: Date.now();
entry.endedAt = endedAt;
if (!beginSubagentAnnounce(evt.runId)) {
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
@@ -101,6 +103,7 @@ function ensureListener() {
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
@@ -124,6 +127,7 @@ export function registerSubagentRun(params: {
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
label?: string;
}) {
const now = Date.now();
const archiveAfterMs = resolveArchiveAfterMs();
@@ -136,6 +140,7 @@ export function registerSubagentRun(params: {
requesterDisplayKey: params.requesterDisplayKey,
task: params.task,
cleanup: params.cleanup,
label: params.label,
createdAt: now,
startedAt: now,
archiveAtMs,
@@ -175,6 +180,7 @@ async function probeImmediateCompletion(runId: string) {
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(runId);

View File

@@ -25,6 +25,7 @@ type SessionListRow = {
key: string;
kind: SessionKind;
provider: string;
label?: string;
displayName?: string;
updatedAt?: number | null;
sessionId?: string;
@@ -205,6 +206,7 @@ export function createSessionsListTool(opts?: {
key: displayKey,
kind,
provider: derivedProvider,
label: typeof entry.label === "string" ? entry.label : undefined,
displayName:
typeof entry.displayName === "string"
? entry.displayName

View File

@@ -29,11 +29,24 @@ import {
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
});
const SessionsSendToolSchema = Type.Union([
Type.Object(
{
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
Type.Object(
{
label: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
]);
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
@@ -43,13 +56,11 @@ export function createSessionsSendTool(opts?: {
return {
label: "Session Send",
name: "sessions_send",
description: "Send a message into another session.",
description:
"Send a message into another session. Use sessionKey or label to identify the target.",
parameters: SessionsSendToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
@@ -63,42 +74,111 @@ export function createSessionsSendTool(opts?: {
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) {
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
const sessionKeyParam = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label");
if (sessionKeyParam && labelParam) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Provide either sessionKey or label (not both).",
});
}
const listSessions = async (listParams: Record<string, unknown>) => {
const result = (await callGateway({
method: "sessions.list",
params: listParams,
timeoutMs: 10_000,
})) as { sessions?: Array<Record<string, unknown>> };
return Array.isArray(result?.sessions) ? result.sessions : [];
};
const activeMinutes = 24 * 60;
const visibleSessions = restrictToSpawned
? await listSessions({
activeMinutes,
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
})
: undefined;
let sessionKey = sessionKeyParam;
if (!sessionKey && labelParam) {
const sessions =
visibleSessions ??
(await listSessions({
activeMinutes,
includeGlobal: false,
includeUnknown: false,
limit: 500,
}));
const matches = sessions.filter((entry) => {
const label =
typeof entry?.label === "string" ? entry.label : undefined;
return label === labelParam;
});
if (matches.length === 0) {
if (restrictToSpawned) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
});
}
} catch {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `No session found with label: ${labelParam}`,
});
}
if (matches.length > 1) {
const keys = matches
.map((entry) => (typeof entry?.key === "string" ? entry.key : ""))
.filter(Boolean)
.join(", ");
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `Multiple sessions found with label: ${labelParam}${keys ? ` (${keys})` : ""}`,
});
}
const key = matches[0]?.key;
if (typeof key !== "string" || !key.trim()) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `Invalid session entry for label: ${labelParam}`,
});
}
sessionKey = key;
}
if (!sessionKey) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Either sessionKey or label is required",
});
}
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
if (restrictToSpawned) {
const sessions = visibleSessions ?? [];
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",

View File

@@ -126,17 +126,7 @@ export function createSessionsSpawnTool(opts?: {
}
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
if (opts?.sandboxed === true) {
try {
await callGateway({
method: "sessions.patch",
params: { key: childSessionKey, spawnedBy: requesterInternalKey },
timeoutMs: 10_000,
});
} catch {
// best-effort; scoping relies on this metadata but spawning still works without it
}
}
const shouldPatchSpawnedBy = opts?.sandboxed === true;
if (model) {
try {
await callGateway({
@@ -185,6 +175,8 @@ export function createSessionsSpawnTool(opts?: {
lane: "subagent",
extraSystemPrompt: childSystemPrompt,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
},
timeoutMs: 10_000,
})) as { runId?: string };
@@ -214,6 +206,7 @@ export function createSessionsSpawnTool(opts?: {
requesterDisplayKey,
task,
cleanup,
label: label || undefined,
});
return jsonResult({

View File

@@ -1010,6 +1010,10 @@ export async function persistInlineDirectives(params: {
agentCfg,
} = params;
let { provider, model } = params;
const activeAgentId = sessionKey
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, activeAgentId);
if (sessionEntry && sessionStore && sessionKey) {
let updated = false;

View File

@@ -91,7 +91,7 @@ export async function ensureSkillSnapshot(params: {
systemSent: true,
skillsSnapshot: skillSnapshot,
};
sessionStore[sessionKey] = nextEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
@@ -123,7 +123,7 @@ export async function ensureSkillSnapshot(params: {
updatedAt: Date.now(),
skillsSnapshot,
};
sessionStore[sessionKey] = nextEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}

View File

@@ -264,7 +264,7 @@ export async function initSessionState(params: {
ctx.MessageThreadId,
);
}
sessionStore[sessionKey] = sessionEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
await saveSessionStore(storePath, sessionStore);
const sessionCtx: TemplateContext = {

View File

@@ -180,7 +180,10 @@ async function ensureDevWorkspace(dir: string) {
path.join(resolvedDir, "TOOLS.md"),
DEV_TOOLS_TEMPLATE,
);
await writeFileIfMissing(path.join(resolvedDir, "USER.md"), DEV_USER_TEMPLATE);
await writeFileIfMissing(
path.join(resolvedDir, "USER.md"),
DEV_USER_TEMPLATE,
);
await writeFileIfMissing(
path.join(resolvedDir, "HEARTBEAT.md"),
DEV_HEARTBEAT_TEMPLATE,

View File

@@ -23,12 +23,7 @@ describe("parseCliProfileArgs", () => {
});
it("still accepts global --dev before subcommand", () => {
const res = parseCliProfileArgs([
"node",
"clawdbot",
"--dev",
"gateway",
]);
const res = parseCliProfileArgs(["node", "clawdbot", "--dev", "gateway"]);
if (!res.ok) throw new Error(res.error);
expect(res.profile).toBe("dev");
expect(res.argv).toEqual(["node", "clawdbot", "gateway"]);

View File

@@ -80,6 +80,7 @@ import {
DEFAULT_WORKSPACE,
ensureWorkspaceAndSessions,
guardCancel,
openUrl,
printWizardHeader,
probeGatewayReachable,
randomToken,

View File

@@ -113,6 +113,7 @@ export type SessionEntry = {
contextTokens?: number;
compactionCount?: number;
claudeCliSessionId?: string;
label?: string;
displayName?: string;
provider?: string;
subject?: string;

View File

@@ -225,6 +225,8 @@ export const AgentParamsSchema = Type.Object(
lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,
label: Type.Optional(Type.String()),
spawnedBy: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
@@ -322,6 +324,7 @@ export const SessionsListParamsSchema = Type.Object(
export const SessionsPatchParamsSchema = Type.Object(
{
key: NonEmptyString,
label: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),

View File

@@ -397,6 +397,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
}
}
if ("label" in p) {
const raw = p.label;
if (raw === null) {
delete next.label;
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid label: empty",
},
};
}
next.label = trimmed;
}
}
if ("thinkingLevel" in p) {
const raw = p.thinkingLevel;
if (raw === null) {
@@ -628,6 +647,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
model: entry?.model,
contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy,
label: entry?.label,
displayName: entry?.displayName,
chatType: entry?.chatType,
provider: entry?.provider,

View File

@@ -52,6 +52,8 @@ export const agentHandlers: GatewayRequestHandlers = {
extraSystemPrompt?: string;
idempotencyKey: string;
timeout?: number;
label?: string;
spawnedBy?: string;
};
const idem = request.idempotencyKey;
const cached = context.dedupe.get(`agent:${idem}`);
@@ -78,6 +80,8 @@ export const agentHandlers: GatewayRequestHandlers = {
cfgForAgent = cfg;
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
const labelValue = request.label?.trim() || entry?.label;
const spawnedByValue = request.spawnedBy?.trim() || entry?.spawnedBy;
const nextEntry: SessionEntry = {
sessionId,
updatedAt: now,
@@ -91,6 +95,8 @@ export const agentHandlers: GatewayRequestHandlers = {
lastTo: entry?.lastTo,
modelOverride: entry?.modelOverride,
providerOverride: entry?.providerOverride,
label: labelValue,
spawnedBy: spawnedByValue,
};
sessionEntry = nextEntry;
const sendPolicy = resolveSendPolicy({

View File

@@ -169,6 +169,24 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
}
if ("label" in p) {
const raw = p.label;
if (raw === null) {
delete next.label;
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid label: empty"),
);
return;
}
next.label = trimmed;
}
}
if ("thinkingLevel" in p) {
const raw = p.thinkingLevel;
if (raw === null) {
@@ -422,6 +440,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
model: entry?.model,
contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy,
label: entry?.label,
lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,

View File

@@ -101,3 +101,157 @@ describe("sessions_send gateway loopback", () => {
}
});
});
describe("sessions_send label lookup", () => {
it(
"finds session by label and sends message",
{ timeout: 15_000 },
async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const server = await startGatewayServer(port);
const spy = vi.mocked(agentCommand);
spy.mockImplementation(async (opts) => {
const params = opts as {
sessionId?: string;
runId?: string;
extraSystemPrompt?: string;
};
const sessionId = params.sessionId ?? "test-labeled";
const runId = params.runId ?? sessionId;
const sessionFile = resolveSessionTranscriptPath(sessionId);
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "start", startedAt },
});
const text = "labeled response";
const message = {
role: "assistant",
content: [{ type: "text", text }],
};
await fs.appendFile(
sessionFile,
`${JSON.stringify({ message })}\n`,
"utf8",
);
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "end", startedAt, endedAt: Date.now() },
});
});
try {
// First, create a session with a label via sessions.patch
const { callGateway } = await import("./call.js");
await callGateway({
method: "sessions.patch",
params: { key: "test-labeled-session", label: "my-test-worker" },
timeoutMs: 5000,
});
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "sessions_send",
);
if (!tool) throw new Error("missing sessions_send tool");
// Send using label instead of sessionKey
const result = await tool.execute("call-by-label", {
label: "my-test-worker",
message: "hello labeled session",
timeoutSeconds: 5,
});
const details = result.details as {
status?: string;
reply?: string;
sessionKey?: string;
};
expect(details.status).toBe("ok");
expect(details.reply).toBe("labeled response");
expect(details.sessionKey).toBe("agent:main:test-labeled-session");
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
}
await server.close();
}
},
);
it("returns error when label not found", { timeout: 15_000 }, async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const server = await startGatewayServer(port);
try {
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "sessions_send",
);
if (!tool) throw new Error("missing sessions_send tool");
const result = await tool.execute("call-missing-label", {
label: "nonexistent-label",
message: "hello",
timeoutSeconds: 5,
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("No session found with label");
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
}
await server.close();
}
});
it(
"returns error when neither sessionKey nor label provided",
{ timeout: 15_000 },
async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
const server = await startGatewayServer(port);
try {
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "sessions_send",
);
if (!tool) throw new Error("missing sessions_send tool");
const result = await tool.execute("call-no-key", {
message: "hello",
timeoutSeconds: 5,
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain(
"Either sessionKey or label is required",
);
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
}
await server.close();
}
},
);
});

View File

@@ -148,12 +148,23 @@ describe("gateway server sessions", () => {
expect(sendPolicyPatched.ok).toBe(true);
expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny");
const labelPatched = await rpcReq<{
ok: true;
entry: { label?: string };
}>(ws, "sessions.patch", {
key: "agent:main:subagent:one",
label: "Briefing",
});
expect(labelPatched.ok).toBe(true);
expect(labelPatched.payload?.entry.label).toBe("Briefing");
const list2 = await rpcReq<{
sessions: Array<{
key: string;
thinkingLevel?: string;
verboseLevel?: string;
sendPolicy?: string;
label?: string;
}>;
}>(ws, "sessions.list", {});
expect(list2.ok).toBe(true);
@@ -163,6 +174,10 @@ describe("gateway server sessions", () => {
expect(main2?.thinkingLevel).toBe("medium");
expect(main2?.verboseLevel).toBeUndefined();
expect(main2?.sendPolicy).toBe("deny");
const subagent = list2.payload?.sessions.find(
(s) => s.key === "agent:main:subagent:one",
);
expect(subagent?.label).toBe("Briefing");
const spawnedOnly = await rpcReq<{
sessions: Array<{ key: string }>;

View File

@@ -34,6 +34,7 @@ export type GatewaySessionsDefaults = {
export type GatewaySessionRow = {
key: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;
provider?: string;
subject?: string;
@@ -485,6 +486,7 @@ export function listSessionsFromStore(params: {
return {
key,
kind: classifySessionKey(key, entry),
label: entry?.label,
displayName,
provider,
subject,

View File

@@ -49,6 +49,7 @@ export type GatewaySessionList = {
totalTokens?: number | null;
responseUsage?: "on" | "off";
modelProvider?: string;
label?: string;
displayName?: string;
provider?: string;
room?: string;

View File

@@ -215,6 +215,7 @@ export type GatewaySessionsDefaults = {
export type GatewaySessionRow = {
key: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;
surface?: string;
subject?: string;

View File

@@ -117,6 +117,7 @@ export function renderSessions(props: SessionsProps) {
<div class="table" style="margin-top: 16px;">
<div class="table-head">
<div>Key</div>
<div>Label</div>
<div>Kind</div>
<div>Updated</div>
<div>Tokens</div>
@@ -132,7 +133,11 @@ export function renderSessions(props: SessionsProps) {
`;
}
function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsProps["onPatch"]) {
function renderRow(
row: GatewaySessionRow,
basePath: string,
onPatch: SessionsProps["onPatch"],
) {
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
const thinking = row.thinkingLevel ?? "";
const verbose = row.verboseLevel ?? "";
@@ -148,6 +153,7 @@ function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsPr
<div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div>
<div>${row.label ?? ""}</div>
<div>${row.kind}</div>
<div>${updated}</div>
<div>${formatSessionTokens(row)}</div>