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
11 changes: 7 additions & 4 deletions apps/web/src/j5/a2a/HumanInboxBell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { useSidebar } from "../../components/ui/sidebar";
import { cn } from "../../lib/utils";
import { inboxCrewRequests } from "../crew/crewProposals.logic";
import { mergeCrewProposalSources, useCrewProposalsRefresh } from "../crew/crewProposalsClient";
import { inboxBadgeCount } from "../crew/crewRuntimeRequests.logic";
import { useCrewRuntimeRequests } from "../crew/crewRuntimeRequestsClient";
import {
crewProposalSourcesAtom,
inboxCountQueryAtom,
Expand All @@ -28,11 +30,12 @@ export function HumanInboxBell({ onBackdrop }: { readonly onBackdrop: boolean })
const sources = useAtomValue(inboxCountSourcesAtom);
const crewSources = useAtomValue(crewProposalSourcesAtom);
const merged = mergeOpenInboxCounts(sources);
// Mid-run seat requests are a human gate too; the inbox badge counts them beside open questions.
// The initial roster is answered inline in the Captain's thread and stays off the inbox badge.
// Mid-run seat requests and seats' provider approvals wait on the person too; the badge counts
// them beside open questions. The initial roster and the Captain's own requests are answered
// inline in the Captain's thread and stay off the inbox badge.
const crewRequests = inboxCrewRequests(mergeCrewProposalSources(crewSources)).length;
const count =
merged.count === null ? (crewRequests > 0 ? crewRequests : null) : merged.count + crewRequests;
const runtimeRequests = useCrewRuntimeRequests().length;
const count = inboxBadgeCount(merged.count, crewRequests, runtimeRequests);
const incomplete = merged.incomplete;
useCountRefresh();
useCrewProposalsRefresh();
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/j5/a2a/HumanInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import { answerHumanExchange } from "./humanInboxClient";
import { useScopedParticipantLabels } from "./ParticipantIdentitiesClient";
import { presentParticipantIdentity } from "./ParticipantIdentity";
import { CrewProposalCard } from "../crew/CrewProposalCard";
import { CrewRuntimeRequestsSection } from "../crew/CrewRuntimeRequestsSection";
import type { ScopedCrewRuntimeRequest } from "../crew/crewRuntimeRequests.logic";
import { useCrewRuntimeRequests } from "../crew/crewRuntimeRequestsClient";
import { inboxCrewRequests } from "../crew/crewProposals.logic";
import {
mergeCrewProposalSources,
Expand Down Expand Up @@ -392,6 +395,7 @@ export function HumanInboxPage() {
);
const [resolvingProposalId, setResolvingProposalId] = useState<string | null>(null);
useCrewProposalsRefresh();
const runtimeRequests = useCrewRuntimeRequests();
const previousOpenItems = useRef(new Map<EnvironmentId, ReadonlySet<string>>());
useEffect(() => {
const next = new Map<EnvironmentId, ReadonlySet<string>>();
Expand Down Expand Up @@ -520,6 +524,16 @@ export function HumanInboxPage() {
[navigate],
);

const openCrewThread = useCallback(
(request: ScopedCrewRuntimeRequest) => {
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(scopeThreadRef(request.environmentId, request.threadId)),
});
},
[navigate],
);

const openThread = useCallback(
(item: HumanInboxItem) => {
if (!item.connected || item.senderThreadId === null) return;
Expand Down Expand Up @@ -559,6 +573,10 @@ export function HumanInboxPage() {
proposals.length === 0
? ""
: ` · ${proposals.length} crew ${proposals.length === 1 ? "request" : "requests"}`
}${
runtimeRequests.length === 0
? ""
: ` · ${runtimeRequests.length} crew agent ${runtimeRequests.length === 1 ? "request" : "requests"}`
}`}
</p>
</div>
Expand Down Expand Up @@ -622,11 +640,14 @@ export function HumanInboxPage() {
</section>
) : null}

<CrewRuntimeRequestsSection requests={runtimeRequests} onOpenThread={openCrewThread} />

{complete &&
!loading &&
error === null &&
items.length === 0 &&
proposals.length === 0 ? (
proposals.length === 0 &&
runtimeRequests.length === 0 ? (
<div className="flex min-h-56 flex-col items-center justify-center px-6 py-12 text-center">
<span className="flex size-10 items-center justify-center rounded-full bg-success/10 text-success">
<InboxIcon aria-hidden className="size-5" />
Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/j5/crew/CrewRuntimeRequestsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { ProviderApprovalDecision } from "@t3tools/contracts";
import { useState } from "react";

import { ComposerPendingApprovalActions } from "../../components/chat/ComposerPendingApprovalActions";
import { ComposerPendingApprovalPanel } from "../../components/chat/ComposerPendingApprovalPanel";
import { Badge } from "../../components/ui/badge";
import { notifyHumanInboxChanged } from "../a2a/humanInboxRefresh";
import { toPendingApproval, type ScopedCrewRuntimeRequest } from "./crewRuntimeRequests.logic";
import { refreshCrewRuntimeRequests, respondCrewRuntimeRequest } from "./crewRuntimeRequestsClient";

const itemKey = (request: ScopedCrewRuntimeRequest) =>
`${request.environmentId}:${request.threadId}:${request.requestId}`;

/**
* Provider approvals from Crew seats, answered here with the composer's own approval UI instead of
* in the seat's thread (Crews AC9); the Captain's requests stay in its thread. An answer that lost
* a race with another device is refused by the server and the message says so; either way the
* item re-reads and leaves once it has resolved.
*/
export function CrewRuntimeRequestsSection(props: {
readonly requests: ReadonlyArray<ScopedCrewRuntimeRequest>;
readonly onOpenThread: (request: ScopedCrewRuntimeRequest) => void;
}) {
const [busyKey, setBusyKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
if (props.requests.length === 0) return null;

const submit = async (request: ScopedCrewRuntimeRequest, decision: ProviderApprovalDecision) => {
setBusyKey(itemKey(request));
setError(null);
try {
await respondCrewRuntimeRequest(request.environmentId, {
threadId: request.threadId,
requestId: request.requestId,
decision,
});
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not answer the request.");
} finally {
notifyHumanInboxChanged(request.environmentId);
await refreshCrewRuntimeRequests(request.environmentId).catch(() => undefined);
setBusyKey(null);
}
};

return (
<section aria-label="Crew agent requests" className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Crew agent requests
</h2>
{error ? (
<p className="mb-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<ul className="space-y-4">
{props.requests.map((request) => (
<li
key={itemKey(request)}
className="rounded-lg border border-border bg-card px-4 py-3 text-sm"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{request.crewName}</span>
<Badge variant="outline" size="sm">
{request.seat}
</Badge>
<button
type="button"
className="min-w-0 truncate text-muted-foreground underline-offset-2 hover:underline"
onClick={() => props.onOpenThread(request)}
>
{request.threadTitle}
</button>
</div>
<div className="mt-2 flex flex-wrap items-end gap-2">
<ComposerPendingApprovalPanel
approval={toPendingApproval(request)}
pendingCount={1}
/>
<div className="flex shrink-0 items-center gap-2">
<ComposerPendingApprovalActions
requestId={request.requestId}
isResponding={busyKey === itemKey(request)}
canRespond
options={request.options}
onRespondToApproval={(_requestId, decision) => submit(request, decision)}
/>
</div>
</div>
</li>
))}
</ul>
</section>
);
}
112 changes: 112 additions & 0 deletions apps/web/src/j5/crew/crewRuntimeRequests.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vite-plus/test";
import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
import type { CrewRuntimeRequestItem } from "@t3tools/contracts/j5";

import {
crewApprovalPollPlan,
inboxBadgeCount,
mergeCrewRuntimeRequestSources,
toPendingApproval,
} from "./crewRuntimeRequests.logic";

const envA = EnvironmentId.make("env:a");
const envB = EnvironmentId.make("env:b");
const seatThread = ThreadId.make("thread:builder");

const item = (requestId: string, threadId = seatThread): CrewRuntimeRequestItem => ({
threadId,
requestId: RuntimeRequestId.make(requestId),
crewInstanceId: "crew:1",
crewName: "Release Crew",
squadronId: "squadron:1",
seat: "builder",
threadTitle: "builder",
createdAt: "2026-09-24T12:00:00.000Z",
requestKind: "command",
detail: "Run the tests?",
});

const sources = (
byEnvironment: ReadonlyArray<[EnvironmentId, ReadonlyArray<CrewRuntimeRequestItem>]>,
) =>
({
isReady: true,
sources: byEnvironment.map(([environmentId, data]) => ({ environmentId, data })),
}) as never;

const shell = (
environmentId: EnvironmentId,
id: string,
hasPendingApprovals: boolean,
archivedAt: string | null = null,
) => ({ environmentId, id: ThreadId.make(id), hasPendingApprovals, archivedAt });

describe("Crew seat approvals in the Inbox", () => {
it("tags each approval with the environment that must answer it", () => {
const merged = mergeCrewRuntimeRequestSources(
sources([
[envA, [item("a1"), item("a2")]],
// The same local thread id on another environment is a different thread.
[envB, [item("a9")]],
]),
);
expect(merged.map((request) => [request.environmentId, request.requestId])).toEqual([
[envA, "a1"],
[envA, "a2"],
[envB, "a9"],
]);
});

it("hands the composer's approval UI a live approval with only the fields it has", () => {
expect(toPendingApproval(item("a1"))).toEqual({
requestId: "a1",
requestKind: "command",
createdAt: "2026-09-24T12:00:00.000Z",
detail: "Run the tests?",
responseCapability: "live",
});
const options = [{ decision: "accept" as const, label: "Allow", warning: "For good." }];
expect(
toPendingApproval({ ...item("a2"), requestKind: "permission", appName: "CI", options }),
).toMatchObject({ requestKind: "permission", appName: "CI", options });
});

it("reads only environments with a thread waiting on an approval", () => {
expect(crewApprovalPollPlan([])).toEqual({ key: "", environmentIds: [] });
// Nothing pending anywhere, or only on an archived thread: no environment is read.
const idle = crewApprovalPollPlan([
shell(envA, "thread:1", false),
shell(envB, "thread:2", true, "2026-09-24T12:00:00.000Z"),
]);
expect(idle).toEqual({ key: "", environmentIds: [] });

const pending = crewApprovalPollPlan([
shell(envB, "thread:2", true),
shell(envA, "thread:1", false),
shell(envB, "thread:3", true),
]);
expect(pending.environmentIds).toEqual([envB]);

// The key moves when any thread's flag turns on or off, even within an environment already
// read, and holds still when unrelated shells change.
const oneCleared = crewApprovalPollPlan([
shell(envB, "thread:2", true),
shell(envA, "thread:1", false),
shell(envB, "thread:3", false),
]);
expect(oneCleared.environmentIds).toEqual([envB]);
expect(oneCleared.key).not.toBe(pending.key);
expect(
crewApprovalPollPlan([shell(envB, "thread:3", true), shell(envB, "thread:2", true)]).key,
).toBe(pending.key);
});

it("counts seat approvals on the bell, up while waiting and back down once answered", () => {
expect(inboxBadgeCount(2, 0, 0)).toBe(2);
expect(inboxBadgeCount(2, 1, 3)).toBe(6);
expect(inboxBadgeCount(2, 1, 1)).toBe(4);
// Asks unread, Crew items read: still counted; nothing at all reads as no badge.
expect(inboxBadgeCount(null, 0, 1)).toBe(1);
expect(inboxBadgeCount(null, 0, 0)).toBeNull();
});
});
72 changes: 72 additions & 0 deletions apps/web/src/j5/crew/crewRuntimeRequests.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { J5ReadSources } from "@t3tools/client-runtime/j5/readSources";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
import type { EnvironmentId } from "@t3tools/contracts";
import type { CrewRuntimeRequestItem } from "@t3tools/contracts/j5";

import type { PendingApproval } from "../../session-logic";

export type ScopedCrewRuntimeRequest = CrewRuntimeRequestItem & {
readonly environmentId: EnvironmentId;
};

/** Every environment's seat approvals, each tagged with the environment that must answer it. */
export const mergeCrewRuntimeRequestSources = (
sources: J5ReadSources<ReadonlyArray<CrewRuntimeRequestItem>>,
): ReadonlyArray<ScopedCrewRuntimeRequest> =>
sources.sources.flatMap((source) =>
(source.data ?? []).map((request) => ({ ...request, environmentId: source.environmentId })),
);

/** The composer's approval shape; the server lists only approvals the provider can still take. */
export const toPendingApproval = (request: CrewRuntimeRequestItem): PendingApproval => ({
requestId: request.requestId,
requestKind: request.requestKind,
createdAt: request.createdAt,
...(request.detail === undefined ? {} : { detail: request.detail }),
...(request.appName === undefined ? {} : { appName: request.appName }),
...(request.options === undefined ? {} : { options: request.options }),
responseCapability: "live",
});

export interface CrewApprovalPollPlan {
/** Changes whenever a thread's pending approval appears or clears; empty when none is pending. */
readonly key: string;
/** The environments worth reading: only those with a thread waiting on an approval. */
readonly environmentIds: ReadonlyArray<EnvironmentId>;
}

/**
* Which environments the Inbox reads for seat approvals. A seat approval always raises its
* thread's `hasPendingApprovals`, so an environment with no such thread has nothing to list and
* costs no request.
*/
export const crewApprovalPollPlan = (
shells: ReadonlyArray<
Pick<EnvironmentThreadShell, "environmentId" | "id" | "hasPendingApprovals" | "archivedAt">
>,
): CrewApprovalPollPlan => {
const pending = shells
.filter((shell) => shell.hasPendingApprovals && shell.archivedAt === null)
.map((shell) => ({
environmentId: shell.environmentId,
entry: `${shell.environmentId}/${shell.id}`,
}))
.toSorted((left, right) => left.entry.localeCompare(right.entry));
return {
key: pending.map((shell) => shell.entry).join("\n"),
environmentIds: [...new Set(pending.map((shell) => shell.environmentId))],
};
};

/**
* The bell: open asks, mid-run seat requests, and seat approvals. An unknown ask count still shows
* the Crew items, since those were read.
*/
export const inboxBadgeCount = (
openAsks: number | null,
crewSeatRequests: number,
crewRuntimeRequests: number,
): number | null => {
const crew = crewSeatRequests + crewRuntimeRequests;
return openAsks === null ? (crew > 0 ? crew : null) : openAsks + crew;
};
Loading
Loading