diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a1efd02b..6073b8224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - 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 - Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos @@ -67,6 +68,7 @@ - Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. - Commands: return /status in directive-only multi-line messages. +- Models: fall back to configured models when the provider catalog is unavailable. - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist ## 2026.1.8 diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 84329a656..97e5fcfa6 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -11,7 +11,7 @@ read_when: - No estimated costs; only the provider-reported windows. ## Where it shows up -- `/status` in chats: compact one‑liner with session tokens + estimated cost (API key only) and provider quota windows when available. +- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available. - `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). diff --git a/docs/token-use.md b/docs/token-use.md index d142dcfc4..d628b1fb6 100644 --- a/docs/token-use.md +++ b/docs/token-use.md @@ -38,7 +38,7 @@ Everything the model receives counts toward the context limit: Use these in chat: -- `/status` → **compact one‑liner** with the session model, context usage, +- `/status` → **emoji‑rich status card** with the session model, context usage, last response input/output tokens, and **estimated cost** (API key only). - `/cost on|off` → appends a **per-response usage line** to every reply. - Persists per session (stored as `responseUsage`). diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index da18e9ddf..fcb3087c8 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -933,6 +933,36 @@ describe("directive behavior", () => { }); }); + it("falls back to configured models when catalog is unavailable", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model", From: "+1222", To: "+1222" }, + {}, + { + agent: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Model catalog unavailable"); + expect(text).toContain("anthropic/claude-opus-4-5"); + expect(text).toContain("openai/gpt-4.1-mini"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("does not repeat missing auth labels on /model list", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index f6d3a1a71..d798bab5f 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -213,7 +213,7 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("status"); + expect(text).toContain("ClawdBot"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 0191ba6ce..7876242ee 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -165,6 +165,7 @@ export async function buildStatusReply(params: { defaultGroupActivation()) : undefined; const statusText = buildStatusMessage({ + config: cfg, agent: { ...cfg.agent, model: { @@ -566,6 +567,7 @@ export async function handleCommands(params: { const reply = await buildStatusReply({ cfg, command, + provider: command.provider, sessionEntry, sessionKey, sessionScope, diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index eb62f0d9b..44e3fe279 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -379,7 +379,87 @@ export async function handleDirectiveOnly(params: { modelDirective === "status" || modelDirective === "list"; if (!directives.rawModelDirective || isModelListAlias) { if (allowedModelCatalog.length === 0) { - return { text: "No models available." }; + const resolvedDefault = resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider, + defaultModel, + }); + const fallbackKeys = new Set(); + const fallbackCatalog: Array<{ + provider: string; + id: string; + }> = []; + for (const raw of Object.keys(params.cfg.agent?.models ?? {})) { + const resolved = resolveModelRefFromString({ + raw: String(raw), + defaultProvider, + aliasIndex, + }); + if (!resolved) continue; + const key = modelKey(resolved.ref.provider, resolved.ref.model); + if (fallbackKeys.has(key)) continue; + fallbackKeys.add(key); + fallbackCatalog.push({ + provider: resolved.ref.provider, + id: resolved.ref.model, + }); + } + if (fallbackCatalog.length === 0 && resolvedDefault.model) { + const key = modelKey(resolvedDefault.provider, resolvedDefault.model); + fallbackKeys.add(key); + fallbackCatalog.push({ + provider: resolvedDefault.provider, + id: resolvedDefault.model, + }); + } + if (fallbackCatalog.length === 0) { + return { text: "No models available." }; + } + const agentDir = resolveClawdbotAgentDir(); + const modelsPath = `${agentDir}/models.json`; + const formatPath = (value: string) => shortenHomePath(value); + const authByProvider = new Map(); + for (const entry of fallbackCatalog) { + if (authByProvider.has(entry.provider)) continue; + const auth = await resolveAuthLabel( + entry.provider, + params.cfg, + modelsPath, + ); + authByProvider.set(entry.provider, formatAuthLabel(auth)); + } + const current = `${params.provider}/${params.model}`; + const defaultLabel = `${defaultProvider}/${defaultModel}`; + const lines = [ + `Current: ${current}`, + `Default: ${defaultLabel}`, + `Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`, + `⚠️ Model catalog unavailable; showing configured models only.`, + ]; + const byProvider = new Map(); + for (const entry of fallbackCatalog) { + const models = byProvider.get(entry.provider); + if (models) { + models.push(entry); + continue; + } + byProvider.set(entry.provider, [entry]); + } + for (const provider of byProvider.keys()) { + const models = byProvider.get(provider); + if (!models) continue; + const authLabel = authByProvider.get(provider) ?? "missing"; + lines.push(""); + lines.push(`[${provider}] auth: ${authLabel}`); + for (const entry of models) { + const label = `${entry.provider}/${entry.id}`; + const aliases = aliasIndex.byKey.get(label); + const aliasSuffix = + aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : ""; + lines.push(` • ${label}${aliasSuffix}`); + } + } + return { text: lines.join("\n") }; } const agentDir = resolveClawdbotAgentDir(); const modelsPath = `${agentDir}/models.json`; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index debf61ad1..cf3db66d0 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; import { buildCommandsMessage, buildStatusMessage } from "./status.js"; const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const; @@ -45,6 +46,27 @@ afterEach(() => { describe("buildStatusMessage", () => { it("summarizes agent readiness and context usage", () => { const text = buildStatusMessage({ + config: { + models: { + providers: { + anthropic: { + apiKey: "test-key", + models: [ + { + id: "pi:opus", + cost: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + }, + ], + }, + }, + }, + }, + } as ClawdbotConfig, agent: { model: "anthropic/pi:opus", contextTokens: 32_000, @@ -52,6 +74,8 @@ describe("buildStatusMessage", () => { sessionEntry: { sessionId: "abc", updatedAt: 0, + inputTokens: 1200, + outputTokens: 800, totalTokens: 16_000, contextTokens: 32_000, thinkingLevel: "low", @@ -64,17 +88,22 @@ describe("buildStatusMessage", () => { resolvedVerbose: "off", queue: { mode: "collect", depth: 0 }, modelAuth: "api-key", + now: 10 * 60_000, // 10 minutes later }); - expect(text).toContain("status agent:main:main"); - expect(text).toContain("model anthropic/pi:opus (api-key)"); - expect(text).toContain("Context 16k/32k (50%)"); - expect(text).toContain("compactions 2"); - expect(text).toContain("think medium"); - expect(text).toContain("verbose off"); - expect(text).toContain("reasoning off"); - expect(text).toContain("elevated on"); - expect(text).toContain("queue collect"); + expect(text).toContain("🦞 ClawdBot"); + expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key"); + expect(text).toContain("🧮 Tokens: 1.2k in / 800 out"); + expect(text).toContain("💵 Cost: $0.0020"); + expect(text).toContain("Context: 16k/32k (50%)"); + expect(text).toContain("🧹 Compactions: 2"); + expect(text).toContain("Session: agent:main:main"); + expect(text).toContain("updated 10m ago"); + expect(text).toContain("Runtime: direct"); + expect(text).toContain("Think: medium"); + expect(text).toContain("Verbose: off"); + expect(text).toContain("Elevated: on"); + expect(text).toContain("Queue: collect"); }); it("shows verbose/elevated labels only when enabled", () => { @@ -89,8 +118,8 @@ describe("buildStatusMessage", () => { queue: { mode: "collect", depth: 0 }, }); - expect(text).toContain("verbose on"); - expect(text).toContain("elevated on"); + expect(text).toContain("Verbose: on"); + expect(text).toContain("Elevated: on"); }); it("prefers model overrides over last-run model", () => { @@ -114,7 +143,7 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("model openai/gpt-4.1-mini"); + expect(text).toContain("🧠 Model: openai/gpt-4.1-mini"); }); it("keeps provider prefix from configured model", () => { @@ -127,7 +156,7 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("model google-antigravity/claude-sonnet-4-5"); + expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5"); }); it("handles missing agent config gracefully", () => { @@ -138,9 +167,9 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("model"); - expect(text).toContain("Context"); - expect(text).toContain("queue collect"); + expect(text).toContain("🧠 Model:"); + expect(text).toContain("Context:"); + expect(text).toContain("Queue: collect"); }); it("includes group activation for group sessions", () => { @@ -158,7 +187,7 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("activation always"); + expect(text).toContain("Activation: always"); }); it("shows queue details when overridden", () => { @@ -179,7 +208,7 @@ describe("buildStatusMessage", () => { }); expect(text).toContain( - "queue collect (depth 3 · debounce 2s · cap 5 · drop old)", + "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)", ); }); @@ -194,7 +223,43 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("📊 Usage: Claude 80% left (5h)"); + const lines = text.split("\n"); + const contextIndex = lines.findIndex((line) => line.startsWith("📚 ")); + expect(contextIndex).toBeGreaterThan(-1); + expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)"); + }); + + it("hides cost when not using an API key", () => { + const text = buildStatusMessage({ + config: { + models: { + providers: { + anthropic: { + models: [ + { + id: "claude-opus-4-5", + cost: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + }, + ], + }, + }, + }, + }, + } as ClawdbotConfig, + agent: { model: "anthropic/claude-opus-4-5" }, + sessionEntry: { sessionId: "c1", updatedAt: 0, inputTokens: 10 }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + modelAuth: "oauth", + }); + + expect(text).not.toContain("💵 Cost:"); }); it("prefers cached prompt tokens from the session log", async () => { @@ -257,7 +322,7 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("Context 1.0k/32k"); + expect(text).toContain("Context: 1.0k/32k"); } finally { restoreHomeEnv(previousHome); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 9cba16582..ba854686c 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -15,6 +15,7 @@ import { } from "../agents/usage.js"; import type { ClawdbotConfig } from "../config/config.js"; import { + resolveMainSessionKey, resolveSessionFilePath, type SessionEntry, type SessionScope, @@ -63,6 +64,7 @@ type StatusArgs = { usageLine?: string; queue?: QueueStatus; includeTranscriptUsage?: boolean; + now?: number; }; const formatTokens = ( @@ -85,6 +87,17 @@ export const formatContextUsageShort = ( contextTokens: number | null | undefined, ) => `Context ${formatTokens(total, contextTokens ?? null)}`; +const formatAge = (ms?: number | null) => { + if (!ms || ms < 0) return "unknown"; + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +}; + const formatQueueDetails = (queue?: QueueStatus) => { if (!queue) return ""; const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null; @@ -169,10 +182,11 @@ const formatUsagePair = (input?: number | null, output?: number | null) => { const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; const outputLabel = typeof output === "number" ? formatTokenCount(output) : "?"; - return `usage ${inputLabel} in / ${outputLabel} out`; + return `🧮 Tokens: ${inputLabel} in / ${outputLabel} out`; }; export function buildStatusMessage(args: StatusArgs): string { + const now = args.now ?? Date.now(); const entry = args.sessionEntry; const resolved = resolveConfiguredModelRef({ cfg: { agent: args.agent ?? {} }, @@ -222,6 +236,33 @@ export function buildStatusMessage(args: StatusArgs): string { args.agent?.elevatedDefault ?? "on"; + const runtime = (() => { + const sandboxMode = args.agent?.sandbox?.mode ?? "off"; + if (sandboxMode === "off") return { label: "direct" }; + const sessionScope = args.sessionScope ?? "per-sender"; + const mainKey = resolveMainSessionKey({ + session: { scope: sessionScope }, + }); + const sessionKey = args.sessionKey?.trim(); + const sandboxed = sessionKey + ? sandboxMode === "all" || sessionKey !== mainKey.trim() + : false; + const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown"; + return { + label: `${runtime}/${sandboxMode}`, + }; + })(); + + const updatedAt = entry?.updatedAt; + const sessionLine = [ + `Session: ${args.sessionKey ?? "unknown"}`, + typeof updatedAt === "number" + ? `updated ${formatAge(now - updatedAt)}` + : "no activity", + ] + .filter(Boolean) + .join(" • "); + const isGroupSession = entry?.chatType === "group" || entry?.chatType === "room" || @@ -232,8 +273,30 @@ export function buildStatusMessage(args: StatusArgs): string { ? (args.groupActivation ?? entry?.groupActivation ?? "mention") : undefined; - const authMode = - args.modelAuth ?? resolveModelAuthMode(provider, args.config); + const contextLine = [ + `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`, + `🧹 Compactions: ${entry?.compactionCount ?? 0}`, + ] + .filter(Boolean) + .join(" · "); + + const queueMode = args.queue?.mode ?? "unknown"; + const queueDetails = formatQueueDetails(args.queue); + const optionParts = [ + `Runtime: ${runtime.label}`, + `Think: ${thinkLevel}`, + `Verbose: ${verboseLevel}`, + reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, + `Elevated: ${elevatedLevel}`, + ]; + const optionsLine = optionParts.filter(Boolean).join(" · "); + const activationParts = [ + groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null, + `🪢 Queue: ${queueMode}${queueDetails}`, + ]; + const activationLine = activationParts.filter(Boolean).join(" · "); + + const authMode = resolveModelAuthMode(provider, args.config); const showCost = authMode === "api-key"; const costConfig = showCost ? resolveModelCostConfig({ @@ -256,36 +319,30 @@ export function buildStatusMessage(args: StatusArgs): string { : undefined; const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined; - const parts: Array = []; - parts.push(`status ${args.sessionKey ?? "unknown"}`); - const modelLabel = model ? `${provider}/${model}` : "unknown"; - const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : ""; - parts.push(`model ${modelLabel}${authLabel}`); - + const authLabelValue = + args.modelAuth ?? + (authMode && authMode !== "unknown" ? authMode : undefined); + const authLabel = authLabelValue ? ` · 🔑 ${authLabelValue}` : ""; + const modelLine = `🧠 Model: ${modelLabel}${authLabel}`; + const commit = resolveCommitHash(); + const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`; const usagePair = formatUsagePair(inputTokens, outputTokens); - if (usagePair) parts.push(usagePair); - if (costLabel) parts.push(`cost ${costLabel}`); + const costLine = costLabel ? `💵 Cost: ${costLabel}` : null; - const contextSummary = formatContextUsageShort( - totalTokens && totalTokens > 0 ? totalTokens : null, - contextTokens ?? null, - ); - parts.push(contextSummary); - parts.push(`compactions ${entry?.compactionCount ?? 0}`); - parts.push(`think ${thinkLevel}`); - parts.push(`verbose ${verboseLevel}`); - parts.push(`reasoning ${reasoningLevel}`); - parts.push(`elevated ${elevatedLevel}`); - if (groupActivationValue) parts.push(`activation ${groupActivationValue}`); - - const queueMode = args.queue?.mode ?? "unknown"; - const queueDetails = formatQueueDetails(args.queue); - parts.push(`queue ${queueMode}${queueDetails}`); - - if (args.usageLine) parts.push(args.usageLine); - - return parts.filter(Boolean).join(" · "); + return [ + versionLine, + modelLine, + usagePair, + costLine, + `📚 ${contextLine}`, + args.usageLine, + `🧵 ${sessionLine}`, + `⚙️ ${optionsLine}`, + activationLine, + ] + .filter(Boolean) + .join("\n"); } export function buildHelpMessage(): string { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index f4ef315ec..58965472e 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -454,6 +454,15 @@ background: rgba(0, 0, 0, 0.2); } +.session-link { + text-decoration: none; + color: var(--accent); +} + +.session-link:hover { + text-decoration: underline; +} + .log-stream { border: 1px solid var(--border); border-radius: 14px; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c19820d4e..873f99a93 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -358,6 +358,7 @@ export function renderApp(state: AppViewState) { limit: state.sessionsFilterLimit, includeGlobal: state.sessionsIncludeGlobal, includeUnknown: state.sessionsIncludeUnknown, + basePath: state.basePath, onFiltersChange: (next) => { state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterLimit = next.limit; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 6e959494e..f26a40315 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -824,27 +824,37 @@ export class ClawdbotApp extends LitElement { const params = new URLSearchParams(window.location.search); const tokenRaw = params.get("token"); const passwordRaw = params.get("password"); - let changed = false; + const sessionRaw = params.get("session"); + let shouldCleanUrl = false; if (tokenRaw != null) { const token = tokenRaw.trim(); if (token && !this.settings.token) { this.applySettings({ ...this.settings, token }); - changed = true; } params.delete("token"); + shouldCleanUrl = true; } if (passwordRaw != null) { const password = passwordRaw.trim(); if (password) { this.password = password; - changed = true; } params.delete("password"); + shouldCleanUrl = true; } - if (!changed && tokenRaw == null && passwordRaw == null) return; + if (sessionRaw != null) { + const session = sessionRaw.trim(); + if (session) { + this.sessionKey = session; + } + params.delete("session"); + shouldCleanUrl = true; + } + + if (!shouldCleanUrl) return; const url = new URL(window.location.href); url.search = params.toString(); window.history.replaceState({}, "", url.toString()); diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index c82526538..47e910750 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; import { formatSessionTokens } from "../presenter"; +import { pathForTab } from "../navigation"; import type { GatewaySessionRow, SessionsListResult } from "../types"; export type SessionsProps = { @@ -12,6 +13,7 @@ export type SessionsProps = { limit: string; includeGlobal: boolean; includeUnknown: boolean; + basePath: string; onFiltersChange: (next: { activeMinutes: string; limit: string; @@ -118,19 +120,27 @@ export function renderSessions(props: SessionsProps) { ${rows.length === 0 ? html`
No sessions found.
` - : rows.map((row) => renderRow(row, props.onPatch))} + : rows.map((row) => renderRow(row, props.basePath, props.onPatch))} `; } -function renderRow(row: GatewaySessionRow, 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 ?? ""; + const displayName = row.displayName ?? row.key; + const canLink = row.kind !== "global"; + const chatUrl = canLink + ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}` + : null; + return html`
-
${row.displayName ?? row.key}
+
${canLink + ? html`${displayName}` + : displayName}
${row.kind}
${updated}
${formatSessionTokens(row)}