diff --git a/apps/mobile/src/features/threads/AgentMailbox.tsx b/apps/mobile/src/features/threads/AgentMailbox.tsx index b213795843b5..02d5a26e881b 100644 --- a/apps/mobile/src/features/threads/AgentMailbox.tsx +++ b/apps/mobile/src/features/threads/AgentMailbox.tsx @@ -1,3 +1,4 @@ +import { getMailboxThreadCandidates } from "@t3tools/client-runtime/state/mailbox-candidates"; import { CommandId, type EnvironmentId, @@ -44,6 +45,7 @@ function MailboxContents({ close, }: MailboxProps & { close: () => void }) { const [search, setSearch] = useState(""); + const [candidateLimit, setCandidateLimit] = useState(12); const [before, setBefore] = useState(); const [beforeTurn, setBeforeTurn] = useState(); const [messageId, setMessageId] = useState(); @@ -120,21 +122,23 @@ function MailboxContents({ close(); navigation.dispatch(CommonActions.navigate("Thread", { environmentId, threadId: id })); }; - const peers = (query.data?.peers ?? []).filter((id) => - threads.some((entry) => entry.environmentId === environmentId && entry.id === id), + const linkedThreadIds = query.data?.peers; + const peers = linkedThreadIds ?? []; + const candidates = useMemo( + () => + linkedThreadIds === undefined + ? [] + : getMailboxThreadCandidates({ + threads, + projects, + environmentId, + threadId, + linkedThreadIds, + search, + }), + [threads, projects, environmentId, threadId, linkedThreadIds, search], ); - const candidates = !search.trim() - ? [] - : threads - .filter( - (entry) => - entry.environmentId === environmentId && - entry.id !== threadId && - !entry.archivedAt && - !peers.includes(entry.id) && - name(entry.id).toLowerCase().includes(search.toLowerCase()), - ) - .slice(0, 12); + const visibleCandidates = candidates.slice(0, candidateLimit); return ( @@ -212,26 +216,54 @@ function MailboxContents({ accessibilityLabel="Find a collaborating thread" placeholder="Find a project or thread…" value={search} - onChangeText={setSearch} + onChangeText={(value) => { + setSearch(value); + setCandidateLimit(12); + }} className="rounded-lg border border-border p-3 text-foreground" /> - {search.trim() - ? candidates.map((thread) => ( - - {name(thread.id)} - - void mutate({ kind: "link", peerThreadId: thread.id, linked: true }) - } - className="p-3" - > - Link - - - )) - : null} + + Available threads ({candidates.length}). Search also includes settled threads in this + environment. + + {visibleCandidates.map((thread) => ( + + + {thread.title} + + {projectById.get(thread.projectId)?.title ?? "Project"} + {thread.settledOverride === "settled" ? " · Settled" : ""} + + + void mutate({ kind: "link", peerThreadId: thread.id, linked: true })} + className="p-3" + > + Link + + + ))} + {linkedThreadIds !== undefined && candidates.length === 0 ? ( + + {search.trim() + ? "No matching threads in this environment." + : "No other active threads available to link."} + + ) : null} + {candidates.length > candidateLimit ? ( + setCandidateLimit((limit) => limit + 12)} + > + + Show more threads ({candidates.length - candidateLimit} remaining) + + + ) : null} diff --git a/apps/web/src/components/chat/AgentMailbox.tsx b/apps/web/src/components/chat/AgentMailbox.tsx index a2ccf94b0237..d795d93cca40 100644 --- a/apps/web/src/components/chat/AgentMailbox.tsx +++ b/apps/web/src/components/chat/AgentMailbox.tsx @@ -1,3 +1,4 @@ +import { getMailboxThreadCandidates } from "@t3tools/client-runtime/state/mailbox-candidates"; import { onOpenAgentMailbox } from "~/mailboxBus"; import { randomUUID } from "~/lib/utils"; import { @@ -37,6 +38,7 @@ export function AgentMailbox({ }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); + const [candidateLimit, setCandidateLimit] = useState(12); const [before, setBefore] = useState(); const [executionId, setExecutionId] = useState(); const [beforeTurn, setBeforeTurn] = useState(); @@ -119,21 +121,23 @@ export function AgentMailbox({ setBusy(false); } }; - const peers = (query.data?.peers ?? []).filter((id) => - threads.some((entry) => entry.environmentId === environmentId && entry.id === id), + const linkedThreadIds = query.data?.peers; + const peers = linkedThreadIds ?? []; + const candidates = useMemo( + () => + !open || linkedThreadIds === undefined + ? [] + : getMailboxThreadCandidates({ + threads, + projects, + environmentId, + threadId, + linkedThreadIds, + search, + }), + [open, threads, projects, environmentId, threadId, linkedThreadIds, search], ); - const candidates = !search.trim() - ? [] - : threads - .filter( - (entry) => - entry.environmentId === environmentId && - entry.id !== threadId && - !entry.archivedAt && - !peers.includes(entry.id) && - name(entry.id).toLowerCase().includes(search.toLowerCase()), - ) - .slice(0, 12); + const visibleCandidates = candidates.slice(0, candidateLimit); const threadLink = (id: ThreadId) => !threads.some((entry) => entry.environmentId === environmentId && entry.id === id) ? ( {name(id)} @@ -225,28 +229,53 @@ export function AgentMailbox({ aria-label="Find a collaborating thread" placeholder="Find a project or thread…" value={search} - onChange={(event) => setSearch(event.target.value)} + onChange={(event) => { + setSearch(event.target.value); + setCandidateLimit(12); + }} /> - {search.trim() - ? candidates.map((thread) => ( -
- {name(thread.id)} - +

+ Available threads ({candidates.length}). Search also includes settled threads in + this environment. +

+ {visibleCandidates.map((thread) => ( +
+
+
{thread.title}
+
+ {projectById.get(thread.projectId)?.title ?? "Project"} + {thread.settledOverride === "settled" ? " · Settled" : ""}
- )) - : null} +
+ +
+ ))} + {linkedThreadIds !== undefined && candidates.length === 0 ? ( +

+ {search.trim() + ? "No matching threads in this environment." + : "No other active threads available to link."} +

+ ) : null} + {candidates.length > candidateLimit ? ( + + ) : null}
diff --git a/docs/user/agent-mailbox.md b/docs/user/agent-mailbox.md index ca4695a368e1..ae7d29f2be64 100644 --- a/docs/user/agent-mailbox.md +++ b/docs/user/agent-mailbox.md @@ -4,9 +4,12 @@ Agents in different threads can exchange messages, including threads in differen same environment. Open **Agent mailbox** from the thread header on web or desktop, or the envelope button in the mobile thread header. On web and desktop, you can also search for **Open agent mailbox** in the command palette. -Search for a project or thread under **Collaborating threads** and select **Link**. Linking works -in both directions. Ask your agents to use their mailbox to coordinate dependencies, share API -contracts, report blockers, and send results. Agents can discover the linked threads themselves. +Under **Collaborating threads**, available active threads appear immediately, with recent threads +first. Select **Link** to connect one. Use **Show more threads** to browse the rest, or search by +words from the project and thread name. Search also includes settled threads. Already-linked +threads appear above the picker, with an **Unlink** action. Linking works in both directions. +Ask your agents to use their mailbox to coordinate dependencies, share API contracts, report blockers, +and send results. Agents can discover the linked threads themselves. A message automatically starts a new turn when its recipient is idle. While the recipient works, mail waits until the current turn and its checkpoint finish; it never steers the agent or cancels diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c7d2774052f6..11a4daceb426 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -139,6 +139,10 @@ "types": "./src/state/mailbox.ts", "default": "./src/state/mailbox.ts" }, + "./state/mailbox-candidates": { + "types": "./src/state/mailboxCandidates.ts", + "default": "./src/state/mailboxCandidates.ts" + }, "./state/issues": { "types": "./src/state/issues.ts", "default": "./src/state/issues.ts" diff --git a/packages/client-runtime/src/state/mailboxCandidates.test.ts b/packages/client-runtime/src/state/mailboxCandidates.test.ts new file mode 100644 index 000000000000..3584e2d01be2 --- /dev/null +++ b/packages/client-runtime/src/state/mailboxCandidates.test.ts @@ -0,0 +1,112 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getMailboxThreadCandidates } from "./mailboxCandidates.ts"; + +const environmentId = EnvironmentId.make("local"); +const otherEnvironmentId = EnvironmentId.make("remote"); +const projectId = ProjectId.make("pseudoapps"); +const stagingProjectId = ProjectId.make("entriq"); +const currentThreadId = ThreadId.make("current"); +const projects = [ + { id: projectId, environmentId, title: "Pseudoapps" }, + { id: stagingProjectId, environmentId, title: "Entriq" }, + { id: stagingProjectId, environmentId: otherEnvironmentId, title: "Remote project" }, +]; + +function thread(id: string, title = id) { + return { + id: ThreadId.make(id), + environmentId, + projectId, + title, + archivedAt: null, + settledOverride: null, + createdAt: "2026-09-06T00:00:00.000Z", + updatedAt: "2026-09-06T00:00:00.000Z", + latestUserMessageAt: null, + }; +} + +type Candidate = Parameters[0]["threads"][number]; +const infrastructure = thread("infrastructure", "Placard Infrastructure Changes"); +const staging = { + ...thread("staging", "Build out Staging Env for Placrd"), + projectId: stagingProjectId, + latestUserMessageAt: "2026-09-08T00:00:00.000Z", +}; +const settled = { + ...thread("settled", "Older staging setup"), + settledOverride: "settled" as const, +}; + +function candidates( + threads: ReadonlyArray, + search = "", + linkedThreadIds: ThreadId[] = [], +) { + return getMailboxThreadCandidates({ + threads, + projects, + environmentId, + threadId: currentThreadId, + linkedThreadIds, + search, + }); +} + +describe("mailbox thread candidates", () => { + it("shows active threads across projects immediately, most recent first", () => { + expect(candidates([infrastructure, settled, staging], " ").map((entry) => entry.id)).toEqual([ + staging.id, + infrastructure.id, + ]); + }); + + it("excludes the current thread, archived threads, linked peers, and other environments", () => { + expect( + candidates( + [ + thread(currentThreadId), + infrastructure, + staging, + { ...thread("archived"), archivedAt: "2026-09-08T00:00:00.000Z" }, + { ...staging, environmentId: otherEnvironmentId }, + ], + "", + [infrastructure.id], + ), + ).toEqual([staging]); + }); + + it("matches trimmed, case-insensitive words across project and thread names", () => { + expect(candidates([infrastructure, staging], " STAGING Entriq ")).toEqual([staging]); + expect(candidates([infrastructure, staging], "changes pseudoapps")).toEqual([infrastructure]); + expect(candidates([staging], "remote")).toEqual([]); + expect(candidates([staging], "Build out Staging Env for Placrd")).toEqual([staging]); + expect(candidates([staging], "Entriq / Build out Staging Env for Placrd")).toEqual([staging]); + }); + + it("includes settled threads when searching, after equally matching active threads", () => { + expect(candidates([settled, staging], "staging")).toEqual([staging, settled]); + expect(candidates([settled], "nothing")).toEqual([]); + }); + + it("keeps every match available to the client for pagination", () => { + const threads = Array.from({ length: 25 }, (_, index) => thread(`staging-${index}`)); + const matches = candidates([...threads, staging], "staging"); + expect(matches).toHaveLength(26); + expect(matches[0]).toEqual(staging); + expect(new Set(matches.map((entry) => entry.id)).size).toBe(26); + }); + + it("ranks an exact thread title ahead of project-name matches", () => { + const exact = thread("exact", "Entriq"); + expect(candidates([staging, exact], "entriq")).toEqual([exact, staging]); + }); + + it("returns a peer to the picker after unlinking", () => { + expect(candidates([infrastructure, staging], "staging", [staging.id])).toEqual([]); + expect(candidates([infrastructure, staging], "staging", [])).toEqual([staging]); + }); +}); diff --git a/packages/client-runtime/src/state/mailboxCandidates.ts b/packages/client-runtime/src/state/mailboxCandidates.ts new file mode 100644 index 000000000000..72ceb99020a7 --- /dev/null +++ b/packages/client-runtime/src/state/mailboxCandidates.ts @@ -0,0 +1,66 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; + +import type { EnvironmentProject, EnvironmentThreadShell } from "./models.ts"; +import { getThreadSortTimestamp } from "./threadSort.ts"; + +type MailboxThread = Pick< + EnvironmentThreadShell, + | "id" + | "environmentId" + | "projectId" + | "title" + | "archivedAt" + | "settledOverride" + | "createdAt" + | "updatedAt" + | "latestUserMessageAt" +>; + +/** Active threads are browsable; search includes settled threads and matches project/title words. */ +export function getMailboxThreadCandidates(input: { + readonly threads: ReadonlyArray; + readonly projects: ReadonlyArray>; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly linkedThreadIds: ReadonlyArray; + readonly search: string; +}): T[] { + const query = normalizeSearchQuery(input.search).replace(/\s+/g, " "); + const words = query ? query.split(" ") : []; + const linked = new Set(input.linkedThreadIds); + const projectTitles = new Map( + input.projects + .filter((project) => project.environmentId === input.environmentId) + .map((project) => [project.id, project.title]), + ); + const candidates = []; + for (const thread of input.threads) { + if ( + thread.environmentId !== input.environmentId || + thread.id === input.threadId || + thread.archivedAt !== null || + linked.has(thread.id) || + (!query && thread.settledOverride === "settled") + ) + continue; + + const title = normalizeSearchQuery(thread.title).replace(/\s+/g, " "); + const searchable = `${projectTitles.get(thread.projectId) ?? ""} / ${title}`.toLowerCase(); + if (!words.every((word) => searchable.includes(word))) continue; + candidates.push({ + thread, + rank: !query || title === query ? 0 : title.includes(query) ? 1 : 2, + settled: thread.settledOverride === "settled" ? 1 : 0, + timestamp: getThreadSortTimestamp(thread, "updated_at"), + }); + } + candidates.sort( + (left, right) => + left.rank - right.rank || + left.settled - right.settled || + right.timestamp - left.timestamp || + left.thread.id.localeCompare(right.thread.id), + ); + return candidates.map(({ thread }) => thread); +}