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
96 changes: 64 additions & 32 deletions apps/mobile/src/features/threads/AgentMailbox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getMailboxThreadCandidates } from "@t3tools/client-runtime/state/mailbox-candidates";
import {
CommandId,
type EnvironmentId,
Expand Down Expand Up @@ -44,6 +45,7 @@ function MailboxContents({
close,
}: MailboxProps & { close: () => void }) {
const [search, setSearch] = useState("");
const [candidateLimit, setCandidateLimit] = useState(12);
const [before, setBefore] = useState<MailboxGetInput["before"]>();
const [beforeTurn, setBeforeTurn] = useState<MailboxGetInput["beforeTurn"]>();
const [messageId, setMessageId] = useState<MailboxGetInput["messageId"]>();
Expand Down Expand Up @@ -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 (
<SafeAreaView className="flex-1 bg-background">
<View className="flex-row items-center justify-between px-4 py-3">
Expand Down Expand Up @@ -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) => (
<View key={thread.id} className="flex-row items-center justify-between gap-2">
<Text className="flex-1">{name(thread.id)}</Text>
<Pressable
accessibilityRole="button"
disabled={busy}
onPress={() =>
void mutate({ kind: "link", peerThreadId: thread.id, linked: true })
}
className="p-3"
>
<Text className="text-primary">Link</Text>
</Pressable>
</View>
))
: null}
<Text className="text-sm text-muted-foreground">
Available threads ({candidates.length}). Search also includes settled threads in this
environment.
</Text>
{visibleCandidates.map((thread) => (
<View key={thread.id} className="flex-row items-center justify-between gap-2">
<View className="flex-1">
<Text>{thread.title}</Text>
<Text className="text-sm text-muted-foreground">
{projectById.get(thread.projectId)?.title ?? "Project"}
{thread.settledOverride === "settled" ? " · Settled" : ""}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Link ${thread.title}`}
disabled={busy}
onPress={() => void mutate({ kind: "link", peerThreadId: thread.id, linked: true })}
className="p-3"
>
<Text className="text-primary">Link</Text>
</Pressable>
</View>
))}
{linkedThreadIds !== undefined && candidates.length === 0 ? (
<Text className="text-sm text-muted-foreground">
{search.trim()
? "No matching threads in this environment."
: "No other active threads available to link."}
</Text>
) : null}
{candidates.length > candidateLimit ? (
<Pressable
accessibilityRole="button"
className="py-2"
onPress={() => setCandidateLimit((limit) => limit + 12)}
>
<Text className="text-primary">
Show more threads ({candidates.length - candidateLimit} remaining)
</Text>
</Pressable>
) : null}
</View>
<View className="gap-3">
<View className="flex-row justify-between">
Expand Down
97 changes: 63 additions & 34 deletions apps/web/src/components/chat/AgentMailbox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getMailboxThreadCandidates } from "@t3tools/client-runtime/state/mailbox-candidates";
import { onOpenAgentMailbox } from "~/mailboxBus";
import { randomUUID } from "~/lib/utils";
import {
Expand Down Expand Up @@ -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<MailboxGetInput["before"]>();
const [executionId, setExecutionId] = useState<MailboxGetInput["executionId"]>();
const [beforeTurn, setBeforeTurn] = useState<MailboxGetInput["beforeTurn"]>();
Expand Down Expand Up @@ -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) ? (
<span>{name(id)}</span>
Expand Down Expand Up @@ -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) => (
<div
key={thread.id}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="truncate">{name(thread.id)}</span>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() =>
void mutate({ kind: "link", peerThreadId: thread.id, linked: true })
}
>
Link
</Button>
<p className="text-sm text-muted-foreground">
Available threads ({candidates.length}). Search also includes settled threads in
this environment.
</p>
{visibleCandidates.map((thread) => (
<div key={thread.id} className="flex items-center justify-between gap-2 text-sm">
<div className="min-w-0 flex-1">
<div className="break-words">{thread.title}</div>
<div className="text-xs text-muted-foreground">
{projectById.get(thread.projectId)?.title ?? "Project"}
{thread.settledOverride === "settled" ? " · Settled" : ""}
</div>
))
: null}
</div>
<Button
size="sm"
variant="outline"
aria-label={`Link ${thread.title}`}
disabled={busy}
onClick={() =>
void mutate({ kind: "link", peerThreadId: thread.id, linked: true })
}
>
Link
</Button>
</div>
))}
{linkedThreadIds !== undefined && candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">
{search.trim()
? "No matching threads in this environment."
: "No other active threads available to link."}
</p>
) : null}
{candidates.length > candidateLimit ? (
<Button
size="sm"
variant="ghost"
onClick={() => setCandidateLimit((limit) => limit + 12)}
>
Show more threads ({candidates.length - candidateLimit} remaining)
</Button>
) : null}
</section>
<section className="space-y-3">
<div className="flex justify-between">
Expand Down
9 changes: 6 additions & 3 deletions docs/user/agent-mailbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
112 changes: 112 additions & 0 deletions packages/client-runtime/src/state/mailboxCandidates.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getMailboxThreadCandidates>[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<Candidate>,
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]);
});
});
Loading
Loading