diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 821ecbd3ab..3b330cacef 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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; } diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index c386ccc0f7..ab6ba84396 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -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 & { 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"); + }); }); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index a7e0b9aa2b..dcc8843bae 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -159,7 +159,7 @@ export function renderChatControls(state: AppViewState) { sessionOptions, (entry) => entry.key, (entry) => - html``, )} @@ -256,19 +256,96 @@ function resolveMainSessionKey( return null; } +/* ── Channel display labels ────────────────────────────── */ +const CHANNEL_LABELS: Record = { + 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:::direct:) ── + 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:::group:) ──── + 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(