fix(ui): show session labels in selector and standardize session key prefixes

- Display session labels in the session selector
- Cap selector width to 300px
- Standardize key prefixes and fallback names for subagent and cron job sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tyler Yust
2026-02-15 14:19:54 -08:00
parent d491c789a3
commit a948212ca7
3 changed files with 282 additions and 39 deletions

View File

@@ -333,7 +333,7 @@
.chat-controls__session {
min-width: 140px;
max-width: 420px;
max-width: 300px;
}
.chat-controls__thinking {
@@ -400,7 +400,7 @@
.chat-controls__session select {
padding: 6px 10px;
font-size: 13px;
max-width: 420px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { SessionsListResult } from "./types.ts";
import { resolveSessionDisplayName } from "./app-render.helpers.ts";
import { parseSessionKey, resolveSessionDisplayName } from "./app-render.helpers.ts";
type SessionRow = SessionsListResult["sessions"][number];
@@ -8,72 +8,238 @@ function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
return { kind: "direct", updatedAt: 0, ...overrides };
}
describe("resolveSessionDisplayName", () => {
it("returns key when no row is provided", () => {
expect(resolveSessionDisplayName("agent:main:main")).toBe("agent:main:main");
/* ================================================================
* parseSessionKey low-level key → type / fallback mapping
* ================================================================ */
describe("parseSessionKey", () => {
it("identifies main session (bare 'main')", () => {
expect(parseSessionKey("main")).toEqual({ prefix: "", fallbackName: "Main Session" });
});
it("returns key when row has no label or displayName", () => {
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
"agent:main:main",
it("identifies main session (agent:main:main)", () => {
expect(parseSessionKey("agent:main:main")).toEqual({
prefix: "",
fallbackName: "Main Session",
});
});
it("identifies subagent sessions", () => {
expect(parseSessionKey("agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253")).toEqual({
prefix: "Subagent:",
fallbackName: "Subagent:",
});
});
it("identifies cron sessions", () => {
expect(parseSessionKey("agent:main:cron:daily-briefing-uuid")).toEqual({
prefix: "Cron:",
fallbackName: "Cron Job:",
});
});
it("identifies direct chat with known channel", () => {
expect(parseSessionKey("agent:main:bluebubbles:direct:+19257864429")).toEqual({
prefix: "",
fallbackName: "iMessage · +19257864429",
});
});
it("identifies direct chat with telegram", () => {
expect(parseSessionKey("agent:main:telegram:direct:user123")).toEqual({
prefix: "",
fallbackName: "Telegram · user123",
});
});
it("identifies group chat with known channel", () => {
expect(parseSessionKey("agent:main:discord:group:guild-chan")).toEqual({
prefix: "",
fallbackName: "Discord Group",
});
});
it("capitalises unknown channels in direct/group patterns", () => {
expect(parseSessionKey("agent:main:mychannel:direct:user1")).toEqual({
prefix: "",
fallbackName: "Mychannel · user1",
});
});
it("identifies channel-prefixed legacy keys", () => {
expect(parseSessionKey("bluebubbles:g-agent-main-bluebubbles-direct-+19257864429")).toEqual({
prefix: "",
fallbackName: "iMessage Session",
});
expect(parseSessionKey("discord:123:456")).toEqual({
prefix: "",
fallbackName: "Discord Session",
});
});
it("handles bare channel name as key", () => {
expect(parseSessionKey("telegram")).toEqual({
prefix: "",
fallbackName: "Telegram Session",
});
});
it("returns raw key for unknown patterns", () => {
expect(parseSessionKey("something-unknown")).toEqual({
prefix: "",
fallbackName: "something-unknown",
});
});
});
/* ================================================================
* resolveSessionDisplayName full resolution with row data
* ================================================================ */
describe("resolveSessionDisplayName", () => {
// ── Key-only fallbacks (no row) ──────────────────
it("returns 'Main Session' for agent:main:main key", () => {
expect(resolveSessionDisplayName("agent:main:main")).toBe("Main Session");
});
it("returns 'Main Session' for bare 'main' key", () => {
expect(resolveSessionDisplayName("main")).toBe("Main Session");
});
it("returns 'Subagent:' for subagent key without row", () => {
expect(resolveSessionDisplayName("agent:main:subagent:abc-123")).toBe("Subagent:");
});
it("returns 'Cron Job:' for cron key without row", () => {
expect(resolveSessionDisplayName("agent:main:cron:abc-123")).toBe("Cron Job:");
});
it("parses direct chat key with channel", () => {
expect(resolveSessionDisplayName("agent:main:bluebubbles:direct:+19257864429")).toBe(
"iMessage · +19257864429",
);
});
it("returns key when displayName matches key", () => {
it("parses channel-prefixed legacy key", () => {
expect(resolveSessionDisplayName("discord:123:456")).toBe("Discord Session");
});
it("returns raw key for unknown patterns", () => {
expect(resolveSessionDisplayName("something-custom")).toBe("something-custom");
});
// ── With row data (label / displayName) ──────────
it("returns parsed fallback when row has no label or displayName", () => {
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
"Main Session",
);
});
it("returns parsed fallback when displayName matches key", () => {
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", displayName: "mykey" }))).toBe(
"mykey",
);
});
it("returns key when label matches key", () => {
it("returns parsed fallback when label matches key", () => {
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", label: "mykey" }))).toBe("mykey");
});
it("uses displayName prominently when available", () => {
expect(
resolveSessionDisplayName(
"discord:123:456",
row({ key: "discord:123:456", displayName: "My Chat" }),
),
).toBe("My Chat (discord:123:456)");
});
it("falls back to label when displayName is absent", () => {
it("uses label alone when available", () => {
expect(
resolveSessionDisplayName(
"discord:123:456",
row({ key: "discord:123:456", label: "General" }),
),
).toBe("General (discord:123:456)");
).toBe("General");
});
it("prefers displayName over label when both are present", () => {
it("falls back to displayName when label is absent", () => {
expect(
resolveSessionDisplayName(
"discord:123:456",
row({ key: "discord:123:456", displayName: "My Chat" }),
),
).toBe("My Chat");
});
it("prefers label over displayName when both are present", () => {
expect(
resolveSessionDisplayName(
"discord:123:456",
row({ key: "discord:123:456", displayName: "My Chat", label: "General" }),
),
).toBe("My Chat (discord:123:456)");
).toBe("General");
});
it("ignores whitespace-only displayName", () => {
it("ignores whitespace-only label and falls back to displayName", () => {
expect(
resolveSessionDisplayName(
"discord:123:456",
row({ key: "discord:123:456", displayName: " ", label: "General" }),
row({ key: "discord:123:456", displayName: "My Chat", label: " " }),
),
).toBe("General (discord:123:456)");
).toBe("My Chat");
});
it("ignores whitespace-only label", () => {
it("uses parsed fallback when whitespace-only label and no displayName", () => {
expect(
resolveSessionDisplayName("discord:123:456", row({ key: "discord:123:456", label: " " })),
).toBe("discord:123:456");
).toBe("Discord Session");
});
it("trims displayName and label", () => {
it("trims label and displayName", () => {
expect(resolveSessionDisplayName("k", row({ key: "k", label: " General " }))).toBe("General");
expect(resolveSessionDisplayName("k", row({ key: "k", displayName: " My Chat " }))).toBe(
"My Chat (k)",
"My Chat",
);
});
// ── Type prefixes applied to labels / displayNames ──
it("prefixes subagent label with Subagent:", () => {
expect(
resolveSessionDisplayName(
"agent:main:subagent:abc-123",
row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }),
),
).toBe("Subagent: maintainer-v2");
});
it("prefixes subagent displayName with Subagent:", () => {
expect(
resolveSessionDisplayName(
"agent:main:subagent:abc-123",
row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }),
),
).toBe("Subagent: Task Runner");
});
it("prefixes cron label with Cron:", () => {
expect(
resolveSessionDisplayName(
"agent:main:cron:abc-123",
row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }),
),
).toBe("Cron: daily-briefing");
});
it("prefixes cron displayName with Cron:", () => {
expect(
resolveSessionDisplayName(
"agent:main:cron:abc-123",
row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }),
),
).toBe("Cron: Nightly Sync");
});
it("does not prefix non-typed sessions with labels", () => {
expect(
resolveSessionDisplayName(
"agent:main:bluebubbles:direct:+19257864429",
row({ key: "agent:main:bluebubbles:direct:+19257864429", label: "Tyler" }),
),
).toBe("Tyler");
});
});

View File

@@ -159,7 +159,7 @@ export function renderChatControls(state: AppViewState) {
sessionOptions,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key}>
html`<option value=${entry.key} title=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
@@ -256,19 +256,96 @@ function resolveMainSessionKey(
return null;
}
/* ── Channel display labels ────────────────────────────── */
const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage",
telegram: "Telegram",
discord: "Discord",
signal: "Signal",
slack: "Slack",
whatsapp: "WhatsApp",
matrix: "Matrix",
email: "Email",
sms: "SMS",
};
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
/** Parsed type / context extracted from a session key. */
export type SessionKeyInfo = {
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
prefix: string;
/** Human-readable fallback when no label / displayName is available. */
fallbackName: string;
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Parse a session key to extract type information and a human-readable
* fallback display name. Exported for testing.
*/
export function parseSessionKey(key: string): SessionKeyInfo {
// ── Main session ─────────────────────────────────
if (key === "main" || key === "agent:main:main") {
return { prefix: "", fallbackName: "Main Session" };
}
// ── Subagent ─────────────────────────────────────
if (key.includes(":subagent:")) {
return { prefix: "Subagent:", fallbackName: "Subagent:" };
}
// ── Cron job ─────────────────────────────────────
if (key.includes(":cron:")) {
return { prefix: "Cron:", fallbackName: "Cron Job:" };
}
// ── Direct chat (agent:<x>:<channel>:direct:<id>) ──
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
if (directMatch) {
const channel = directMatch[1];
const identifier = directMatch[2];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
}
// ── Group chat (agent:<x>:<channel>:group:<id>) ────
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
if (groupMatch) {
const channel = groupMatch[1];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} Group` };
}
// ── Channel-prefixed legacy keys (e.g. "bluebubbles:g-…") ──
for (const ch of KNOWN_CHANNEL_KEYS) {
if (key === ch || key.startsWith(`${ch}:`)) {
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
}
}
// ── Unknown — return key as-is ───────────────────
return { prefix: "", fallbackName: key };
}
export function resolveSessionDisplayName(
key: string,
row?: SessionsListResult["sessions"][number],
) {
const displayName = row?.displayName?.trim() || "";
): string {
const label = row?.label?.trim() || "";
if (displayName && displayName !== key) {
return `${displayName} (${key})`;
}
const displayName = row?.displayName?.trim() || "";
const { prefix, fallbackName } = parseSessionKey(key);
if (label && label !== key) {
return `${label} (${key})`;
return prefix ? `${prefix} ${label}` : label;
}
return key;
if (displayName && displayName !== key) {
return prefix ? `${prefix} ${displayName}` : displayName;
}
return fallbackName;
}
function resolveSessionOptions(