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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A Bot in trouble no longer needs somebody watching

A boundary refusal or a stalled run was recorded and then waited for a person to happen to look — at
the right channel, or at the audit page an administrator has and nobody else does. The trail knew;
nobody was told.

**Attention**, in the sidebar for everybody, shows the refusals and stalled runs nobody has handled
yet, scoped to the Bots this person may use, with a badge saying how many. Marking one handled
clears it for everyone and records who did; two people pressing Resolve at once is settled by the
database rather than by luck, and the second is told who got there first.

It is a view over the trail, not a second record of it. Refusals and stalls are already written
transactionally by the gateway and the stall guard, so the inbox cannot miss one and nothing new
runs on the action path. The only state it owns is the resolution, held beside the append-only trail
rather than in it. The trail itself still keeps everything; the inbox is only what is open now.
### A channel a Bot has spoken in unseen shows a dot

The sidebar marks a channel when a Bot has said something since you last had it open: a dot beside
Expand Down
34 changes: 34 additions & 0 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
IconBellRinging,
IconBolt,
IconBox,
IconLogout,
Expand Down Expand Up @@ -45,6 +46,7 @@ import {
SidebarRail,
} from "@/components/ui/sidebar";
import { signOutMutationOptions } from "@/lib/auth/mutations";
import { attentionListQueryOptions } from "@/lib/attention/queries";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import {
type ChannelSummary,
Expand Down Expand Up @@ -204,6 +206,9 @@ function ChannelRow({

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: currentUser } = useQuery(currentUserQueryOptions());
// Unhandled attention items this person may see; drawn as a badge only when nonzero.
const attentionCount =
useQuery(attentionListQueryOptions()).data?.length ?? 0;
const queryClient = useQueryClient();
const navigate = useNavigate();
const signOut = useMutation(signOutMutationOptions(queryClient));
Expand Down Expand Up @@ -321,6 +326,35 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="gap-px">
<SidebarMenuItem>
{/*
* Above Skills because it is the row that can be urgent. The count is the number of
* unhandled items this person may see; zero draws no badge, because an empty inbox
* asking for attention is the boy who cried wolf.
*/}
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
render={(props) => (
<Link
{...props}
to="/attention"
activeProps={{
className: "bg-foreground/5",
}}
/>
)}
>
<div className="size-[28px] flex items-center justify-center">
<IconBellRinging />
</div>
<span className="text-sm trackint-tight">Attention</span>
{attentionCount > 0 ? (
<span className="ml-auto rounded-full bg-destructive px-1.5 text-destructive-foreground text-xs tabular-nums">
{attentionCount}
</span>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{/* Beside Agents rather than inside Admin: writing a skill is something anybody does. */}
<SidebarMenuButton
Expand Down
27 changes: 27 additions & 0 deletions app/src/lib/attention/mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { QueryClient } from "@tanstack/react-query";
import { mutationOptions } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { attentionKeys } from "./queries";

/** Who handled it and when, echoed back so the second presser learns who got there first. */
export type AttentionResolution = {
auditEventId: string;
resolvedBy: string;
resolvedAt: string;
};

export function resolveAttentionMutationOptions(queryClient: QueryClient) {
return mutationOptions({
mutationFn: (eventId: string): Promise<AttentionResolution> =>
client(
`/api/attention/${encodeURIComponent(eventId)}/resolve`,
"resolution",
{
method: "POST",
fallback: "The item could not be marked handled.",
},
),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: attentionKeys.all }),
});
}
34 changes: 34 additions & 0 deletions app/src/lib/attention/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { queryOptions } from "@tanstack/react-query";
import { client } from "@/lib/client";

/** One trail row that means a Bot is waiting on a person. */
export type AttentionItem = {
/** The trail row's id; resolving cites the exact row. */
id: string;
kind: "refused" | "tool_rejected" | "stalled";
botId: string;
at: string;
/** One sentence a person can act on, written by whatever recorded the row. */
sentence: string;
};

export const attentionKeys = {
all: ["attention"] as const,
list: () => ["attention", "list"] as const,
};

/**
* Polled the way grants are: often enough that a Bot in trouble is noticed inside a minute, and
* refetched on focus so coming back to the tab answers immediately.
*/
export function attentionListQueryOptions() {
return queryOptions({
queryKey: attentionKeys.list(),
refetchInterval: 15_000,
refetchOnWindowFocus: true,
queryFn: (): Promise<AttentionItem[]> =>
client("/api/attention", "items", {
fallback: "The attention list could not be loaded.",
}),
});
}
21 changes: 21 additions & 0 deletions app/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Route as AuthedAppRouteImport } from './routes/_authed/_app'
import { Route as AuthedAdminRouteRouteImport } from './routes/_authed/admin/route'
import { Route as AuthedSettingsRouteRouteImport } from './routes/_authed/settings/route'
import { Route as AuthedAppIndexRouteImport } from './routes/_authed/_app/index'
import { Route as AuthedAppAttentionRouteImport } from './routes/_authed/_app/attention'
import { Route as AuthedAppBotRouteImport } from './routes/_authed/_app/bot'
import { Route as AuthedAppSkillsRouteImport } from './routes/_authed/_app/skills'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
Expand Down Expand Up @@ -68,6 +69,11 @@ const AuthedAppIndexRoute = AuthedAppIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthedAppRoute,
} as any)
const AuthedAppAttentionRoute = AuthedAppAttentionRouteImport.update({
id: '/attention',
path: '/attention',
getParentRoute: () => AuthedAppRoute,
} as any)
const AuthedAppBotRoute = AuthedAppBotRouteImport.update({
id: '/bot',
path: '/bot',
Expand Down Expand Up @@ -203,6 +209,7 @@ export interface FileRoutesByFullPath {
'/sign': typeof SignRoute
'/admin': typeof AuthedAdminRouteRouteWithChildren
'/settings': typeof AuthedSettingsRouteRouteWithChildren
'/attention': typeof AuthedAppAttentionRoute
'/bot': typeof AuthedAppBotRoute
'/skills': typeof AuthedAppSkillsRoute
'/admin/audit': typeof AuthedAdminAuditRoute
Expand Down Expand Up @@ -231,6 +238,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof AuthedAppIndexRoute
'/sign': typeof SignRoute
'/attention': typeof AuthedAppAttentionRoute
'/bot': typeof AuthedAppBotRoute
'/skills': typeof AuthedAppSkillsRoute
'/admin/audit': typeof AuthedAdminAuditRoute
Expand Down Expand Up @@ -263,6 +271,7 @@ export interface FileRoutesById {
'/_authed/admin': typeof AuthedAdminRouteRouteWithChildren
'/_authed/settings': typeof AuthedSettingsRouteRouteWithChildren
'/_authed/_app': typeof AuthedAppRouteWithChildren
'/_authed/_app/attention': typeof AuthedAppAttentionRoute
'/_authed/_app/bot': typeof AuthedAppBotRoute
'/_authed/_app/skills': typeof AuthedAppSkillsRoute
'/_authed/admin/audit': typeof AuthedAdminAuditRoute
Expand Down Expand Up @@ -296,6 +305,7 @@ export interface FileRouteTypes {
| '/sign'
| '/admin'
| '/settings'
| '/attention'
| '/bot'
| '/skills'
| '/admin/audit'
Expand Down Expand Up @@ -324,6 +334,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/sign'
| '/attention'
| '/bot'
| '/skills'
| '/admin/audit'
Expand Down Expand Up @@ -355,6 +366,7 @@ export interface FileRouteTypes {
| '/_authed/admin'
| '/_authed/settings'
| '/_authed/_app'
| '/_authed/_app/attention'
| '/_authed/_app/bot'
| '/_authed/_app/skills'
| '/_authed/admin/audit'
Expand Down Expand Up @@ -431,6 +443,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAppIndexRouteImport
parentRoute: typeof AuthedAppRoute
}
'/_authed/_app/attention': {
id: '/_authed/_app/attention'
path: '/attention'
fullPath: '/attention'
preLoaderRoute: typeof AuthedAppAttentionRouteImport
parentRoute: typeof AuthedAppRoute
}
'/_authed/_app/bot': {
id: '/_authed/_app/bot'
path: '/bot'
Expand Down Expand Up @@ -663,6 +682,7 @@ const AuthedSettingsRouteRouteWithChildren =
AuthedSettingsRouteRoute._addFileChildren(AuthedSettingsRouteRouteChildren)

interface AuthedAppRouteChildren {
AuthedAppAttentionRoute: typeof AuthedAppAttentionRoute
AuthedAppBotRoute: typeof AuthedAppBotRoute
AuthedAppSkillsRoute: typeof AuthedAppSkillsRoute
AuthedAppIndexRoute: typeof AuthedAppIndexRoute
Expand All @@ -672,6 +692,7 @@ interface AuthedAppRouteChildren {
}

const AuthedAppRouteChildren: AuthedAppRouteChildren = {
AuthedAppAttentionRoute: AuthedAppAttentionRoute,
AuthedAppBotRoute: AuthedAppBotRoute,
AuthedAppSkillsRoute: AuthedAppSkillsRoute,
AuthedAppIndexRoute: AuthedAppIndexRoute,
Expand Down
130 changes: 130 additions & 0 deletions app/src/routes/_authed/_app/attention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import {
IconAlertTriangle,
IconHandStop,
IconPlugOff,
} from "@tabler/icons-react";
import {
PageRows,
PageSection,
PageShell,
} from "@/components/layout/page-shell";
import { Button } from "@/components/ui/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
import type { AttentionItem } from "@/lib/attention/queries";
import { attentionListQueryOptions } from "@/lib/attention/queries";
import { resolveAttentionMutationOptions } from "@/lib/attention/mutations";
import { queryClient } from "@/query-client";

/**
* What is waiting on a person: boundary refusals and stalled runs, drawn from the trail and gone
* once somebody marks them handled. The trail itself keeps everything; this page is only what is
* open now.
*/

export const Route = createFileRoute("/_authed/_app/attention")({
component: AttentionPage,
});

const KIND_WORDS: Record<AttentionItem["kind"], string> = {
refused: "Action refused",
tool_rejected: "Tool call refused",
stalled: "Run stalled",
};

function KindIcon({ kind }: { kind: AttentionItem["kind"] }) {
if (kind === "stalled") return <IconPlugOff />;
if (kind === "tool_rejected") return <IconHandStop />;
return <IconAlertTriangle />;
}

function AttentionPage() {
const items = useQuery(attentionListQueryOptions());
const resolve = useMutation(resolveAttentionMutationOptions(queryClient));

return (
<PageShell
description={
<>
Refusals and stalled runs that nobody has handled yet. Everything here
is already recorded in the trail; marking an item handled clears it
for everyone and says who did.
</>
}
title="Attention"
>
<PageSection title="Waiting on you">
{items.isPending ? null : items.error ? (
<p className="mt-2 text-destructive text-sm" role="alert">
The attention list could not be loaded.
</p>
) : items.data?.length === 0 ? (
<p className="mt-2 text-muted-foreground text-sm">
Nothing is waiting. A refusal or a stalled run will appear here.
</p>
) : (
<PageRows>
{items.data?.map((item, index) => (
<div key={item.id}>
{index > 0 ? <Separator /> : null}
<Item size="sm">
<ItemMedia variant="icon">
<KindIcon kind={item.kind} />
</ItemMedia>
<ItemContent>
<ItemTitle>
{KIND_WORDS[item.kind]} · {item.botId}
</ItemTitle>
<ItemDescription>
{item.sentence}{" "}
<span className="whitespace-nowrap">
{new Date(item.at).toLocaleString()}
</span>
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
render={(props) => (
<Link
{...props}
to="/bot"
search={{ agent: item.botId }}
/>
)}
size="sm"
variant="ghost"
>
Open Bot
</Button>
<Button
disabled={resolve.isPending}
onClick={() => resolve.mutate(item.id)}
size="sm"
variant="outline"
>
{resolve.isPending ? "Resolving…" : "Resolve"}
</Button>
</ItemActions>
</Item>
</div>
))}
</PageRows>
)}
{resolve.error ? (
<p className="mt-2 text-destructive text-xs" role="alert">
{resolve.error.message}
</p>
) : null}
</PageSection>
</PageShell>
);
}
1 change: 1 addition & 0 deletions server/drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default defineConfig({
"./src/db/schema/coworker.ts",
"./src/db/schema/components.ts",
"./src/db/schema/plugins.ts",
"./src/db/schema/attention.ts",
"./src/db/schema/work.ts",
],
out: "./drizzle",
Expand Down
8 changes: 8 additions & 0 deletions server/drizzle/0020_attention_resolutions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE "attention_resolutions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"audit_event_id" uuid NOT NULL,
"resolved_by" text NOT NULL,
"resolved_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "attention_resolutions_event_idx" ON "attention_resolutions" USING btree ("audit_event_id");
Loading