Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
- Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to
empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them.

### A conversation shows who it is waiting on

When a Bot asks another Bot, the end of the conversation now shows that Bot's avatar and "Asking
Risk Analyst…" until the answer comes back; a routine firing into it reads "Market Scout is running a
routine…". A Bot whose address this deployment will not dial is reported at once, not after five
minutes of retries. The sidebar keeps the conversation lit while it waits, and both survive a
reload. In a shared conversation only the person whose Bot asked sees whom it asked; the others see
only that it is busy. Migration `0063_work_items_open_thread` adds one partial index to `work_items`.

### Screens share one spacing, row and empty-state design

Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px
Expand Down
3 changes: 2 additions & 1 deletion app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ function ChannelRow({
}
pinned={channel.pinned}
unread={unread}
busy={channel.busy ?? false}
// A turn running, or work the conversation waits on between turns: a hop, a routine.
busy={(channel.busy ?? false) || (channel.work?.length ?? 0) > 0}
/>
</motion.div>
);
Expand Down
18 changes: 18 additions & 0 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { HandoffResumeNotice } from "@/components/computer/handoff-resume-notice";
import { attachmentModality } from "@/components/channels/chat-messages";
import { toAgentOptions } from "@/components/channels/composer";
import { ChannelWorkRows } from "@/components/channels/channel-work";
import { ConversationView } from "@/components/channels/conversation-view";
import { VoiceCallWidget } from "@/components/channels/voice-call-widget";
import {
Expand Down Expand Up @@ -1081,6 +1082,23 @@ export function ChannelChat({
* run before closing it; see server/src/channels/stall-guard.ts.
*/
stopped={runError ?? undefined}
/*
* What the server says this conversation waits on: a Bot its Bot asked, a routine.
* Hidden while this tab's own turn is going, which already shows itself and would
* otherwise draw "Asking Ana…" under the sentence saying it had asked. The composer
* stays open either way: a person may keep talking while a hop runs, and a relay that
* finds them mid-turn waits and is tried again.
*/
working={
agent.isRunning || turnsInFlight > 0 ? null : (
<ChannelWorkRows
botOf={(botId) =>
agentProfiles?.find((profile) => profile.id === botId)
}
work={channel.work}
/>
)
}
/>
</div>
</ConversationProvider>
Expand Down
58 changes: 58 additions & 0 deletions app/src/components/channels/channel-work.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import type { ChannelWork } from "@/lib/channels/queries";
import { ProgressRow } from "./progress-row";

/** A Bot as this person knows it: its name and the seed its avatar is drawn from. */
export type KnownBot = { name: string; avatarSeed: string };

/**
* What a conversation is waiting on, one row each, at the end of the transcript.
*
* Driven by the server's list rather than this tab's run, because the work is not this tab's: a Bot
* the conversation's Bot asked answers out of sight, and a routine fires with nobody's browser
* open. Without these the conversation looks finished while something is still working on it.
*
* Gone when the list empties, which is when the answer is being written into the conversation or
* the work is over. The caller hides it while its own run is going, so a turn that has just asked
* another Bot does not show "Asking Ana…" under its own half-written reply.
*/
export function ChannelWorkRows({
work,
botOf,
}: {
work: readonly ChannelWork[] | undefined;
/** The Bot this person knows by that id, when they know it at all. */
botOf?: (botId: string) => KnownBot | undefined;
}) {
if (!work || work.length === 0) return null;
return (
<div className="flex flex-col gap-1" data-testid="channel-work">
{work.map((entry) => {
const known = botOf?.(entry.botId);
const name = known?.name ?? entry.botName;
return (
<ProgressRow
key={entry.key}
label={labelFor(entry, name)}
// Seeded by the id when the person does not know the Bot, so the same Bot still draws
// the same face every time.
media={
<AbstractAvatar
name={name ?? "Bot"}
seed={known?.avatarSeed ?? entry.botId}
size={20}
/>
}
/>
);
})}
</div>
);
}

function labelFor(entry: ChannelWork, name: string | null): string {
if (entry.kind === "handoff") {
return name ? `Asking ${name}…` : "Asking another Bot…";
}
return name ? `${name} is running a routine…` : "A routine is running…";
}
8 changes: 8 additions & 0 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ type ChatTranscriptProps = {
* be told, and only the thing that ended the turn knows which one happened.
*/
stopped?: string;
/**
* Work this conversation is waiting on that is not this tab's run: a Bot it asked, a routine.
* Drawn in the same slot as `Thinking`, under the last thing said, because that is where the
* answer will appear.
*/
working?: React.ReactNode;
};

/** One shared empty array, so a screen without a queue does not hand down a new one per render. */
Expand Down Expand Up @@ -1549,6 +1555,7 @@ export function ChatTranscript({
queued = EMPTY_QUEUE,
restoring = false,
stopped,
working,
}: ChatTranscriptProps) {
const isDrawn = useDrawnTool();
/*
Expand Down Expand Up @@ -1692,6 +1699,7 @@ export function ChatTranscript({
) : waitingOnFirstToken ? (
<Thinking />
) : null}
{working}
{queued.map((message) => (
<Queued
attachments={message.attachments}
Expand Down
4 changes: 4 additions & 0 deletions app/src/components/channels/conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function ConversationView({
pending = false,
autoFocus = false,
stopped,
working,
stoppable,
queueWhileBusy = false,
restoring = false,
Expand Down Expand Up @@ -69,6 +70,8 @@ export function ConversationView({
autoFocus?: boolean;
/** Why the last turn ended without an answer. Drawn at the end of the transcript, not here. */
stopped?: string;
/** Work the conversation waits on that is not this tab's run. Drawn at the end of the transcript. */
working?: ReactNode;
/**
* There is a run for Stop to abort, which is a narrower fact than `pending` and is the honest one
* to draw a Stop button from. Defaults to `pending` for a caller with no gap between the two.
Expand Down Expand Up @@ -432,6 +435,7 @@ export function ConversationView({
queued={queued}
restoring={restoring}
{...(stopped ? { stopped } : {})}
working={working}
/>
</div>
<div className="max-w-2xl mx-auto w-full px-0 pb-4 shrink-0">
Expand Down
33 changes: 33 additions & 0 deletions app/src/components/channels/progress-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";

/**
* One line saying something is under way, with who is doing it.
*
* For a wait the person did not start and cannot watch: a Bot asked by their Bot, a routine firing,
* a sign-in or an approval somebody else has to finish. Drawn where the result will appear, so the
* person looks at the right spot.
*
* It borrows the shimmer a running tool line uses, so every kind of "working on it" in the
* transcript reads the same.
*/
export function ProgressRow({
label,
media,
}: {
/** What is under way, as a person would say it: "Asking Ana…". */
label: string;
/** Who is doing it, drawn before the words: a Bot's avatar. */
media?: ReactNode;
}) {
return (
<p
className="flex items-center gap-2 text-muted-foreground text-sm"
data-testid="progress-row"
// `status` rather than `alert`: progress, not an interruption.
role="status"
>
{media ? <span aria-hidden="true">{media}</span> : null}
<span className="tool-line-running">{label}</span>
</p>
);
}
51 changes: 47 additions & 4 deletions app/src/lib/channels/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,49 @@ export type AgentChannel = {
* history, and one whose history this deployment cannot reach. See `channel-chat.tsx`.
*/
lastMessageAt: string | null;
/**
* What this conversation is waiting on right now, oldest first. See `ChannelWork`.
*
* Read with the channel and replaced whole by every busy event, so a reload shows the same thing
* the socket last said. Absent from a server that predates it, which reads as nothing.
*/
work?: ChannelWork[];
/**
* When the server read `work`, from the socket event that last set it. Absent on a fetched read.
* Kept so an older event arriving late does not put back a list that has moved on.
*/
workAt?: string;
};

/**
* Something a conversation is waiting on that nobody's browser is running: a Bot its Bot asked,
* answering out of sight, or a routine firing into it.
*
* The server reads it from the hop queue and the routine runs, so it holds for as long as the work
* does, across reloads and replicas. `since` is when it started, for the timer a person watches.
*/
export type ChannelWork =
| {
kind: "handoff";
key: string;
/** The Bot that was asked. */
botId: string;
/** Its name when it was asked, or null. Prefer the roster's name when the person has one. */
botName: string | null;
/** ISO-8601. */
since: string;
}
| {
kind: "routine";
key: string;
routineId: string;
/** The Bot the routine runs as. */
botId: string;
botName: string | null;
/** ISO-8601. */
since: string;
};

/** A channel plus what the roster renders about it. */
export type ChannelSummary = AgentChannel & {
/** A few words about the conversation, or null. The roster falls back to `name`. */
Expand All @@ -38,17 +79,19 @@ export type ChannelSummary = AgentChannel & {
/**
* Whether a turn is running in this channel right now.
*
* Socket-only and transient: the server never persists it and the roster query never returns it,
* so it is undefined until a busy event arrives and is dropped whenever the roster is refetched.
* A headless turn — a handoff hop, a relay — sets it, which is how the roster shows work the
* browser never streamed.
* Mostly socket-only and transient: a person's own run is announced and never stored, so a
* refetch drops it. The roster returns it true for a channel with `work`, which is stored, so a
* conversation a Bot is still answering in stays lit across a reload. A headless turn — a handoff
* hop, a relay — sets it, which is how the roster shows work the browser never streamed.
*/
busy?: boolean;
};

export const channelKeys = {
all: ["channels"] as const,
list: () => ["channels", "list"] as const,
/** Every channel read by id, to refetch them together. */
details: () => ["channels", "detail"] as const,
detail: (channelId: string) => ["channels", "detail", channelId] as const,
};

Expand Down
Loading
Loading