Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@corbits/inbox": "workspace:*",
"@corbits/inference-catalog": "workspace:*",
"@corbits/last-30-days-research-workflow": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60",
"@corbits/mcp-tools": "workspace:*",
"@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3",
"@corbits/memory-hub": "workspace:*",
Expand Down
9 changes: 5 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/chat-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@tanstack/react-query": "catalog:",
"@corbits/connections": "workspace:*",
"@corbits/jimmy-agent": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60",
"arktype": "catalog:",
"@corbits/icons": "workspace:*",
"react": "^19.2.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export type {
CorbitAvatarSize,
} from "./avatar";

export { threadMessagesToTimeline, roomRefFor } from "./mailbox-timeline";
export type { TimelineItem } from "./mailbox-timeline";

export { PinnedStrip } from "./pinned-strip";
export type { PinsStatus } from "./use-workbench-feed";
export {
Expand Down
57 changes: 57 additions & 0 deletions packages/chat-ui/src/mailbox-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import type { MailboxThreadMessage } from "@corbits/mailbox";

import { roomRefFor, threadMessagesToTimeline } from "./mailbox-timeline";

function message(
overrides: Partial<MailboxThreadMessage> & Pick<MailboxThreadMessage, "id" | "messageId">,
): MailboxThreadMessage {
return {
references: [],
fromAddress: "sender@dana.localhost",
createdAt: "2026-09-17T00:00:00.000000Z",
read: true,
archived: false,
parentId: null,
body: "hello",
...overrides,
};
}

describe("threadMessagesToTimeline", () => {
test("links a reply's threadId to its parent's own id via inReplyTo", () => {
const parent = message({ id: "id-1", messageId: "msg-1@mail" });
const child = message({
id: "id-2",
messageId: "msg-2@mail",
inReplyTo: "msg-1@mail",
body: "reply",
});

const items = threadMessagesToTimeline([parent, child]);

expect(items[0]?.threadId).toBeUndefined();
expect(items[1]?.threadId).toBe("id-1");
});

test("leaves threadId absent when the parent isn't in this batch", () => {
const orphan = message({
id: "id-3",
messageId: "msg-3@mail",
inReplyTo: "msg-missing@mail",
});

const [item] = threadMessagesToTimeline([orphan]);

expect(item?.threadId).toBeUndefined();
});
});

describe("roomRefFor", () => {
test("stamps the same { kind: 'workbench', id } ref the mailbox writers use", () => {
expect(roomRefFor("tenant-1", "room-1")).toEqual({
kind: "workbench",
id: "room-1",
});
});
});
69 changes: 69 additions & 0 deletions packages/chat-ui/src/mailbox-timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Pure adapter from a mailbox thread read (`@corbits/mailbox`'s
// `MailboxThreadMessage`) onto the timeline item shape `use-workbench-feed.ts`
// already produces (CL-8174 slice 2a). No new wire shape: a mail message
// becomes exactly the `MessageItem` the rest of chat-ui already knows how to
// render, so the timeline never has to branch on where a message came from.

import type { MailboxRef, MailboxThreadMessage } from "@corbits/mailbox";
import type { MessageItem } from "./api";

/** The timeline item shape — an alias, not a new type, so a mail-derived
* row and a chat-native row are interchangeable everywhere the timeline
* already consumes `MessageItem`. */
export type TimelineItem = MessageItem;

/**
* Maps one mailbox thread's messages onto timeline items.
*
* `subject`, when present, prefixes the body as its own line — mail is the
* only source that carries a subject, so it is folded into the one text
* part a `MessageItem` has room for rather than inventing a field the rest
* of chat-ui would have to learn about.
*
* `threadId` links a reply to its parent's own `id` (not its `messageId`,
* which is the RFC-2822-style id the `inReplyTo`/`references` headers
* carry) by resolving `inReplyTo` against every message's `messageId` in
* this same batch. A message whose parent isn't in this batch — the root,
* or a reply to a message this read didn't page in — keeps `threadId`
* absent rather than guessing.
*/
export function threadMessagesToTimeline(
messages: readonly MailboxThreadMessage[],
): TimelineItem[] {
const idByMessageId = new Map<string, string>();
for (const message of messages) {
idByMessageId.set(message.messageId, message.id);
}

return messages.map((message) => {
const parentId =
message.inReplyTo !== undefined
? idByMessageId.get(message.inReplyTo)
: undefined;
const text =
message.subject !== undefined && message.subject.length > 0
? `${message.subject}\n${message.body}`
: message.body;
return {
id: message.id,
createdAt: message.createdAt,
parts: [{ kind: "text", text }],
sender: { name: null, address: message.fromAddress },
...(parentId !== undefined ? { threadId: parentId } : {}),
};
});
}

/**
* The same `{ kind: "workbench", id }` ref every mailbox writer stamps
* (`apps/hub/src/mailbox-persist.ts`'s `hubMailboxResolveRefs`,
* `packages/chat/src/mailbox-fanout.ts`'s `writeChatMailboxFanout`) — a
* workbench is a plain tenant now (CL-8083), so the room a thread hangs
* off is the same id a chat room is scoped to, and `tenantId` is carried
* here only so a caller with just a tenant in hand has a matching call
* shape; the stamped ref itself never varies by which of the two identical
* ids produced it.
*/
export function roomRefFor(_tenantId: string, roomId: string): MailboxRef {
return { kind: "workbench", id: roomId };
}
2 changes: 1 addition & 1 deletion packages/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"@corbits/commands": "workspace:*",
"@corbits/error-sink": "workspace:*",
"@corbits/hub-api-client": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60",
"@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3",
"@corbits/turn-artifacts": "workspace:*",
"@corbits/workflows": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/inbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"dependencies": {
"@corbits/error-sink": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60",
"@corbits/notify": "workspace:*",
"@intx/hub-api": "workspace:*",
"@intx/log": "0.3.0",
Expand Down
Loading