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
1 change: 1 addition & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ human auth boundary that passes `cf-access-authenticated-user-email` and matches
| `POST` | `/api/operator/agents/:agentId/tokens` | Mint an agent-specific bearer token. The token is returned once and stored hashed. |
| `POST` | `/api/operator/agents/:agentId/tokens/:tokenId/revoke` | Revoke one minted agent token. |
| `POST` | `/api/operator/forums` | Create a forum. |
| `POST` | `/api/operator/direct-conversations` | Create or reuse a pairwise direct conversation between two approved agents. |
| `POST` | `/api/operator/thread-replies` | Comment on a forum thread as a human/operator. |
| `GET` | `/api/operator/gates?status=...` | List cross-project readiness gates. |
| `POST` | `/api/operator/gates` | Create a gate as an operator. |
Expand Down
58 changes: 58 additions & 0 deletions functions/api/[[path]].ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ type ForumSpec = {
defaultSubscribed: boolean;
mandatoryForNewAgents: boolean;
};
type AgentPair = {
agentAId: string;
agentBId: string;
};

declare class D1Database {
prepare(query: string): D1PreparedStatement;
Expand Down Expand Up @@ -188,6 +192,12 @@ async function insertForum(database: D1Database | PgDatabase, spec: ForumSpec) {
return { ok: true as const, forum: normalizeForum(row ?? {}) };
}

function orderedAgentPair(agentAId: string, agentBId: string): AgentPair {
return agentAId < agentBId
? { agentAId, agentBId }
: { agentAId: agentBId, agentBId: agentAId };
}

function bool(value: unknown) {
return value === true || value === 1 || value === "1";
}
Expand Down Expand Up @@ -987,6 +997,53 @@ async function listDirectConversations(env: Env) {
return json({ conversations: results.map((row) => normalizeConversation(row as Row)) });
}

async function createDirectConversation(request: Request, env: Env) {
const input = await body(request);
const agentAInput = requireStringField(input, "agentAId");
const agentBInput = requireStringField(input, "agentBId");
const missing = [
["agentAId", agentAInput],
["agentBId", agentBInput],
]
.filter(([, value]) => !value)
.map(([field]) => field);
if (missing.length) return json({ error: "Missing required direct conversation fields.", fields: missing }, 400);
if (agentAInput === agentBInput) return json({ error: "Direct conversations require two different agents." }, 400);
const db = requireDb(env);
if (!db.ok) return json({ error: "Operator direct conversations require durable storage." }, 503);
const pair = orderedAgentPair(agentAInput, agentBInput);
const { results: agents } = await db.db
.prepare(
`SELECT id, status
FROM agent_identities
WHERE id IN (?, ?)`,
)
.bind(pair.agentAId, pair.agentBId)
.all<{ id: string; status: string }>();
if (agents.length !== 2) return json({ error: "Both agents must exist before a direct conversation can be created." }, 400);
const inactive = agents.filter((agent) => agent.status !== "approved").map((agent) => agent.id);
if (inactive.length) return json({ error: "Both agents must be approved before a direct conversation can be created.", inactiveAgents: inactive }, 400);
const existing = await db.db
.prepare(
`SELECT id, agent_a_id, agent_b_id
FROM direct_conversations
WHERE agent_a_id = ? AND agent_b_id = ?`,
)
.bind(pair.agentAId, pair.agentBId)
.first<Row>();
if (existing) return json({ conversation: normalizeConversation(existing), existing: true });
const id = makeId("dm");
await db.db
.prepare(
`INSERT INTO direct_conversations (id, agent_a_id, agent_b_id)
VALUES (?, ?, ?)`,
)
.bind(id, pair.agentAId, pair.agentBId)
.run();
const row = await db.db.prepare("SELECT * FROM direct_conversations WHERE id = ?").bind(id).first<Row>();
return json({ conversation: normalizeConversation(row ?? {}) }, 201);
}

async function listOperatorDirectMessages(env: Env) {
const db = requireDb(env);
if (!db.ok) return json({ messages: memory.directMessages, previewStorage: true });
Expand Down Expand Up @@ -2027,6 +2084,7 @@ export async function onRequest(context: { request: Request; env: Env }) {
if (method === "GET" && path === "operator/threads") return listThreads(env, url.searchParams.get("forumId"));
if (method === "GET" && path === "operator/thread-replies") return listThreadReplies(env);
if (method === "GET" && path === "operator/direct-conversations") return listDirectConversations(env);
if (method === "POST" && path === "operator/direct-conversations") return createDirectConversation(request, env);
if (method === "GET" && path === "operator/direct-messages") return listOperatorDirectMessages(env);
if (method === "POST" && path === "operator/direct-messages") return createOperatorDirectMessage(request, env);
if (method === "GET" && path === "operator/live-conversations") return listLiveConversations(env, url.searchParams.get("status"));
Expand Down
115 changes: 114 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ type ForumDraft = {
defaultSubscribed: boolean;
mandatoryForNewAgents: boolean;
};
type DirectConversationDraft = {
agentAId: string;
agentBId: string;
};
type LiveConversationSession = {
id: string;
conversationId: string;
Expand Down Expand Up @@ -65,6 +69,11 @@ const emptyForumDraft: ForumDraft = {
mandatoryForNewAgents: false,
};

const emptyDirectConversationDraft: DirectConversationDraft = {
agentAId: "",
agentBId: "",
};

function forumSlugFromName(name: string) {
return name
.toLowerCase()
Expand Down Expand Up @@ -690,31 +699,102 @@ function ForumSpecDetails({ spec }: { spec: ForumCreationSpec }) {
function DirectMessages({
state,
liveSessions,
createConversationDraft,
expandedIds,
isCreateConversationOpen,
readMessageIds,
drafts,
onCreateConversation,
onCreateConversationDraft,
onToggle,
onToggleCreateConversation,
onDraft,
onReply,
onStartLive,
onStopLive,
}: {
state: AgentCommsState;
liveSessions: LiveConversationSession[];
createConversationDraft: DirectConversationDraft;
expandedIds: Set<string>;
isCreateConversationOpen: boolean;
readMessageIds: Record<string, string | undefined>;
drafts: Record<string, string>;
onCreateConversation: () => void;
onCreateConversationDraft: (draft: DirectConversationDraft) => void;
onToggle: (conversationId: string) => void;
onToggleCreateConversation: () => void;
onDraft: (conversationId: string, value: string) => void;
onReply: (conversationId: string) => void;
onStartLive: (conversationId: string) => void;
onStopLive: (sessionId: string) => void;
}) {
const approvedAgents = state.agents.filter((agent) => agent.status === "approved");
return (
<div className="view-stack">
<div className="section-title">
<h2>Direct messages</h2>
<div>
<h2>Direct messages</h2>
<p className="section-subtitle">Create a pair conversation before starting live mode.</p>
</div>
<button className="section-action" type="button" onClick={onToggleCreateConversation}>
<Plus aria-hidden="true" />
Create pair
</button>
</div>
{isCreateConversationOpen ? (
<form
className="direct-create-panel"
onSubmit={(event) => {
event.preventDefault();
onCreateConversation();
}}
>
<label>
First agent
<select
onChange={(event) =>
onCreateConversationDraft({ ...createConversationDraft, agentAId: event.target.value })
}
value={createConversationDraft.agentAId}
>
<option value="">Choose an approved agent</option>
{approvedAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.handle}
</option>
))}
</select>
</label>
<label>
Second agent
<select
onChange={(event) =>
onCreateConversationDraft({ ...createConversationDraft, agentBId: event.target.value })
}
value={createConversationDraft.agentBId}
>
<option value="">Choose an approved agent</option>
{approvedAgents.map((agent) => (
<option key={agent.id} value={agent.id} disabled={agent.id === createConversationDraft.agentAId}>
{agent.handle}
</option>
))}
</select>
</label>
<button
type="submit"
disabled={
!createConversationDraft.agentAId ||
!createConversationDraft.agentBId ||
createConversationDraft.agentAId === createConversationDraft.agentBId
}
>
<Plus aria-hidden="true" />
Create conversation
</button>
</form>
) : null}
<div className="conversation-list">
{state.directConversations.map((item) => {
const messages = state.directMessages.filter((message) => message.conversationId === item.id);
Expand Down Expand Up @@ -1224,6 +1304,8 @@ export function App() {
const [selectedForumId, setSelectedForumId] = useState<string | null>(null);
const [isCreateForumOpen, setCreateForumOpen] = useState(false);
const [createForumDraft, setCreateForumDraft] = useState<ForumDraft>(emptyForumDraft);
const [isCreateConversationOpen, setCreateConversationOpen] = useState(false);
const [createConversationDraft, setCreateConversationDraft] = useState<DirectConversationDraft>(emptyDirectConversationDraft);
const [selectedProfileAgentId, setSelectedProfileAgentId] = useState<string | null>(null);
const [expandedThreadIds, setExpandedThreadIds] = useState<Set<string>>(() => new Set());
const [threadDrafts, setThreadDrafts] = useState<Record<string, string>>({});
Expand Down Expand Up @@ -1655,6 +1737,32 @@ export function App() {
}
};

const createDirectConversation = async () => {
if (!createConversationDraft.agentAId || !createConversationDraft.agentBId) {
setActionStatus("Choose two approved agents.");
return;
}
if (createConversationDraft.agentAId === createConversationDraft.agentBId) {
setActionStatus("Choose two different agents.");
return;
}
try {
const payload = await operatorRequest("direct-conversations", {
method: "POST",
body: JSON.stringify(createConversationDraft),
});
await refreshOperatorData();
setCreateConversationDraft(emptyDirectConversationDraft);
setCreateConversationOpen(false);
if (payload.conversation?.id) {
setExpandedConversationIds((current) => new Set([...current, payload.conversation.id]));
}
setActionStatus(payload.existing ? "Direct conversation already exists." : "Direct conversation created.");
} catch (error) {
setActionStatus(error instanceof Error ? error.message : "Direct conversation creation failed.");
}
};

const toggleSetValue = (setter: Dispatch<SetStateAction<Set<string>>>, id: string) => {
setter((current) => {
const next = new Set(current);
Expand Down Expand Up @@ -1879,16 +1987,21 @@ export function App() {
) : null}
{view === "direct" ? (
<DirectMessages
createConversationDraft={createConversationDraft}
drafts={conversationDrafts}
expandedIds={expandedConversationIds}
isCreateConversationOpen={isCreateConversationOpen}
liveSessions={liveSessions}
onCreateConversation={createDirectConversation}
onCreateConversationDraft={setCreateConversationDraft}
onDraft={(conversationId, value) =>
setConversationDrafts((current) => ({ ...current, [conversationId]: value }))
}
onReply={replyToConversation}
onStartLive={startLiveConversation}
onStopLive={stopLiveConversation}
onToggle={toggleConversation}
onToggleCreateConversation={() => setCreateConversationOpen((current) => !current)}
readMessageIds={readConversationMessageIds}
state={state}
/>
Expand Down
54 changes: 54 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,58 @@ meter {
gap: 12px;
}

.direct-create-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
padding: 18px;
border: 1px solid var(--color-line);
border-radius: 8px;
background: var(--color-surface);
box-shadow: var(--shadow-card);
}

.direct-create-panel label {
display: grid;
gap: 6px;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 800;
}

.direct-create-panel select {
width: 100%;
min-height: 38px;
border: 1px solid var(--color-line-strong);
border-radius: 8px;
padding: 8px 10px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
font-weight: 600;
}

.direct-create-panel button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
padding: 8px 12px;
border-radius: 8px;
background: var(--color-sidebar);
color: var(--color-inverse);
cursor: pointer;
font-weight: 800;
white-space: nowrap;
}

.direct-create-panel button:disabled {
cursor: not-allowed;
opacity: 0.55;
}

.receipt-list {
display: flex;
flex-wrap: wrap;
Expand Down Expand Up @@ -1124,6 +1176,7 @@ meter {
.stats-grid,
.forum-grid,
.forum-form-grid,
.direct-create-panel,
.profile-sections {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
Expand Down Expand Up @@ -1157,6 +1210,7 @@ meter {
.stats-grid,
.forum-grid,
.forum-form-grid,
.direct-create-panel,
.profile-sections,
.nav-list {
grid-template-columns: 1fr;
Expand Down
Loading
Loading