From c1e0ff051abfbef3cfc478b23a11a5cb8c26729c Mon Sep 17 00:00:00 2001 From: kevin9327 Date: Fri, 4 Sep 2026 07:02:45 +0900 Subject: [PATCH] Keep a handoff grant revocable after somebody hides the coworker it points at The Handoff section joins two lists that are not filtered the same way. The grants come from botsReachableFrom, a raw read of this coworker's `bot` grants with no visibility filter of any kind. The roster comes from GET /api/agents, which drops every coworker the signed-in person has hidden - and hidden is a per-person display preference, one row per user in agent_preferences, not a fact about the coworker. So an administrator who tidied a coworker off their own roster stopped being shown the grants pointing at it. No switch was drawn and no note said why, the "N of M" above the list quietly dropped by one, and nothing on any other screen manages these grants, so the grant could no longer be taken away at all. It was still in force. A hop is decided by mayAddress, which calls the same unfiltered botsReachableFrom (server/src/index.ts:346-353), so the coworker went on being asked while the only surface that could stop it had stopped listing it. A boundary you cannot see is one you cannot withdraw, and the comment right above this code already promised the opposite: "a stale grant may still be revoked - taking away is always allowed". The selection moves to lib/agents/handoff-roster.ts so the list and the count cannot disagree again. A coworker on your roster is offered as before. One you have hidden is offered only when a grant already points at it, marked on the row as hidden from your roster: hiding is a preference about clutter and this screen has no business undoing it for a coworker with nothing to withdraw. The count is over the rows actually drawn, which is the question somebody reading it is asking. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 ++ app/src/components/agents/handoff-panel.tsx | 52 +++++---- app/src/lib/agents/handoff-roster.ts | 88 ++++++++++++++ app/tests/handoff-roster.test.ts | 123 ++++++++++++++++++++ 4 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 app/src/lib/agents/handoff-roster.ts create mode 100644 app/tests/handoff-roster.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11fb9c67f..5fef66362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Hiding a coworker no longer hides the grants pointing at it + +Hiding a coworker is a preference about your own roster — one row per person — and the grants saying +which Bots may hand work to it are a deployment-wide fact an administrator set. The Handoff section +joined the two, so hiding a coworker from your roster took every grant aimed at it off the screen: +the switch was gone, no note said why, and the count above the list quietly dropped by one. Those +grants were still in force, because a hop is decided by the grant and not by anybody's roster, so +the coworker went on being asked while the only screen that could stop it had stopped listing it. +A coworker you have hidden now appears in that list when a grant already points at it, marked as +hidden from your roster, so it can be switched off. One you have hidden with nothing granted to it +stays hidden. + ### Duplicating a coworker keeps the endpoint it was copied from Duplicate used to point every copy at this deployment's own managed Bot, whatever the coworker being diff --git a/app/src/components/agents/handoff-panel.tsx b/app/src/components/agents/handoff-panel.tsx index 2c7a7a58d..393971727 100644 --- a/app/src/components/agents/handoff-panel.tsx +++ b/app/src/components/agents/handoff-panel.tsx @@ -15,6 +15,7 @@ import { ItemTitle, } from "@/components/ui/item"; import { Switch } from "@/components/ui/switch"; +import { handoffRoster } from "@/lib/agents/handoff-roster"; import { setHandoffGrantMutationOptions } from "@/lib/agents/mutations"; import { agentHandoffQueryOptions, @@ -38,29 +39,27 @@ export function HandoffPanel({ agentId }: { agentId: string }) { const queryClient = useQueryClient(); const handoff = useQuery(agentHandoffQueryOptions(agentId)); const agents = useQuery(agentListQueryOptions()); + /* + * The roster this person has hidden, read so a grant pointing into it can still be taken away. + * + * Hiding is a per-person display preference and the grants are not filtered by it at all, so + * joining the grants against the visible roster alone dropped live grants off the only screen that + * manages them. See `handoffRoster`, which is where that join now happens. + */ + const hiddenAgents = useQuery(agentListQueryOptions(true)); const setGrant = useMutation(setHandoffGrantMutationOptions(queryClient)); if (handoff.isPending || !handoff.data) return null; const { enabled, canGrant, reachable, grantable } = handoff.data; - /* - * A Bot may not be granted itself, and the server refuses it, so it is not offered here either. - * Hidden Bots are already absent from this list. - */ - const others = (agents.data ?? []).filter( - (candidate) => candidate.id !== agentId, - ); - const granted = others.filter((candidate) => - reachable.includes(candidate.id), - ).length; - /* - * On a Bot that cannot be a grantee only the leftovers are shown: a stale grant may still be - * revoked — taking away is always allowed — but offering switches that can only bounce off the - * server's refusal is the thing the explanation item above replaces. - */ - const candidates = grantable - ? others - : others.filter((candidate) => reachable.includes(candidate.id)); + // A Bot may not be granted itself, and the server refuses it, so it is not offered here either. + const { candidates, granted, total } = handoffRoster({ + agentId, + roster: agents.data ?? [], + hidden: hiddenAgents.data ?? [], + reachable, + grantable, + }); // Nothing to say to somebody who cannot change it and has nothing to read. if (!canGrant && reachable.length === 0) return null; @@ -72,9 +71,9 @@ export function HandoffPanel({ agentId }: { agentId: string }) { Bots it may ask {/* The current answer at a glance, so the list below is detail rather than homework. */} - {grantable && others.length > 0 ? ( + {grantable && total > 0 ? ( - {granted} of {others.length} + {granted} of {total} ) : null} @@ -121,7 +120,7 @@ export function HandoffPanel({ agentId }: { agentId: string }) {

) : null} - {grantable && others.length === 0 ? ( + {grantable && total === 0 ? ( @@ -147,7 +146,16 @@ export function HandoffPanel({ agentId }: { agentId: string }) { {candidate.name} - {candidate.title} + {/* + * Said on the row, because otherwise it is a coworker that is not on your roster + * appearing in a list with no explanation. It is here only because this Bot may + * already ask it, and that is the sentence a person needs to decide what to do. + */} + + {candidate.hidden + ? `${candidate.title} · hidden from your roster` + : candidate.title} +