feat(FN-989): add inter-agent messaging system with mailbox UI and CLI commands
- Add Message types (Message, MessageThread, MessageRecipient) and exports to @fusion/core - Create MessageStore with full CRUD: send, read, delete, inbox, threads, and search - Add messages table migration (schema v12) with SQLite full-text search support - Add REST API routes for messaging (CRUD, search, broadcast, unread count) - Add frontend API client functions for all messaging endpoints - Build MailboxModal and MessageComposer dashboard components with header integration - Add CLI message commands (inbox, send, read, delete) with rich output formatting - Add comprehensive test coverage for MessageStore, CLI commands, and UI components - Update documentation (CLI STANDALONE.md, dashboard README) with messaging usage
This commit is contained in:
@@ -108,6 +108,17 @@ fn task import owner/repo --limit 10 --labels "bug,enhancement"
|
||||
```bash
|
||||
fn agent stop <agent-id> # Stop (pause) a running agent
|
||||
fn agent start <agent-id> # Start (resume) a stopped agent
|
||||
fn agent mailbox <agent-id> # View an agent's mailbox
|
||||
```
|
||||
|
||||
### Messaging
|
||||
|
||||
```bash
|
||||
fn message inbox # List inbox messages
|
||||
fn message outbox # List sent messages
|
||||
fn message send <agent-id> <msg> # Send a message to an agent
|
||||
fn message read <id> # Read a specific message
|
||||
fn message delete <id> # Delete a message
|
||||
```
|
||||
|
||||
### Typical workflow
|
||||
|
||||
@@ -49,6 +49,7 @@ const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runM
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
||||
const { runInit } = await import("./commands/init.js");
|
||||
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
|
||||
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
|
||||
|
||||
const HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
@@ -109,6 +110,12 @@ Usage:
|
||||
fn git fetch [remote] Fetch from remote (default: origin)
|
||||
fn agent stop <id> Stop a running agent (pause execution)
|
||||
fn agent start <id> Start a stopped agent (resume execution)
|
||||
fn agent mailbox <id> View an agent's mailbox
|
||||
fn message inbox List inbox messages
|
||||
fn message outbox List sent messages
|
||||
fn message send <agent-id> <msg> Send a message to an agent
|
||||
fn message read <id> Read a specific message
|
||||
fn message delete <id> Delete a message
|
||||
fn backup --create Create a database backup immediately
|
||||
fn backup --list List all database backups
|
||||
fn backup --restore <file> Restore database from a backup file
|
||||
@@ -746,9 +753,56 @@ async function main() {
|
||||
await runAgentStart(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "mailbox": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn agent mailbox <id>"); process.exit(1); }
|
||||
await runAgentMailbox(id, projectName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: agent ${subcommand || ""}`);
|
||||
console.log("Try: fn agent stop <id> | fn agent start <id>");
|
||||
console.log("Try: fn agent stop <id> | fn agent start <id> | fn agent mailbox <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "inbox": {
|
||||
await runMessageInbox(projectName);
|
||||
break;
|
||||
}
|
||||
case "outbox": {
|
||||
await runMessageOutbox(projectName);
|
||||
break;
|
||||
}
|
||||
case "send": {
|
||||
const toId = args[2];
|
||||
const content = args[3];
|
||||
if (!toId || !content) {
|
||||
console.error("Usage: fn message send <agent-id> <content>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runMessageSend(toId, content, projectName);
|
||||
break;
|
||||
}
|
||||
case "read": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn message read <id>"); process.exit(1); }
|
||||
await runMessageRead(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn message delete <id>"); process.exit(1); }
|
||||
await runMessageDelete(id, projectName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: message ${subcommand || ""}`);
|
||||
console.log("Try: fn message inbox | fn message outbox | fn message send | fn message read | fn message delete");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
291
packages/cli/src/commands/__tests__/message.test.ts
Normal file
291
packages/cli/src/commands/__tests__/message.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ── Mock MessageStore ────────────────────────────────────────────────
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetInbox = vi.fn();
|
||||
const mockGetOutbox = vi.fn();
|
||||
const mockGetMailbox = vi.fn();
|
||||
const mockGetMessage = vi.fn();
|
||||
const mockSendMessage = vi.fn();
|
||||
const mockMarkAsRead = vi.fn();
|
||||
const mockDeleteMessage = vi.fn();
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
MessageStore: vi.fn().mockImplementation(() => ({
|
||||
init: mockInit,
|
||||
getInbox: mockGetInbox,
|
||||
getOutbox: mockGetOutbox,
|
||||
getMailbox: mockGetMailbox,
|
||||
getMessage: mockGetMessage,
|
||||
sendMessage: mockSendMessage,
|
||||
markAsRead: mockMarkAsRead,
|
||||
deleteMessage: mockDeleteMessage,
|
||||
})),
|
||||
}));
|
||||
|
||||
// ── Mock project-context ─────────────────────────────────────────────
|
||||
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockResolvedValue({
|
||||
projectId: "test-project",
|
||||
projectPath: "/tmp/test-project",
|
||||
projectName: "test-project",
|
||||
isRegistered: true,
|
||||
store: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Spies ────────────────────────────────────────────────────────────
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit");
|
||||
}) as any);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// ── Import after mocks ───────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
runMessageInbox,
|
||||
runMessageOutbox,
|
||||
runMessageSend,
|
||||
runMessageRead,
|
||||
runMessageDelete,
|
||||
runAgentMailbox,
|
||||
} from "../message.js";
|
||||
|
||||
// ── Test Data ─────────────────────────────────────────────────────────
|
||||
|
||||
const mockMessage = {
|
||||
id: "msg-001",
|
||||
fromId: "agent-001",
|
||||
fromType: "agent" as const,
|
||||
toId: "cli",
|
||||
toType: "user" as const,
|
||||
content: "Hello from the agent",
|
||||
type: "agent-to-user" as const,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockReadMessage = {
|
||||
...mockMessage,
|
||||
id: "msg-002",
|
||||
read: true,
|
||||
content: "This is read",
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("runMessageInbox", () => {
|
||||
beforeEach(() => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 2, ownerId: "cli", ownerType: "user" });
|
||||
mockGetInbox.mockResolvedValue([mockMessage, mockReadMessage]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should list inbox messages with unread count", async () => {
|
||||
await runMessageInbox();
|
||||
|
||||
expect(mockGetInbox).toHaveBeenCalledWith("cli", "user", { limit: 20 });
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Inbox"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("2 unread"));
|
||||
});
|
||||
|
||||
it("should show 'No messages' when inbox is empty", async () => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 0, ownerId: "cli", ownerType: "user" });
|
||||
mockGetInbox.mockResolvedValue([]);
|
||||
|
||||
await runMessageInbox();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No messages"));
|
||||
});
|
||||
|
||||
it("should show unread marker for unread messages", async () => {
|
||||
await runMessageInbox();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("●"));
|
||||
});
|
||||
|
||||
it("should truncate long messages", async () => {
|
||||
mockGetInbox.mockResolvedValue([{
|
||||
...mockMessage,
|
||||
content: "A".repeat(200),
|
||||
}]);
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 1, ownerId: "cli", ownerType: "user" });
|
||||
|
||||
await runMessageInbox();
|
||||
|
||||
// Should truncate to 80 chars + "…"
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("…"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageOutbox", () => {
|
||||
beforeEach(() => {
|
||||
mockGetOutbox.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should list sent messages", async () => {
|
||||
const sentMessage = {
|
||||
...mockMessage,
|
||||
fromId: "cli",
|
||||
fromType: "user" as const,
|
||||
toId: "agent-001",
|
||||
toType: "agent" as const,
|
||||
type: "user-to-agent" as const,
|
||||
};
|
||||
mockGetOutbox.mockResolvedValue([sentMessage]);
|
||||
|
||||
await runMessageOutbox();
|
||||
|
||||
expect(mockGetOutbox).toHaveBeenCalledWith("cli", "user", { limit: 20 });
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Outbox"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent agent-001"));
|
||||
});
|
||||
|
||||
it("should show 'No sent messages' when outbox is empty", async () => {
|
||||
mockGetOutbox.mockResolvedValue([]);
|
||||
|
||||
await runMessageOutbox();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No sent messages"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageSend", () => {
|
||||
beforeEach(() => {
|
||||
mockSendMessage.mockResolvedValue(mockMessage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should send a message to an agent", async () => {
|
||||
await runMessageSend("agent-001", "Hello agent!");
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fromId: "cli",
|
||||
fromType: "user",
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "Hello agent!",
|
||||
type: "user-to-agent",
|
||||
}),
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Message sent"));
|
||||
});
|
||||
|
||||
it("should show the message ID after sending", async () => {
|
||||
await runMessageSend("agent-001", "Test message");
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("msg-001"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageRead", () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessage.mockResolvedValue(mockMessage);
|
||||
mockMarkAsRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should display a message and mark as read", async () => {
|
||||
await runMessageRead("msg-001");
|
||||
|
||||
expect(mockGetMessage).toHaveBeenCalledWith("msg-001");
|
||||
expect(mockMarkAsRead).toHaveBeenCalledWith("msg-001");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("msg-001"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Hello from the agent"));
|
||||
});
|
||||
|
||||
it("should show message details", async () => {
|
||||
await runMessageRead("msg-001");
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("agent-to-user"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent agent-001"));
|
||||
});
|
||||
|
||||
it("should not mark as read if already read", async () => {
|
||||
mockGetMessage.mockResolvedValue(mockReadMessage);
|
||||
|
||||
await runMessageRead("msg-002");
|
||||
|
||||
expect(mockMarkAsRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should exit with error for missing message", async () => {
|
||||
mockGetMessage.mockResolvedValue(null);
|
||||
|
||||
await expect(runMessageRead("msg-nonexistent")).rejects.toThrow("process.exit");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("not found"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageDelete", () => {
|
||||
beforeEach(() => {
|
||||
mockDeleteMessage.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should delete a message", async () => {
|
||||
await runMessageDelete("msg-001");
|
||||
|
||||
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Message msg-001 deleted"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAgentMailbox", () => {
|
||||
beforeEach(() => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 1, ownerId: "agent-001", ownerType: "agent" });
|
||||
mockGetInbox.mockResolvedValue([mockMessage]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should show agent mailbox with unread count", async () => {
|
||||
await runAgentMailbox("agent-001");
|
||||
|
||||
expect(mockGetMailbox).toHaveBeenCalledWith("agent-001", "agent");
|
||||
expect(mockGetInbox).toHaveBeenCalledWith("agent-001", "agent", { limit: 20 });
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent Mailbox: agent-001"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("1 unread"));
|
||||
});
|
||||
|
||||
it("should show 'No messages' when agent mailbox is empty", async () => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 0, ownerId: "agent-001", ownerType: "agent" });
|
||||
mockGetInbox.mockResolvedValue([]);
|
||||
|
||||
await runAgentMailbox("agent-001");
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No messages"));
|
||||
});
|
||||
|
||||
it("should show messages with from label", async () => {
|
||||
await runAgentMailbox("agent-001");
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent agent-001"));
|
||||
});
|
||||
});
|
||||
209
packages/cli/src/commands/message.ts
Normal file
209
packages/cli/src/commands/message.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { MessageStore } from "@fusion/core";
|
||||
import type { ParticipantType } from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Get the project path for message operations.
|
||||
* Falls back to process.cwd() if no project is specified.
|
||||
*/
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
const context = await resolveProject(projectName);
|
||||
return context.projectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await resolveProject(undefined);
|
||||
return context.projectPath;
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an initialized MessageStore for the given project.
|
||||
*/
|
||||
async function createMessageStore(projectName?: string): Promise<MessageStore> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const msgStore = new MessageStore({ rootDir: projectPath + "/.fusion" });
|
||||
await msgStore.init();
|
||||
return msgStore;
|
||||
}
|
||||
|
||||
/** User ID for CLI-originated messages */
|
||||
const CLI_USER_ID = "cli";
|
||||
|
||||
/**
|
||||
* List inbox messages.
|
||||
*/
|
||||
export async function runMessageInbox(projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const mailbox = await store.getMailbox(CLI_USER_ID, "user");
|
||||
const messages = await store.getInbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 📬 Inbox (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = msg.fromType === "agent" ? `Agent ${msg.fromId}` : msg.fromId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List sent messages.
|
||||
*/
|
||||
export async function runMessageOutbox(projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const messages = await store.getOutbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(" 📤 Outbox");
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No sent messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const toLabel = msg.toType === "agent" ? `Agent ${msg.toId}` : msg.toId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` To: ${toLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to an agent.
|
||||
*/
|
||||
export async function runMessageSend(toId: string, content: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const message = await store.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId,
|
||||
toType: "agent",
|
||||
content,
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Message sent: ${message.id}`);
|
||||
console.log(` To: Agent ${toId}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and display a specific message.
|
||||
*/
|
||||
export async function runMessageRead(id: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const message = await store.getMessage(id);
|
||||
|
||||
if (!message) {
|
||||
console.error(`Message ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
if (!message.read) {
|
||||
await store.markAsRead(id);
|
||||
}
|
||||
|
||||
const fromLabel = formatParticipant(message.fromId, message.fromType);
|
||||
const toLabel = formatParticipant(message.toId, message.toType);
|
||||
const timeStr = new Date(message.createdAt).toLocaleString();
|
||||
|
||||
console.log();
|
||||
console.log(` Message: ${message.id}`);
|
||||
console.log(` Type: ${message.type}`);
|
||||
console.log(` From: ${fromLabel}`);
|
||||
console.log(` To: ${toLabel}`);
|
||||
console.log(` Time: ${timeStr}`);
|
||||
console.log();
|
||||
console.log(` ${message.content}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message.
|
||||
*/
|
||||
export async function runMessageDelete(id: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
await store.deleteMessage(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Message ${id} deleted`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* View an agent's mailbox.
|
||||
*/
|
||||
export async function runAgentMailbox(agentId: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const mailbox = await store.getMailbox(agentId, "agent");
|
||||
const messages = await store.getInbox(agentId, "agent", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 🤖 Agent Mailbox: ${agentId} (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = formatParticipant(msg.fromId, msg.fromType);
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}From: ${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatParticipant(id: string, type: ParticipantType): string {
|
||||
switch (type) {
|
||||
case "agent": return `Agent ${id}`;
|
||||
case "user": return id === "cli" ? "You (CLI)" : id === "dashboard" ? "You (Dashboard)" : `User ${id}`;
|
||||
case "system": return "System";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
Reference in New Issue
Block a user