Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a9c060c
Gate first sign-in behind a per-user onboarding wizard
guidovizoso Aug 28, 2026
01d723f
Merge remote-tracking branch 'origin/main' into guido/experience
guidovizoso Aug 28, 2026
403c7ac
Say work is offered aloud, so a sweep can start now instead of at its…
guidovizoso Aug 28, 2026
e77c0a1
Let a channel tell its members a turn is running in it
guidovizoso Aug 28, 2026
18cc3ef
Relay a handoff answer back into the conversation that asked
guidovizoso Aug 28, 2026
338f055
Show a channel working on the roster while a turn runs in it
guidovizoso Aug 28, 2026
97e0c64
Keep an open channel current with turns nobody here streamed
guidovizoso Aug 28, 2026
bd4287a
Start a new chat from anywhere with Shift+N
guidovizoso Aug 28, 2026
07ec44a
Format the onboarding route
guidovizoso Aug 28, 2026
56700ad
Read the asking conversation before taking the run's lock
guidovizoso Aug 31, 2026
9bc7d11
Refetch the roster as well as the user when onboarding completes
guidovizoso Aug 31, 2026
1fe4cd9
Say which wizard agents are examples, and unstick its two classes
guidovizoso Aug 31, 2026
a15b934
Say in the changelog what changed, including reversing 0.0.5's relay …
guidovizoso Aug 31, 2026
5e40b6b
Merge remote-tracking branch 'origin/main' into guido/experience
guidovizoso Aug 31, 2026
61e2065
Give the queue's wake-up listener back on the way out, like its siblings
davidmckayv Aug 31, 2026
cc96e73
Say in the README that this is a template to clone, not a product
davidmckayv Aug 31, 2026
df40257
Add a setup prompt for somebody doing this with an AI, and link it fr…
davidmckayv Aug 31, 2026
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
35 changes: 32 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A Bot's answer comes back to the conversation that asked

**This reverses what 0.0.5 shipped.** The 0.0.5 notes below say the asking Bot does not relay text
on the addressed Bot's behalf, and the answer lands in that Bot's own conversation. In practice
that meant reading the answer somewhere you never asked anything, so it is now the other way
around: the addressed Bot works in a scratch conversation nobody is shown, and the asking Bot
relays what came back — attributed by name — into the conversation you are watching. What you read
is the asking Bot's account of the answer rather than the answer verbatim; very long answers are
clipped to keep the relay itself from failing.

### Channels say when a Bot is working in them

A channel whose Bot is mid-turn shows a working indicator on its roster avatar — including turns no
browser started, such as a handoff running on the server or a routine. An open conversation also
picks up turns that arrived while nobody here streamed them, so a relayed answer appears without
leaving and coming back.

### First sign-in gets an onboarding wizard

A new person lands in a short welcome wizard before the app; everyone who signed in before this
upgrade is stamped as already onboarded by the migration and sees nothing.

### Shift+N starts a new chat from anywhere

Bound across the signed-in app, shown under **Settings → Keyboard shortcuts**, and inert while you
are typing in a field. Handoff work is also picked up the moment it is queued rather than at the
next poll, so an answer's round trip no longer pays up to two seconds per leg.

### A Bot's shell can no longer reach the embedded database without a password

In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the
Expand Down Expand Up @@ -113,9 +141,10 @@ separately in #287.
A Bot asked something it is not the right Bot for can now put the question to one that is. The
addressed Bot answers **as itself, in its own conversation**, with its own tools and its own
knowledge. The asking Bot does not relay text on its behalf, so what you read is the answer that
Bot actually gave rather than another Bot's summary of it. The asking conversation records that the
question was put and to whom. A Bot that judges no other Bot will do can instead reach the person
who asked it.
Bot actually gave rather than another Bot's summary of it. *(Reversed since: see Unreleased — the
answer is now relayed back into the conversation that asked.)* The asking conversation records
that the question was put and to whom. A Bot that judges no other Bot will do can instead reach
the person who asked it.

**No Bot may address any other until an administrator says so.** Which Bot may reach which is an
ordinary grant, made per Bot on that Bot's own screen under **Bots it may ask**, and a Bot with no
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ your own machine.

</div>

> **A template, not a product.** OpenBot is meant to be cloned and made your own. There is no hosted version to sign up for, and nothing here is published as a package to depend on: every workspace in this repository is private. You take the repository, replace the example tenant package under `examples/` with your own coworkers, channels and skills, and run it. Everything below describes a starting point, not a finished thing somebody operates for you.

> **Alpha, and under active development.** OpenBot is early. Expect rough edges and bugs, and expect things to move. Issues and pull requests are welcome.

> **Runs on your machine.** Everything below is written for a laptop. `.env.example` carries `OPENBOT_SINGLE_USER=true`, which admits every request as one administrator, so a fresh clone reaches the product without registering an OAuth client first. [Sign-in](#sign-in) turns that off, and is required before anybody else can reach the deployment.
Expand Down Expand Up @@ -57,6 +59,11 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),

## Quick start

> **Setting up with an AI assistant?** Paste [`prompt.txt`](prompt.txt) into it first. It carries the
> same steps as below plus the things that are easy to get wrong: which of the ten blank keys in
> `.env.example` are actually yours to fill (three), which the start script generates for you, and
> what each start-up refusal means. Every claim in it is checked against this repository.

1. Create `.env`:

```sh
Expand Down
2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@shadcn/react": "^0.3.0",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-form": "^1.33.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.27",
"better-auth": "^1.6.27",
Expand All @@ -31,6 +32,7 @@
"prompt-area": "^0.6.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-use-measure": "^2.1.7",
"shadcn": "^4.17.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
Expand Down
1 change: 1 addition & 0 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ function ChannelRow({
}
pinned={channel.pinned}
unread={unread}
busy={channel.busy ?? false}
/>
</motion.div>
);
Expand Down
8 changes: 7 additions & 1 deletion app/src/components/app-sidebar/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const Channel = memo(function Channel({
lastMessageAt,
pinned,
unread,
busy,
}: {
channelId: string;
participantIds: string[];
Expand All @@ -51,6 +52,7 @@ export const Channel = memo(function Channel({
lastMessageAt?: string;
pinned: boolean;
unread: boolean;
busy: boolean;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
Expand Down Expand Up @@ -110,7 +112,11 @@ export const Channel = memo(function Channel({
}}
>
<div className="">
<ChannelAvatar participantIds={participantIds} size={32} />
<ChannelAvatar
participantIds={participantIds}
size={32}
typing={busy}
/>
</div>
<div className="flex-col min-w-0 flex-1">
<div className="flex flex-row items-center justify-between gap-2">
Expand Down
71 changes: 48 additions & 23 deletions app/src/components/channels/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,81 @@
import Avatar from "boring-avatars";
import { memo } from "react";
import { cn } from "@/lib/utils";

/**
* Memoized roster avatar. Row updates usually change preview/timestamp only, and
* `use-channel-events` preserves participant id arrays for unchanged rows.
*
* `size-full` opts the generated SVG out of ancestor icon selectors such as
* `[&_svg:not([class*='size-'])]:size-4`.
*
* `typing` overlays a working indicator at the bottom-right — three bouncing dots, so a channel
* whose agent is mid-turn reads as busy from the roster without moving the row's layout.
*/
export const ChannelAvatar = memo(function ChannelAvatar({
participantIds,
size = 32,
typing = false,
}: {
participantIds: string[];
size?: number;
typing?: boolean;
}) {
const channelSize = participantIds?.length;

if (channelSize === 1) {
return (
<div className="" style={{ height: size, width: size }}>
<Avatar className="size-full" name={participantIds[0]} size={size} />
</div>
);
}

const firstThree = participantIds.slice(0, 3);

return (
<div
className="flex flex-row items-center"
style={{ height: size, width: size }}
>
{firstThree.map((c, i) => {
return (
const avatar =
channelSize === 1 ? (
<Avatar className="size-full" name={participantIds[0]} size={size} />
) : (
<div className="flex flex-row items-center size-full">
{participantIds.slice(0, 3).map((c, i, shown) => (
<div
key={c}
className="shrink-0 border-2 border-sidebar rounded-full flex items-center justify-center"
key={c}
style={{
height: size / (firstThree.length / 2),
width: size / (firstThree.length / 2),
height: size / (shown.length / 2),
width: size / (shown.length / 2),
transform: `translateX(${i * -75}%)`,
}}
>
<Avatar
className="size-full"
name={c}
size={size / (firstThree.length / 2)}
size={size / (shown.length / 2)}
/>
</div>
);
})}
))}
</div>
);

return (
<div className="relative" style={{ height: size, width: size }}>
{avatar}
{typing ? <TypingBadge /> : null}
</div>
);
});

/**
* Three bouncing dots in a small badge, ringed in the sidebar's own colour so it sits on the
* avatar as a badge rather than floating over it. The staggered negative delays start each dot at
* a different point in the same bounce, which is what makes the three read as one wave.
*/
function TypingBadge() {
return (
<div className="absolute -bottom-0.5 -right-0.5 flex items-center gap-0.5 rounded-full bg-sidebar p-0.5 ring-2 ring-sidebar">
<span className="sr-only">Working…</span>
<Dot className="[animation-delay:-0.3s]" />
<Dot className="[animation-delay:-0.15s]" />
<Dot />
</div>
);
}

function Dot({ className }: { className?: string }) {
return (
<span
className={cn("size-1 rounded-full bg-primary animate-bounce", className)}
/>
);
}
97 changes: 95 additions & 2 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@ import {
transcriptMessages,
} from "@/components/channels/transcript-messages";
import { agentListQueryOptions } from "@/lib/agents/queries";
import { recordChannelActivityMutationOptions } from "@/lib/channels/mutations";
import type { AgentChannel } from "@/lib/channels/queries";
import {
recordChannelActivityMutationOptions,
setChannelBusyMutationOptions,
} from "@/lib/channels/mutations";
import {
type AgentChannel,
type ChannelSummary,
channelKeys,
} from "@/lib/channels/queries";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { ConversationProvider } from "@/lib/copilot/conversation";
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
import { stoppedReason } from "@/lib/copilot/stopped-turn";
import { readThreadMessages } from "@/lib/copilot/thread-messages";
import { useSkillCommands } from "@/lib/plugins/skill-commands";
import { queryClient } from "@/query-client";
import { newId } from "../../lib/new-id";

/**
Expand Down Expand Up @@ -182,6 +190,73 @@ export function ChannelChat({
};
}, [copilotkit, agent, isReady, channel.threadId, runtimeAgentId]);

/*
* A turn nobody here streamed, surfaced while the channel is open.
*
* A relayed handoff answer runs on the server and lands in this thread with no browser attached.
* The roster hears about it — the activity socket patches the channel-list cache — but this
* transcript restores history once, on mount, and would show the new turn only after leaving and
* coming back. So it watches that same cache: when this channel's `lastMessageAt` advances to a
* moment a Bot authored, the durable history is read again. Riding the roster's own cache rather
* than a second subscription means "the sidebar updated" and "the transcript refreshes" are the
* one signal, and cannot drift apart.
*
* APPENDED BY ID, NOT COMPARED BY LENGTH. The stored history is not the local transcript: it
* keeps only what `readableTurns` can parse, and the local side keeps tool lines the platform
* does not hand back — so after a headless turn the stored read can be shorter than the screen
* and still hold the news. What is new is exactly the messages whose ids this transcript has
* never seen; appending them leaves everything local intact, and this tab's own turns echo back
* with ids already on screen and append nothing.
*
* Retried briefly, because the roster is patched when the turn is on record with the runner and
* the platform's read of the thread can be a beat behind it.
*/
useEffect(() => {
const authoredAt = () => {
const cache = queryClient.getQueryData<{
pages: { channels: ChannelSummary[] }[];
}>(channelKeys.list());
const summary = cache?.pages
.flatMap((page) => page.channels)
.find((row) => row.id === channel.id);
// Only a Bot's turn is news here; a person's own line arrives through the run that sent it.
if (!summary || summary.lastMessageAgentId === null) return null;
return summary.lastMessageAt;
};

let lastSeen = authoredAt();

const pull = () => {
void (async () => {
for (const delayMs of [0, 750, 1500]) {
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
const stored = await readThreadMessages(
channel.threadId,
runtimeAgentId,
);
const current = agentRef.current;
const seen = new Set(current.messages.map((message) => message.id));
const fresh = stored.messages.filter(
(message) => !seen.has(message.id),
);
if (fresh.length === 0) continue;
current.setMessages([...current.messages, ...fresh]);
return;
}
})();
};

return queryClient.getQueryCache().subscribe(() => {
const at = authoredAt();
if (at && at !== lastSeen) {
lastSeen = at;
pull();
}
});
}, [channel.id, channel.threadId, runtimeAgentId]);

// Tool calls from this conversation act on this coworker's own computer.
useActiveBot(runtimeAgentId);

Expand Down Expand Up @@ -222,6 +297,24 @@ export function ChannelChat({
* Tell the roster what was just said. Failures here must not block the conversation.
*/
const recordActivity = useMutation(recordChannelActivityMutationOptions());

/*
* Show this channel as working on the roster while its own turn runs.
*
* The server cannot see a person's turn begin — the runtime does not tell it — so the browser
* reports it, keyed on whether a turn is in flight. The server broadcasts it to every member, so
* the row shows the dots even on a tab that has since navigated elsewhere; a run that outlives
* this tab clears itself when the roster next refetches, which is the acceptable failure for a
* transient hint. Not cleared on unmount on purpose: a turn keeps running server-side after the
* person leaves the channel, and clearing here would drop the indicator while the work goes on.
*/
const setBusy = useMutation(setChannelBusyMutationOptions());
const busy = turnsInFlight > 0;
// Keyed on the busy transition alone; `setBusy.mutate` is a stable handle, not a dependency.
// biome-ignore lint/correctness/useExhaustiveDependencies: firing on the busy transition only.
useEffect(() => {
setBusy.mutate({ channelId: channel.id, busy });
}, [busy, channel.id]);
const report = (text: string, agentId: string | null) => {
const trimmed = text.trim();
if (!trimmed) return;
Expand Down
Loading