@ai-matrx/chat 0.7.27 → 0.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Plus, RotateCcw, UserMinus, X } from "lucide-react";
4
+ import { AgentListDropdown } from "@ai-matrx/agents/catalog/react";
5
+ import { MessengerAvatar } from "../MessengerAvatar.js";
6
+ const ROW_BUTTON = "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-[13px] text-foreground hover:bg-accent disabled:opacity-50";
7
+ function Row({ row, action }) {
8
+ return /* @__PURE__ */ jsxs("li", { className: "flex min-w-0 items-center gap-3 px-4 py-2.5", children: [
9
+ /* @__PURE__ */ jsx(MessengerAvatar, { name: row.name, avatar: row.avatar, size: "header" }),
10
+ /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
11
+ /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [
12
+ /* @__PURE__ */ jsx("span", { className: "truncate text-[15px] text-foreground", children: row.name }),
13
+ row.seatLabel ? /* @__PURE__ */ jsx("span", { className: "shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground", children: row.seatLabel }) : null
14
+ ] }),
15
+ row.description ? /* @__PURE__ */ jsx("span", { className: "truncate text-[13px] text-muted-foreground", children: row.description }) : null
16
+ ] }),
17
+ action
18
+ ] });
19
+ }
20
+ function MessengerManageAgents(props) {
21
+ const inList = props.rows.map((r) => r.agentId);
22
+ return /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 flex-col bg-background", "data-messenger-manage": true, children: [
23
+ /* @__PURE__ */ jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-card px-4", children: [
24
+ /* @__PURE__ */ jsx("h2", { className: "min-w-0 flex-1 truncate text-[15px] font-semibold text-foreground", children: "Manage agents" }),
25
+ /* @__PURE__ */ jsx(
26
+ AgentListDropdown,
27
+ {
28
+ onSelect: props.onAdd,
29
+ excludeAgentIds: inList,
30
+ consumerId: "messenger-manage-agents",
31
+ triggerSlot: /* @__PURE__ */ jsxs(
32
+ "button",
33
+ {
34
+ type: "button",
35
+ className: "inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-[13px] font-medium text-primary-foreground hover:bg-primary/90",
36
+ children: [
37
+ /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4" }),
38
+ "Add agent"
39
+ ]
40
+ }
41
+ )
42
+ }
43
+ ),
44
+ /* @__PURE__ */ jsx(
45
+ "button",
46
+ {
47
+ type: "button",
48
+ onClick: props.onClose,
49
+ "aria-label": "Close",
50
+ className: "flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground",
51
+ children: /* @__PURE__ */ jsx(X, { className: "h-5 w-5" })
52
+ }
53
+ )
54
+ ] }),
55
+ /* @__PURE__ */ jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto overflow-x-hidden", children: [
56
+ props.error ? /* @__PURE__ */ jsx("p", { className: "border-b border-border px-4 py-2 text-[13px] text-destructive", children: props.error }) : null,
57
+ /* @__PURE__ */ jsx("h3", { className: "px-4 pb-1 pt-4 text-[12px] font-medium text-muted-foreground", children: "In your list" }),
58
+ /* @__PURE__ */ jsxs("ul", { className: "flex flex-col", children: [
59
+ props.rows.length === 0 ? /* @__PURE__ */ jsx("li", { className: "px-4 py-3 text-[13px] text-muted-foreground", children: "No agents yet" }) : null,
60
+ props.rows.map((row) => /* @__PURE__ */ jsx(
61
+ Row,
62
+ {
63
+ row,
64
+ action: /* @__PURE__ */ jsxs(
65
+ "button",
66
+ {
67
+ type: "button",
68
+ className: ROW_BUTTON,
69
+ disabled: props.pending.has(row.agentId),
70
+ onClick: () => props.onRemove(row.agentId),
71
+ children: [
72
+ /* @__PURE__ */ jsx(UserMinus, { className: "h-4 w-4" }),
73
+ "Remove"
74
+ ]
75
+ }
76
+ )
77
+ },
78
+ row.agentId
79
+ ))
80
+ ] }),
81
+ props.removedStarters.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
82
+ /* @__PURE__ */ jsx("h3", { className: "px-4 pb-1 pt-5 text-[12px] font-medium text-muted-foreground", children: "Removed starters" }),
83
+ /* @__PURE__ */ jsx("ul", { className: "flex flex-col", children: props.removedStarters.map((row) => /* @__PURE__ */ jsx(
84
+ Row,
85
+ {
86
+ row,
87
+ action: /* @__PURE__ */ jsxs(
88
+ "button",
89
+ {
90
+ type: "button",
91
+ className: ROW_BUTTON,
92
+ disabled: props.pending.has(row.agentId),
93
+ onClick: () => props.onRestore(row.agentId),
94
+ children: [
95
+ /* @__PURE__ */ jsx(RotateCcw, { className: "h-4 w-4" }),
96
+ "Restore"
97
+ ]
98
+ }
99
+ )
100
+ },
101
+ row.agentId
102
+ )) })
103
+ ] }) : null
104
+ ] })
105
+ ] });
106
+ }
107
+ export {
108
+ MessengerManageAgents
109
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Every menu the agents messenger shows, as pure builders over plain inputs —
3
+ * each one about the item that was clicked. The source wires the actions; the
4
+ * shell draws the entries (right-click, "⋯" and the header title dropdown).
5
+ */
6
+ import type { MessengerMenuEntry } from "../contract.js";
7
+ import { type ThreadSummary } from "./roster.js";
8
+ export interface TitleMenuInput {
9
+ /** Newest first — whatever pages are loaded. */
10
+ readonly threads: readonly ThreadSummary[];
11
+ /** The open thread; null = a new chat not yet saved. */
12
+ readonly currentConversationId: string | null;
13
+ readonly hasMore: boolean;
14
+ readonly loadingMore: boolean;
15
+ readonly pinnedIds: ReadonlySet<string>;
16
+ readonly now?: Date;
17
+ }
18
+ export interface TitleMenuActions {
19
+ readonly openThread: (conversationId: string) => void;
20
+ readonly newChat: () => void;
21
+ readonly loadMore: () => void;
22
+ readonly togglePin: (conversationId: string) => void;
23
+ }
24
+ /** The header title's dropdown: + New chat, every past thread (current checked), Show more, Pin/Unpin this thread. */
25
+ export declare function buildTitleMenu(input: TitleMenuInput, actions: TitleMenuActions): MessengerMenuEntry[];
26
+ export interface AgentRowMenuInput {
27
+ readonly pinned: boolean;
28
+ /** False for a seat with one fixed thread (the staff seat): no "New chat". */
29
+ readonly canNewChat: boolean;
30
+ }
31
+ export interface AgentRowMenuActions {
32
+ readonly newChat: () => void;
33
+ readonly togglePin: () => void;
34
+ readonly openAgent: () => void;
35
+ readonly editAgent: () => void;
36
+ readonly remove: () => void;
37
+ readonly copyLink: () => void;
38
+ }
39
+ /** An agent row's right-click / "⋯". */
40
+ export declare function buildAgentRowMenu(input: AgentRowMenuInput, actions: AgentRowMenuActions): MessengerMenuEntry[];
41
+ export interface ThreadMenuActions {
42
+ readonly open?: () => void;
43
+ readonly togglePin: () => void;
44
+ readonly rename: () => void;
45
+ readonly openInChat: () => void;
46
+ readonly copyLink: () => void;
47
+ }
48
+ /** A pinned thread row (with Open), or the header of an open thread (without). */
49
+ export declare function buildThreadMenu(pinned: boolean, actions: ThreadMenuActions): MessengerMenuEntry[];
@@ -0,0 +1,89 @@
1
+ import {
2
+ Bot,
3
+ ChevronsDown,
4
+ Link2,
5
+ MessageSquareText,
6
+ MessagesSquare,
7
+ Pencil,
8
+ Pin,
9
+ PinOff,
10
+ Plus,
11
+ SquarePen,
12
+ UserMinus
13
+ } from "lucide-react";
14
+ import { relativeDate, threadTitle } from "./roster.js";
15
+ function buildTitleMenu(input, actions) {
16
+ const { threads, currentConversationId, hasMore, loadingMore, pinnedIds, now } = input;
17
+ const entries = [
18
+ {
19
+ id: "new-chat",
20
+ label: "New chat",
21
+ icon: Plus,
22
+ checked: currentConversationId === null,
23
+ onSelect: actions.newChat
24
+ }
25
+ ];
26
+ if (threads.length > 0) entries.push({ id: "threads", separator: true });
27
+ for (const thread of threads) {
28
+ entries.push({
29
+ id: `thread:${thread.conversationId}`,
30
+ label: threadTitle(thread),
31
+ icon: pinnedIds.has(thread.conversationId) ? Pin : MessageSquareText,
32
+ detail: relativeDate(thread.updatedAt, now),
33
+ checked: thread.conversationId === currentConversationId,
34
+ onSelect: () => actions.openThread(thread.conversationId)
35
+ });
36
+ }
37
+ if (hasMore) {
38
+ entries.push({
39
+ id: "more",
40
+ label: loadingMore ? "Loading\u2026" : "Show more",
41
+ icon: ChevronsDown,
42
+ disabled: loadingMore,
43
+ keepOpen: true,
44
+ onSelect: actions.loadMore
45
+ });
46
+ }
47
+ if (currentConversationId !== null) {
48
+ const pinned = pinnedIds.has(currentConversationId);
49
+ entries.push(
50
+ { id: "pin-sep", separator: true },
51
+ {
52
+ id: pinned ? "unpin-thread" : "pin-thread",
53
+ label: pinned ? "Unpin this chat" : "Pin this chat",
54
+ icon: pinned ? PinOff : Pin,
55
+ onSelect: () => actions.togglePin(currentConversationId)
56
+ }
57
+ );
58
+ }
59
+ return entries;
60
+ }
61
+ function buildAgentRowMenu(input, actions) {
62
+ const entries = [];
63
+ if (input.canNewChat) entries.push({ id: "new-chat", label: "New chat", icon: SquarePen, onSelect: actions.newChat });
64
+ entries.push(
65
+ input.pinned ? { id: "unpin-agent", label: "Unpin agent", icon: PinOff, onSelect: actions.togglePin } : { id: "pin-agent", label: "Pin agent to top", icon: Pin, onSelect: actions.togglePin },
66
+ { id: "open-agent", label: "Open agent", icon: Bot, onSelect: actions.openAgent },
67
+ { id: "edit-agent", label: "Edit agent", icon: Pencil, onSelect: actions.editAgent },
68
+ { id: "copy-link", label: "Copy link", icon: Link2, onSelect: actions.copyLink },
69
+ { id: "remove-sep", separator: true },
70
+ { id: "remove", label: "Remove from list", icon: UserMinus, destructive: true, onSelect: actions.remove }
71
+ );
72
+ return entries;
73
+ }
74
+ function buildThreadMenu(pinned, actions) {
75
+ const entries = [];
76
+ if (actions.open) entries.push({ id: "open", label: "Open", icon: MessageSquareText, onSelect: actions.open });
77
+ entries.push(
78
+ pinned ? { id: "unpin-thread", label: "Unpin", icon: PinOff, onSelect: actions.togglePin } : { id: "pin-thread", label: "Pin this chat", icon: Pin, onSelect: actions.togglePin },
79
+ { id: "rename", label: "Rename", icon: Pencil, onSelect: actions.rename },
80
+ { id: "open-in-chat", label: "Open in chat", icon: MessagesSquare, onSelect: actions.openInChat },
81
+ { id: "copy-link", label: "Copy link", icon: Link2, onSelect: actions.copyLink }
82
+ );
83
+ return entries;
84
+ }
85
+ export {
86
+ buildAgentRowMenu,
87
+ buildThreadMenu,
88
+ buildTitleMenu
89
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The agents messenger's per-person state on the server — `platform.user_entity_state`.
3
+ *
4
+ * Read: the person's own rows, directly (RLS std_select), only those carrying
5
+ * `metadata.messenger`. Write: `platform.ues_merge_metadata`, the caller-scoped
6
+ * SECURITY DEFINER merge (top-level keys; a JSON null removes one). Nothing
7
+ * lives in browser storage.
8
+ */
9
+ import { type AgentSeatState, type MessengerPersonState } from "./roster.js";
10
+ export declare function readMessengerPersonState(): Promise<MessengerPersonState>;
11
+ /** The whole `messenger` object for an agent (the merge is top-level, so it is always written whole). */
12
+ export declare function writeAgentSeat(agentId: string, state: AgentSeatState | null): Promise<void>;
13
+ export declare function writeThreadPin(conversationId: string, pin: {
14
+ agentId: string;
15
+ title: string | null;
16
+ } | null): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { supabase } from "../../host/db.js";
2
+ import { getUserId } from "../../host/identity.js";
3
+ import {
4
+ EMPTY_PERSON_STATE,
5
+ MESSENGER_METADATA_KEY,
6
+ personStateFromRows
7
+ } from "./roster.js";
8
+ async function readMessengerPersonState() {
9
+ const userId = getUserId();
10
+ if (!userId) return EMPTY_PERSON_STATE;
11
+ const { data, error } = await supabase.schema("platform").from("user_entity_state").select("entity_type, entity_id, metadata").eq("user_id", userId).in("entity_type", ["agent", "conversation"]).not(`metadata->${MESSENGER_METADATA_KEY}`, "is", null);
12
+ if (error) throw new Error(error.message);
13
+ return personStateFromRows(data ?? []);
14
+ }
15
+ async function merge(entityType, entityId, value) {
16
+ const { error } = await supabase.schema("platform").rpc("ues_merge_metadata", {
17
+ p_entity_type: entityType,
18
+ p_entity_id: entityId,
19
+ p_patch: { [MESSENGER_METADATA_KEY]: value }
20
+ });
21
+ if (error) throw new Error(error.message);
22
+ }
23
+ function writeAgentSeat(agentId, state) {
24
+ if (state === null) return merge("agent", agentId, null);
25
+ const value = {};
26
+ if (state.in !== void 0) value.in = state.in;
27
+ if (state.pinned) value.pinned = true;
28
+ return merge("agent", agentId, Object.keys(value).length > 0 ? value : null);
29
+ }
30
+ function writeThreadPin(conversationId, pin) {
31
+ return merge(
32
+ "conversation",
33
+ conversationId,
34
+ pin === null ? null : { pinned: true, agentId: pin.agentId, title: pin.title }
35
+ );
36
+ }
37
+ export {
38
+ readMessengerPersonState,
39
+ writeAgentSeat,
40
+ writeThreadPin
41
+ };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * THE AGENTS-YOU-KNOW ROSTER — pure rules, no React, no database.
3
+ *
4
+ * The roster is the starter seats, plus agents the person added, minus seats
5
+ * they removed. Per-person state is `platform.user_entity_state.metadata.messenger`:
6
+ *
7
+ * entity_type "agent" → { in: true | false, pinned?: true }
8
+ * (in:false on a starter's agent = removed)
9
+ * entity_type "conversation" → { pinned: true, agentId, title? }
10
+ *
11
+ * Everything here is deterministic over its inputs so the merge, the rows'
12
+ * order, the title dropdown and every menu are unit-tested without a store.
13
+ */
14
+ /**
15
+ * THE STARTER SEATS — mandate keys, never agent ids: each resolves (through
16
+ * the mandate resolver) to whoever holds that job for this person right now.
17
+ * Order is the roster's order for agents with no threads yet.
18
+ */
19
+ export declare const MESSENGER_STARTER_MANDATE_KEYS: readonly ["messenger.answer_anything", "chat.default_new_chat", "personal_staff.front_line"];
20
+ export type MessengerStarterKey = (typeof MESSENGER_STARTER_MANDATE_KEYS)[number];
21
+ /** The `metadata` key every messenger write lives under. */
22
+ export declare const MESSENGER_METADATA_KEY = "messenger";
23
+ export interface AgentSeatState {
24
+ /** true = added (or kept); false = a starter the person removed. Absent = default. */
25
+ readonly in?: boolean;
26
+ readonly pinned?: boolean;
27
+ }
28
+ export interface ThreadPinState {
29
+ readonly pinned: true;
30
+ readonly agentId: string;
31
+ /** The title when it was pinned — used only until the live title is loaded. */
32
+ readonly title?: string | null;
33
+ }
34
+ export interface MessengerPersonState {
35
+ readonly agents: ReadonlyMap<string, AgentSeatState>;
36
+ readonly threads: ReadonlyMap<string, ThreadPinState>;
37
+ }
38
+ export declare const EMPTY_PERSON_STATE: MessengerPersonState;
39
+ /** A starter seat after the mandate resolver answered (agentId null = not resolved / no holder). */
40
+ export interface ResolvedSeat {
41
+ readonly mandateKey: MessengerStarterKey;
42
+ readonly agentId: string | null;
43
+ }
44
+ export interface RosterAgent {
45
+ readonly agentId: string;
46
+ /** The seat this agent fills, when it is a starter. Turns go through that mandate. */
47
+ readonly mandateKey: MessengerStarterKey | null;
48
+ readonly pinned: boolean;
49
+ }
50
+ export interface RosterMerge {
51
+ /** Who is in the list, starters first (seat order), then added agents. */
52
+ readonly agents: readonly RosterAgent[];
53
+ /** Starters the person removed — Manage offers them back. */
54
+ readonly removedStarters: readonly ResolvedSeat[];
55
+ }
56
+ /** Parse one `user_entity_state` row's metadata into the messenger's state (tolerant of junk). */
57
+ export declare function readMessengerMetadata(metadata: unknown): Record<string, unknown> | null;
58
+ export interface UesRow {
59
+ readonly entity_type: string;
60
+ readonly entity_id: string;
61
+ readonly metadata: unknown;
62
+ }
63
+ /** Rows from `platform.user_entity_state` → the person's messenger state. */
64
+ export declare function personStateFromRows(rows: readonly UesRow[]): MessengerPersonState;
65
+ /** Starters (minus removed) + added agents. A seat whose holder is also added appears once, as the seat. */
66
+ export declare function mergeRoster(seats: readonly ResolvedSeat[], state: MessengerPersonState): RosterMerge;
67
+ export declare const AGENT_ROW_PREFIX = "agent:";
68
+ export declare const THREAD_ROW_PREFIX = "thread:";
69
+ export declare const agentRowId: (agentId: string) => string;
70
+ export declare const threadRowId: (conversationId: string) => string;
71
+ export type RowRef = {
72
+ kind: "agent";
73
+ agentId: string;
74
+ } | {
75
+ kind: "thread";
76
+ conversationId: string;
77
+ };
78
+ export declare function parseRowId(id: string): RowRef | null;
79
+ /** One thread as the list and the dropdown know it. */
80
+ export interface ThreadSummary {
81
+ readonly conversationId: string;
82
+ readonly title: string | null;
83
+ readonly updatedAt: string;
84
+ }
85
+ export declare const UNTITLED_THREAD = "New chat";
86
+ export declare const threadTitle: (t: {
87
+ title?: string | null;
88
+ }) => string;
89
+ export interface RowOrderInput {
90
+ readonly roster: readonly RosterAgent[];
91
+ /** Newest-first threads per agent (whatever page is loaded). */
92
+ readonly threadsByAgent: ReadonlyMap<string, readonly ThreadSummary[]>;
93
+ readonly pinnedThreads: ReadonlyMap<string, ThreadPinState>;
94
+ }
95
+ export type OrderedRow = {
96
+ kind: "thread";
97
+ conversationId: string;
98
+ agentId: string;
99
+ title: string;
100
+ updatedAt: string | null;
101
+ } | {
102
+ kind: "agent";
103
+ agentId: string;
104
+ latest: ThreadSummary | null;
105
+ pinned: boolean;
106
+ };
107
+ /**
108
+ * Pinned threads first (newest activity first), then pinned agents, then every
109
+ * other agent by its latest thread (newest first); agents with no thread keep
110
+ * roster order at the end.
111
+ */
112
+ export declare function orderRows({ roster, threadsByAgent, pinnedThreads }: RowOrderInput): OrderedRow[];
113
+ /** "Just now", "5m", "3h", "Yesterday", "4d", then the short date. Pure; `now` injectable. */
114
+ export declare function relativeDate(iso: string | null | undefined, now?: Date): string;
@@ -0,0 +1,137 @@
1
+ import { MANDATE_KEYS } from "@ai-matrx/agents/mandates";
2
+ const MESSENGER_STARTER_MANDATE_KEYS = [
3
+ MANDATE_KEYS.messenger__answer_anything,
4
+ MANDATE_KEYS.chat__default_new_chat,
5
+ MANDATE_KEYS.personal_staff__front_line
6
+ ];
7
+ const MESSENGER_METADATA_KEY = "messenger";
8
+ const EMPTY_PERSON_STATE = { agents: /* @__PURE__ */ new Map(), threads: /* @__PURE__ */ new Map() };
9
+ function readMessengerMetadata(metadata) {
10
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
11
+ const value = metadata[MESSENGER_METADATA_KEY];
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
13
+ return value;
14
+ }
15
+ function personStateFromRows(rows) {
16
+ const agents = /* @__PURE__ */ new Map();
17
+ const threads = /* @__PURE__ */ new Map();
18
+ for (const row of rows) {
19
+ const m = readMessengerMetadata(row.metadata);
20
+ if (!m) continue;
21
+ if (row.entity_type === "agent") {
22
+ const state = {};
23
+ if (typeof m.in === "boolean") state.in = m.in;
24
+ if (m.pinned === true) state.pinned = true;
25
+ agents.set(row.entity_id, state);
26
+ } else if (row.entity_type === "conversation" && m.pinned === true && typeof m.agentId === "string") {
27
+ threads.set(row.entity_id, {
28
+ pinned: true,
29
+ agentId: m.agentId,
30
+ title: typeof m.title === "string" ? m.title : null
31
+ });
32
+ }
33
+ }
34
+ return { agents, threads };
35
+ }
36
+ function mergeRoster(seats, state) {
37
+ const agents = [];
38
+ const removedStarters = [];
39
+ const seen = /* @__PURE__ */ new Set();
40
+ for (const seat of seats) {
41
+ if (!seat.agentId || seen.has(seat.agentId)) continue;
42
+ const own = state.agents.get(seat.agentId);
43
+ if (own?.in === false) {
44
+ removedStarters.push(seat);
45
+ seen.add(seat.agentId);
46
+ continue;
47
+ }
48
+ seen.add(seat.agentId);
49
+ agents.push({ agentId: seat.agentId, mandateKey: seat.mandateKey, pinned: own?.pinned === true });
50
+ }
51
+ for (const [agentId, own] of state.agents) {
52
+ if (own.in !== true || seen.has(agentId)) continue;
53
+ seen.add(agentId);
54
+ agents.push({ agentId, mandateKey: null, pinned: own.pinned === true });
55
+ }
56
+ return { agents, removedStarters };
57
+ }
58
+ const AGENT_ROW_PREFIX = "agent:";
59
+ const THREAD_ROW_PREFIX = "thread:";
60
+ const agentRowId = (agentId) => `${AGENT_ROW_PREFIX}${agentId}`;
61
+ const threadRowId = (conversationId) => `${THREAD_ROW_PREFIX}${conversationId}`;
62
+ function parseRowId(id) {
63
+ if (id.startsWith(AGENT_ROW_PREFIX)) return { kind: "agent", agentId: id.slice(AGENT_ROW_PREFIX.length) };
64
+ if (id.startsWith(THREAD_ROW_PREFIX)) return { kind: "thread", conversationId: id.slice(THREAD_ROW_PREFIX.length) };
65
+ return null;
66
+ }
67
+ const UNTITLED_THREAD = "New chat";
68
+ const threadTitle = (t) => t.title && t.title.trim() !== "" ? t.title : UNTITLED_THREAD;
69
+ const time = (iso) => iso ? Date.parse(iso) || 0 : 0;
70
+ function orderRows({ roster, threadsByAgent, pinnedThreads }) {
71
+ const findThread = (conversationId) => {
72
+ for (const list of threadsByAgent.values()) {
73
+ const hit = list.find((t) => t.conversationId === conversationId);
74
+ if (hit) return hit;
75
+ }
76
+ return null;
77
+ };
78
+ const threads = [...pinnedThreads].map(([conversationId, pin]) => {
79
+ const live = findThread(conversationId);
80
+ return {
81
+ kind: "thread",
82
+ conversationId,
83
+ agentId: pin.agentId,
84
+ title: threadTitle(live ?? { title: pin.title }),
85
+ updatedAt: live?.updatedAt ?? null
86
+ };
87
+ });
88
+ threads.sort((a, b) => time(b.updatedAt) - time(a.updatedAt));
89
+ const agents = roster.map((agent, index) => ({
90
+ index,
91
+ row: {
92
+ kind: "agent",
93
+ agentId: agent.agentId,
94
+ latest: threadsByAgent.get(agent.agentId)?.[0] ?? null,
95
+ pinned: agent.pinned
96
+ }
97
+ }));
98
+ agents.sort((a, b) => {
99
+ if (a.row.pinned !== b.row.pinned) return a.row.pinned ? -1 : 1;
100
+ const ta = time(a.row.latest?.updatedAt);
101
+ const tb = time(b.row.latest?.updatedAt);
102
+ if (ta !== tb) return tb - ta;
103
+ return a.index - b.index;
104
+ });
105
+ return [...threads, ...agents.map((a) => a.row)];
106
+ }
107
+ function relativeDate(iso, now = /* @__PURE__ */ new Date()) {
108
+ if (!iso) return "";
109
+ const at = new Date(iso);
110
+ if (Number.isNaN(at.getTime())) return "";
111
+ const minutes = Math.floor((now.getTime() - at.getTime()) / 6e4);
112
+ if (minutes < 1) return "Just now";
113
+ if (minutes < 60) return `${minutes}m`;
114
+ const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
115
+ const days = Math.round((startOfDay(now) - startOfDay(at)) / 864e5);
116
+ if (days <= 0) return `${Math.floor(minutes / 60)}h`;
117
+ if (days === 1) return "Yesterday";
118
+ if (days < 7) return `${days}d`;
119
+ return at.toLocaleDateString(void 0, { month: "short", day: "numeric", year: at.getFullYear() === now.getFullYear() ? void 0 : "2-digit" });
120
+ }
121
+ export {
122
+ AGENT_ROW_PREFIX,
123
+ EMPTY_PERSON_STATE,
124
+ MESSENGER_METADATA_KEY,
125
+ MESSENGER_STARTER_MANDATE_KEYS,
126
+ THREAD_ROW_PREFIX,
127
+ UNTITLED_THREAD,
128
+ agentRowId,
129
+ mergeRoster,
130
+ orderRows,
131
+ parseRowId,
132
+ personStateFromRows,
133
+ readMessengerMetadata,
134
+ relativeDate,
135
+ threadRowId,
136
+ threadTitle
137
+ };
@@ -41,6 +41,12 @@ export interface MessengerMenuAction {
41
41
  readonly onSelect: () => void;
42
42
  readonly destructive?: boolean;
43
43
  readonly disabled?: boolean;
44
+ /** Secondary text at the item's right edge (a thread's relative date). */
45
+ readonly detail?: string;
46
+ /** Marks the current choice in a list (the open thread in the title dropdown). */
47
+ readonly checked?: boolean;
48
+ /** Selecting it leaves the menu open ("Show more" paging the same list). */
49
+ readonly keepOpen?: boolean;
44
50
  }
45
51
  export interface MessengerMenuSeparator {
46
52
  readonly id: string;
@@ -130,7 +136,8 @@ export interface MessengerSource {
130
136
  readonly headerMenu?: (contact: MessengerContact) => readonly MessengerMenuEntry[];
131
137
  /**
132
138
  * A dropdown under the header title (past threads + "New chat" for an agent).
133
- * Present: the title opens it. Absent: the title opens the details panel.
139
+ * Present with entries: the title opens it. Absent, or no entries for this
140
+ * contact: the title opens the details panel.
134
141
  */
135
142
  readonly titleMenu?: (contact: MessengerContact) => readonly MessengerMenuEntry[];
136
143
  readonly headerActions?: (contact: MessengerContact) => ReactNode;
@@ -140,6 +147,12 @@ export interface MessengerSource {
140
147
  readonly renderDetails?: (contact: MessengerContact) => ReactNode;
141
148
  /** What the thread area shows with no contact selected. */
142
149
  readonly renderEmpty?: () => ReactNode;
150
+ /**
151
+ * A source-owned screen for the thread area (the agents source's "Manage
152
+ * agents"). Non-null: it replaces the thread and its header until the source
153
+ * returns null again. Chrome stays the shell's; the screen is the source's.
154
+ */
155
+ readonly renderMain?: () => ReactNode | null;
143
156
  }
144
157
  /** The shell's search: case-insensitive over name, preview and `searchText`. */
145
158
  export declare function matchesQuery(contact: MessengerContact, query: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/chat",
3
- "version": "0.7.27",
3
+ "version": "0.7.29",
4
4
  "description": "THE one AI Matrx person-to-agent chat system: the full chat (running agents, conversations, tool calls, streaming, context, surfaces, voice) as explicit per-module subpaths @ai-matrx/chat/<domain>/<module> (no wildcards: scripts/public-modules.json) over a host contract (/host, /host/react) whose only required value is a db. One chat, one renderer: the old kernel entries (., /external-store, /matrx, /react, /tools, styles.css, tokens.css) are retired.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3275,6 +3275,16 @@
3275
3275
  "import": "./dist/messenger/MessengerShell.js",
3276
3276
  "default": "./dist/messenger/MessengerShell.js"
3277
3277
  },
3278
+ "./messenger/agents/AgentsMessenger": {
3279
+ "types": "./dist/messenger/agents/AgentsMessenger.d.ts",
3280
+ "import": "./dist/messenger/agents/AgentsMessenger.js",
3281
+ "default": "./dist/messenger/agents/AgentsMessenger.js"
3282
+ },
3283
+ "./messenger/agents/roster": {
3284
+ "types": "./dist/messenger/agents/roster.d.ts",
3285
+ "import": "./dist/messenger/agents/roster.js",
3286
+ "default": "./dist/messenger/agents/roster.js"
3287
+ },
3278
3288
  "./messenger/contract": {
3279
3289
  "types": "./dist/messenger/contract.d.ts",
3280
3290
  "import": "./dist/messenger/contract.js",
@@ -4706,18 +4716,18 @@
4706
4716
  "server-only": "^0.0.1",
4707
4717
  "uuid": "^14.0.2",
4708
4718
  "zod": "^4.6.5",
4709
- "@ai-matrx/agents": "latest",
4710
- "@ai-matrx/alchemy": "latest",
4711
4719
  "@ai-matrx/associations": "latest",
4712
4720
  "@ai-matrx/browser-audio": "latest",
4721
+ "@ai-matrx/alchemy": "latest",
4713
4722
  "@ai-matrx/canvas": "latest",
4714
4723
  "@ai-matrx/content-ir": "latest",
4715
4724
  "@ai-matrx/content-ir-react": "latest",
4716
4725
  "@ai-matrx/data": "latest",
4726
+ "@ai-matrx/diff": "latest",
4717
4727
  "@ai-matrx/design-system": "latest",
4718
4728
  "@ai-matrx/icons": "latest",
4719
4729
  "@ai-matrx/kit": "latest",
4720
- "@ai-matrx/diff": "latest",
4730
+ "@ai-matrx/media": "latest",
4721
4731
  "@ai-matrx/messaging": "latest",
4722
4732
  "@ai-matrx/print": "latest",
4723
4733
  "@ai-matrx/realtime": "latest",
@@ -4725,7 +4735,7 @@
4725
4735
  "@ai-matrx/records-ui": "latest",
4726
4736
  "@ai-matrx/rich-content": "latest",
4727
4737
  "@ai-matrx/rich-editor": "latest",
4728
- "@ai-matrx/media": "latest"
4738
+ "@ai-matrx/agents": "latest"
4729
4739
  },
4730
4740
  "devDependencies": {
4731
4741
  "@arethetypeswrong/cli": "^0.18.5",
@@ -4844,5 +4854,5 @@
4844
4854
  "check:side-effects": "node ../scripts/check-side-effects-listed.mjs . && node ../scripts/check-side-effects-listed.mjs --self-test",
4845
4855
  "check:public-surface": "node ./scripts/sync-public-surface.mjs --check && node ./scripts/sync-public-surface.mjs --self-test"
4846
4856
  },
4847
- "gitHead": "da1cefa8706b65633e000a46c417e22d410d499f"
4857
+ "gitHead": "4d71c2b833fcf4ddf381519c7ceda59cfa0174db"
4848
4858
  }