mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
New-chat preset cards and activity from every agent in a chat
New chat screen: the "Start a chat" center gains channel-intro-style preset cards. Default agent shows who will handle the chat with a picker to swap in another managed agent or a whole team (team personas are created in the chat like template agents, first one becomes the default). Directory shows the selected project's path (or free-chat empty state) and opens the project picker. Invite searches the user directory and pre-adds people as members on create, merged with the @mention flow. Multi-agent activity: the chat transcript and working indicators only read the default agent, so a second agent's turns rendered nothing. The transcript now merges every active managed agent's items by timestamp, live turn ids come from the by-channel store (which now carries turnIds), and the composer stop button cancels every agent working in the chat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b6af79309f
commit
b74f5e42a8
@@ -53,6 +53,8 @@ export type ActiveChannelTurnSummary = {
|
||||
agentCount: number;
|
||||
agentPubkeys: string[];
|
||||
agentNames?: string[];
|
||||
/** Live turn ids in this channel, across all tracked agents. */
|
||||
turnIds: string[];
|
||||
};
|
||||
|
||||
// Module-level state: agentPubkey → turnId → ActiveTurn
|
||||
@@ -468,25 +470,27 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
|
||||
|
||||
const summaries = new Map<
|
||||
string,
|
||||
{ anchorAt: number; agentPubkeys: Set<string> }
|
||||
{ anchorAt: number; agentPubkeys: Set<string>; turnIds: string[] }
|
||||
>();
|
||||
|
||||
for (const [agentKey, agentTurns] of activeTurnsByAgent) {
|
||||
if (agentTurns.size === 0) continue;
|
||||
const offset = clockOffsetByAgent.get(agentKey) ?? 0;
|
||||
|
||||
for (const turn of agentTurns.values()) {
|
||||
for (const [turnId, turn] of agentTurns.entries()) {
|
||||
const anchorAt = turn.startedAt + offset;
|
||||
const summary = summaries.get(turn.channelId);
|
||||
if (!summary) {
|
||||
summaries.set(turn.channelId, {
|
||||
anchorAt,
|
||||
agentPubkeys: new Set([agentKey]),
|
||||
turnIds: [turnId],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.agentPubkeys.add(agentKey);
|
||||
summary.turnIds.push(turnId);
|
||||
if (anchorAt < summary.anchorAt) {
|
||||
summary.anchorAt = anchorAt;
|
||||
}
|
||||
@@ -499,6 +503,7 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
|
||||
anchorAt: summary.anchorAt,
|
||||
agentCount: summary.agentPubkeys.size,
|
||||
agentPubkeys: [...summary.agentPubkeys].sort(),
|
||||
turnIds: summary.turnIds.sort(),
|
||||
}))
|
||||
.sort((a, b) => a.channelId.localeCompare(b.channelId));
|
||||
cachedChannelTurnSummaries = result;
|
||||
|
||||
@@ -158,6 +158,62 @@ export function useLoadArchivedObserverEvents(enabled: boolean) {
|
||||
return { fetchOlderArchived, hasOlderArchived };
|
||||
}
|
||||
|
||||
const EMPTY_MERGED_TRANSCRIPT: TranscriptItem[] = [];
|
||||
|
||||
/**
|
||||
* Transcript items merged across several agents, ordered by timestamp — for
|
||||
* surfaces (chats) where more than one agent can be working and every
|
||||
* agent's activity must render. The merged array reference is stable until
|
||||
* one of the underlying per-agent transcripts changes.
|
||||
*/
|
||||
export function useAgentsTranscript(
|
||||
enabled: boolean,
|
||||
agentPubkeys: readonly string[],
|
||||
): TranscriptItem[] {
|
||||
const cacheRef = React.useRef<{
|
||||
parts: TranscriptItem[][];
|
||||
merged: TranscriptItem[];
|
||||
} | null>(null);
|
||||
|
||||
const getSnapshot = React.useCallback(() => {
|
||||
if (!enabled || agentPubkeys.length === 0) {
|
||||
return EMPTY_MERGED_TRANSCRIPT;
|
||||
}
|
||||
const parts = agentPubkeys.map((pubkey) =>
|
||||
getAgentTranscript(pubkey, true),
|
||||
);
|
||||
const cached = cacheRef.current;
|
||||
if (
|
||||
cached &&
|
||||
cached.parts.length === parts.length &&
|
||||
parts.every((part, index) => part === cached.parts[index])
|
||||
) {
|
||||
return cached.merged;
|
||||
}
|
||||
const merged =
|
||||
parts.length === 1
|
||||
? parts[0]
|
||||
: parts
|
||||
.flat()
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.timestamp) - Date.parse(right.timestamp),
|
||||
);
|
||||
cacheRef.current = { parts, merged };
|
||||
return merged;
|
||||
}, [agentPubkeys, enabled]);
|
||||
|
||||
const snapshot = React.useSyncExternalStore(subscribeToStore, getSnapshot);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled && agentPubkeys.length > 0) {
|
||||
void ensureRelayObserverSubscription();
|
||||
}
|
||||
}, [enabled, agentPubkeys]);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest agent-generated conversation title (`chat_title` observer frame)
|
||||
* for a channel. Requires an active observer subscription — pair with
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { scopeByChannel } from "@/features/agents/ui/agentSessionPanelLayout";
|
||||
import {
|
||||
useAgentChatTitle,
|
||||
useAgentTranscript,
|
||||
useAgentsTranscript,
|
||||
} from "@/features/agents/ui/useObserverEvents";
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import { useUpdateChatMetadataMutation } from "@/features/chats/hooks";
|
||||
@@ -120,22 +121,34 @@ export function ChatDetail({
|
||||
}: ChatDetailProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const updateMetadataMutation = useUpdateChatMetadataMutation();
|
||||
const hasObserver = defaultAgent ? isManagedAgentActive(defaultAgent) : false;
|
||||
const activeAgentTurns = useActiveAgentTurns(defaultAgent?.pubkey);
|
||||
// Every active managed agent, not just the default: a chat can have
|
||||
// several agents working and all of their activity must render.
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const activeAgentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = (managedAgentsQuery.data ?? [])
|
||||
.filter(isManagedAgentActive)
|
||||
.map((agent) => normalizePubkey(agent.pubkey));
|
||||
if (defaultAgent && isManagedAgentActive(defaultAgent)) {
|
||||
pubkeys.push(normalizePubkey(defaultAgent.pubkey));
|
||||
}
|
||||
return [...new Set(pubkeys)].sort();
|
||||
}, [defaultAgent, managedAgentsQuery.data]);
|
||||
const hasObserver = activeAgentPubkeys.length > 0;
|
||||
const activeChannelTurns = useActiveAgentTurnsByChannel();
|
||||
// Per-turn ids, not a channel-wide boolean: while a new turn runs, older
|
||||
// turn blocks must still render as completed (and never show their own
|
||||
// "Working" marker).
|
||||
const activeTurnIds = React.useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
activeAgentTurns
|
||||
activeChannelTurns
|
||||
.filter((turn) => turn.channelId === chat.id)
|
||||
.flatMap((turn) => turn.turnIds),
|
||||
),
|
||||
[activeAgentTurns, chat.id],
|
||||
[activeChannelTurns, chat.id],
|
||||
);
|
||||
const isChatTurnActive = activeTurnIds.size > 0;
|
||||
const transcript = useAgentTranscript(hasObserver, defaultAgent?.pubkey);
|
||||
const transcript = useAgentsTranscript(hasObserver, activeAgentPubkeys);
|
||||
const scopedTranscript = React.useMemo(
|
||||
() => scopeByChannel(transcript, chat.id),
|
||||
[chat.id, transcript],
|
||||
@@ -161,16 +174,24 @@ export function ChatDetail({
|
||||
[defaultAgent?.pubkey, messages, scopedTranscript],
|
||||
);
|
||||
const handleStopAgent = React.useCallback(() => {
|
||||
if (!defaultAgent?.pubkey) {
|
||||
return;
|
||||
}
|
||||
cancelManagedAgentTurn(defaultAgent.pubkey, chat.id).catch(
|
||||
(error: unknown) => {
|
||||
// Cancel every agent with a live turn in this chat; fall back to the
|
||||
// default agent when the turn store hasn't caught up yet.
|
||||
const workingPubkeys =
|
||||
activeChannelTurns.find((turn) => turn.channelId === chat.id)
|
||||
?.agentPubkeys ?? [];
|
||||
const targets =
|
||||
workingPubkeys.length > 0
|
||||
? workingPubkeys
|
||||
: defaultAgent?.pubkey
|
||||
? [defaultAgent.pubkey]
|
||||
: [];
|
||||
for (const pubkey of targets) {
|
||||
cancelManagedAgentTurn(pubkey, chat.id).catch((error: unknown) => {
|
||||
console.error("Failed to stop agent turn", error);
|
||||
toast.error("Could not stop the agent");
|
||||
},
|
||||
);
|
||||
}, [chat.id, defaultAgent?.pubkey]);
|
||||
});
|
||||
}
|
||||
}, [activeChannelTurns, chat.id, defaultAgent?.pubkey]);
|
||||
const selectedProject = React.useMemo(
|
||||
() => chatProjectForMetadata(metadata),
|
||||
[metadata],
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
Notebook,
|
||||
NotepadTextDashed,
|
||||
Search,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ChatProject } from "@/features/chats/lib/chatSetup";
|
||||
import type {
|
||||
AgentTeam,
|
||||
ManagedAgent,
|
||||
UserSearchResult,
|
||||
} from "@/shared/api/types";
|
||||
import { searchUsers } from "@/shared/api/tauri";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
/** Which agent(s) the new chat starts with. */
|
||||
export type ChatAgentPreset =
|
||||
| { kind: "default" }
|
||||
| { kind: "agent"; agent: ManagedAgent }
|
||||
| { kind: "team"; team: AgentTeam };
|
||||
|
||||
export type ChatInvitee = {
|
||||
pubkey: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export function chatAgentPresetLabel(
|
||||
preset: ChatAgentPreset,
|
||||
defaultAgentName: string,
|
||||
) {
|
||||
if (preset.kind === "agent") {
|
||||
return preset.agent.name;
|
||||
}
|
||||
if (preset.kind === "team") {
|
||||
return preset.team.name;
|
||||
}
|
||||
return defaultAgentName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset cards for the new-chat screen — same container language as the
|
||||
* channel-intro action cards: default agent (swap to another agent or a
|
||||
* team), the project's working directory, and pre-invited people.
|
||||
*/
|
||||
export function ChatStartPresets({
|
||||
agentPreset,
|
||||
agents,
|
||||
defaultAgentName,
|
||||
invited,
|
||||
onAgentPresetChange,
|
||||
onInvitedChange,
|
||||
projectCard,
|
||||
teams,
|
||||
}: {
|
||||
agentPreset: ChatAgentPreset;
|
||||
agents: ManagedAgent[];
|
||||
defaultAgentName: string;
|
||||
invited: ChatInvitee[];
|
||||
onAgentPresetChange: (preset: ChatAgentPreset) => void;
|
||||
onInvitedChange: (invited: ChatInvitee[]) => void;
|
||||
/** Rendered as the middle card — the project picker owns its popover. */
|
||||
projectCard: React.ReactNode;
|
||||
teams: AgentTeam[];
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<AgentPresetCard
|
||||
agentPreset={agentPreset}
|
||||
agents={agents}
|
||||
defaultAgentName={defaultAgentName}
|
||||
onAgentPresetChange={onAgentPresetChange}
|
||||
teams={teams}
|
||||
/>
|
||||
{projectCard}
|
||||
<InviteCard invited={invited} onInvitedChange={onInvitedChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PresetCardProps = React.ComponentPropsWithoutRef<"button"> & {
|
||||
icon: React.ReactNode;
|
||||
subtitle: string;
|
||||
testId?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
// Plain-prop spread + ref forwarding so Radix `asChild` triggers can drive
|
||||
// the card (popover click/aria props arrive via ...props).
|
||||
export function PresetCard({
|
||||
icon,
|
||||
subtitle,
|
||||
testId,
|
||||
title,
|
||||
...props
|
||||
}: PresetCardProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex h-28 w-64 shrink-0 flex-col rounded-2xl border border-border/70 bg-background/70 p-4 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid={testId}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground [&_svg]:h-4 [&_svg]:w-4">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="mt-auto min-w-0">
|
||||
<span className="block truncate text-base font-medium leading-6 text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<span className="block truncate text-sm leading-5 text-muted-foreground">
|
||||
{subtitle}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerRow({
|
||||
checked,
|
||||
icon,
|
||||
label,
|
||||
meta,
|
||||
onSelect,
|
||||
}: {
|
||||
checked?: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
meta?: string | null;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/60 focus-visible:bg-muted/60"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{label}</span>
|
||||
{meta ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{checked ? <Check className="h-4 w-4 shrink-0" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPresetCard({
|
||||
agentPreset,
|
||||
agents,
|
||||
defaultAgentName,
|
||||
onAgentPresetChange,
|
||||
teams,
|
||||
}: {
|
||||
agentPreset: ChatAgentPreset;
|
||||
agents: ManagedAgent[];
|
||||
defaultAgentName: string;
|
||||
onAgentPresetChange: (preset: ChatAgentPreset) => void;
|
||||
teams: AgentTeam[];
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedAgent = agentPreset.kind === "agent" ? agentPreset.agent : null;
|
||||
const icon =
|
||||
agentPreset.kind === "team" ? (
|
||||
<Users aria-hidden />
|
||||
) : selectedAgent?.avatarUrl ? (
|
||||
<UserAvatar
|
||||
avatarUrl={selectedAgent.avatarUrl}
|
||||
displayName={selectedAgent.name}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Bot aria-hidden />
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<PresetCard
|
||||
icon={icon}
|
||||
subtitle={agentPreset.kind === "team" ? "Team" : "Default agent"}
|
||||
testId="chat-preset-agent"
|
||||
title={chatAgentPresetLabel(agentPreset, defaultAgentName)}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-2">
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<PickerRow
|
||||
checked={agentPreset.kind === "default"}
|
||||
icon={<Bot aria-hidden />}
|
||||
label={defaultAgentName}
|
||||
meta="Default agent"
|
||||
onSelect={() => {
|
||||
onAgentPresetChange({ kind: "default" });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
{agents.map((agent) => (
|
||||
<PickerRow
|
||||
checked={
|
||||
agentPreset.kind === "agent" &&
|
||||
normalizePubkey(agentPreset.agent.pubkey) ===
|
||||
normalizePubkey(agent.pubkey)
|
||||
}
|
||||
icon={
|
||||
<UserAvatar
|
||||
avatarUrl={agent.avatarUrl ?? null}
|
||||
displayName={agent.name}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
key={agent.pubkey}
|
||||
label={agent.name}
|
||||
meta={
|
||||
agent.status === "running" || agent.status === "deployed"
|
||||
? "Running"
|
||||
: "Stopped"
|
||||
}
|
||||
onSelect={() => {
|
||||
onAgentPresetChange({ kind: "agent", agent });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{teams.length > 0 ? (
|
||||
<>
|
||||
<div className="my-2 border-t border-border/60" />
|
||||
<div className="px-2 pb-1 text-xs font-medium text-muted-foreground">
|
||||
Teams
|
||||
</div>
|
||||
{teams.map((team) => (
|
||||
<PickerRow
|
||||
checked={
|
||||
agentPreset.kind === "team" &&
|
||||
agentPreset.team.id === team.id
|
||||
}
|
||||
icon={<Users aria-hidden />}
|
||||
key={team.id}
|
||||
label={team.name}
|
||||
meta={
|
||||
team.personaIds.length === 1
|
||||
? "1 agent"
|
||||
: `${team.personaIds.length} agents`
|
||||
}
|
||||
onSelect={() => {
|
||||
onAgentPresetChange({ kind: "team", team });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectPresetCard({
|
||||
isNoProjectSelected,
|
||||
selectedProject,
|
||||
...props
|
||||
}: {
|
||||
isNoProjectSelected: boolean;
|
||||
selectedProject: ChatProject | null;
|
||||
} & React.ComponentPropsWithoutRef<"button">) {
|
||||
return (
|
||||
<PresetCard
|
||||
{...props}
|
||||
icon={
|
||||
selectedProject ? (
|
||||
<Notebook aria-hidden />
|
||||
) : (
|
||||
<NotepadTextDashed aria-hidden />
|
||||
)
|
||||
}
|
||||
subtitle={
|
||||
selectedProject
|
||||
? (selectedProject.path ?? "No directory")
|
||||
: isNoProjectSelected
|
||||
? "Free chat — no directory"
|
||||
: "Pick a project"
|
||||
}
|
||||
testId="chat-preset-directory"
|
||||
title={selectedProject ? selectedProject.name : "No project"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCard({
|
||||
invited,
|
||||
onInvitedChange,
|
||||
}: {
|
||||
invited: ChatInvitee[];
|
||||
onInvitedChange: (invited: ChatInvitee[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [results, setResults] = React.useState<UserSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!open || trimmed.length === 0) {
|
||||
setResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
let cancelled = false;
|
||||
const handle = window.setTimeout(() => {
|
||||
searchUsers(trimmed)
|
||||
.then((users) => {
|
||||
if (!cancelled) {
|
||||
setResults(users);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResults([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsSearching(false);
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [open, query]);
|
||||
|
||||
const invitedPubkeys = new Set(
|
||||
invited.map((person) => normalizePubkey(person.pubkey)),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<PresetCard
|
||||
icon={<UserPlus aria-hidden />}
|
||||
subtitle={
|
||||
invited.length === 0
|
||||
? "Add someone to the chat"
|
||||
: invited
|
||||
.map((person) => person.displayName ?? "someone")
|
||||
.join(", ")
|
||||
}
|
||||
testId="chat-preset-invite"
|
||||
title={invited.length === 0 ? "Invite" : `${invited.length} invited`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-2">
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-9 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search people"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{invited.length > 0 ? (
|
||||
<div className="mb-2 flex flex-col gap-1">
|
||||
{invited.map((person) => (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-muted/40 px-2 py-1.5 text-sm"
|
||||
key={person.pubkey}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={person.avatarUrl}
|
||||
displayName={person.displayName ?? "?"}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{person.displayName ?? person.pubkey.slice(0, 8)}
|
||||
</span>
|
||||
<button
|
||||
aria-label={`Remove ${person.displayName ?? "invitee"}`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
onInvitedChange(
|
||||
invited.filter(
|
||||
(candidate) => candidate.pubkey !== person.pubkey,
|
||||
),
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{results
|
||||
.filter((user) => !invitedPubkeys.has(normalizePubkey(user.pubkey)))
|
||||
.map((user) => (
|
||||
<PickerRow
|
||||
icon={
|
||||
<UserAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
displayName={user.displayName ?? "?"}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
key={user.pubkey}
|
||||
label={user.displayName ?? user.pubkey.slice(0, 8)}
|
||||
meta={user.nip05Handle}
|
||||
onSelect={() => {
|
||||
onInvitedChange([
|
||||
...invited,
|
||||
{
|
||||
pubkey: user.pubkey,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
]);
|
||||
setQuery("");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{query.trim() && !isSearching && results.length === 0 ? (
|
||||
<div className={cn("px-2 py-3 text-sm text-muted-foreground")}>
|
||||
No people found
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,28 @@ import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import {
|
||||
managedAgentsQueryKey,
|
||||
useAvailableAcpRuntimes,
|
||||
useManagedAgentsQuery,
|
||||
usePersonasQuery,
|
||||
useTeamsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import {
|
||||
attachManagedAgentToChannel,
|
||||
createChannelManagedAgents,
|
||||
type CreateChannelManagedAgentInput,
|
||||
} from "@/features/agents/channelAgents";
|
||||
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
|
||||
import {
|
||||
getUsableTeams,
|
||||
resolveTeamPersonas,
|
||||
} from "@/features/agents/lib/teamPersonas";
|
||||
import { useLastRuntime } from "@/features/agents/lib/useLastRuntime";
|
||||
import {
|
||||
type ChatAgentPreset,
|
||||
type ChatInvitee,
|
||||
ChatStartPresets,
|
||||
ProjectPresetCard,
|
||||
} from "@/features/chats/ui/ChatStartPresets";
|
||||
import {
|
||||
useCreateChatMutation,
|
||||
useSendChatContextMessageMutation,
|
||||
@@ -32,10 +52,18 @@ import {
|
||||
import { ChatProjectDialog } from "@/features/chats/ui/ChatProjectDialog";
|
||||
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { ensureWelcomeGuideAgentInChannel } from "@/features/onboarding/welcomeGuide";
|
||||
import {
|
||||
ensureWelcomeGuideAgentInChannel,
|
||||
WELCOME_GUIDE_AGENT_NAME,
|
||||
} from "@/features/onboarding/welcomeGuide";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { addChannelMembers, sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { Channel, ChannelTemplate } from "@/shared/api/types";
|
||||
import type {
|
||||
AgentTeam,
|
||||
Channel,
|
||||
ChannelTemplate,
|
||||
ManagedAgent,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -92,6 +120,10 @@ export function QuickStartChat({
|
||||
string | null
|
||||
>(() => initialProjectSelection(initialProjectId, projects));
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
const [agentPreset, setAgentPreset] = React.useState<ChatAgentPreset>({
|
||||
kind: "default",
|
||||
});
|
||||
const [invited, setInvited] = React.useState<ChatInvitee[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const createChatMutation = useCreateChatMutation();
|
||||
@@ -100,6 +132,14 @@ export function QuickStartChat({
|
||||
const templatesQuery = useChannelTemplatesQuery();
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const { applyAgents, applyCanvas } = useApplyTemplate();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const teamsQuery = useTeamsQuery();
|
||||
const acpRuntimesQuery = useAvailableAcpRuntimes();
|
||||
const { lastRuntimeId } = useLastRuntime();
|
||||
const usableTeams = React.useMemo(
|
||||
() => getUsableTeams(teamsQuery.data ?? [], personasQuery.data ?? []),
|
||||
[personasQuery.data, teamsQuery.data],
|
||||
);
|
||||
const templates = templatesQuery.data ?? [];
|
||||
const allProjects = projects;
|
||||
const selectedProject =
|
||||
@@ -138,6 +178,58 @@ export function QuickStartChat({
|
||||
[onProjectCreated],
|
||||
);
|
||||
|
||||
// Create the team's persona agents in the new chat (mirrors the template
|
||||
// agent flow) and return the first as the chat's default agent.
|
||||
const createTeamAgents = React.useCallback(
|
||||
async (team: AgentTeam, channelId: string): Promise<ManagedAgent> => {
|
||||
const runtimes = acpRuntimesQuery.data ?? [];
|
||||
const defaultProvider =
|
||||
runtimes.find((runtime) => runtime.id === lastRuntimeId) ??
|
||||
runtimes[0] ??
|
||||
null;
|
||||
if (!defaultProvider) {
|
||||
throw new Error("No agent runtimes available for the team");
|
||||
}
|
||||
const { resolvedPersonas } = resolveTeamPersonas(
|
||||
team,
|
||||
personasQuery.data ?? [],
|
||||
);
|
||||
const inputs: CreateChannelManagedAgentInput[] = resolvedPersonas.map(
|
||||
(persona) => ({
|
||||
runtime:
|
||||
resolvePersonaRuntime(persona.runtime, runtimes, defaultProvider)
|
||||
.runtime ?? defaultProvider,
|
||||
name: persona.displayName,
|
||||
personaId: persona.id,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
role: "bot",
|
||||
ensureRunning: true,
|
||||
}),
|
||||
);
|
||||
if (inputs.length === 0) {
|
||||
throw new Error("The team has no usable agents");
|
||||
}
|
||||
const result = await createChannelManagedAgents(channelId, inputs);
|
||||
const first = result.successes[0]?.agent;
|
||||
if (!first) {
|
||||
throw new Error(
|
||||
result.failures[0]?.error ?? "Could not create the team's agents",
|
||||
);
|
||||
}
|
||||
if (result.failures.length > 0) {
|
||||
toast.warning(
|
||||
result.failures.length === 1
|
||||
? "1 team agent could not be created"
|
||||
: `${result.failures.length} team agents could not be created`,
|
||||
);
|
||||
}
|
||||
return first;
|
||||
},
|
||||
[acpRuntimesQuery.data, lastRuntimeId, personasQuery.data],
|
||||
);
|
||||
|
||||
const handleCreate = React.useCallback(
|
||||
async (
|
||||
content: string,
|
||||
@@ -172,7 +264,21 @@ export function QuickStartChat({
|
||||
await applyCanvas(templateId, chat.id, title, projectCanvasContext);
|
||||
void applyAgents(templateId, chat.id);
|
||||
|
||||
const agent = await ensureWelcomeGuideAgentInChannel(chat.id, relayUrl);
|
||||
// The preset picked on the start screen decides which agent(s) the
|
||||
// chat opens with; the welcome guide remains the default.
|
||||
let agent: ManagedAgent;
|
||||
if (agentPreset.kind === "agent") {
|
||||
const attached = await attachManagedAgentToChannel(chat.id, {
|
||||
agent: agentPreset.agent,
|
||||
ensureRunning: true,
|
||||
role: "bot",
|
||||
});
|
||||
agent = attached.agent;
|
||||
} else if (agentPreset.kind === "team") {
|
||||
agent = await createTeamAgents(agentPreset.team, chat.id);
|
||||
} else {
|
||||
agent = await ensureWelcomeGuideAgentInChannel(chat.id, relayUrl);
|
||||
}
|
||||
// The agent may have just been created/started outside the mutation
|
||||
// hooks — refresh the managed-agents cache so the new chat resolves
|
||||
// its default agent immediately (agent replies render as agent rows,
|
||||
@@ -204,15 +310,21 @@ export function QuickStartChat({
|
||||
});
|
||||
}
|
||||
|
||||
const memberMentionPubkeys = nonAgentMentionPubkeys({
|
||||
defaultAgentPubkey: agent.pubkey,
|
||||
identityPubkey: identityQuery.data?.pubkey,
|
||||
managedAgentPubkeys:
|
||||
managedAgentsQuery.data?.map(
|
||||
(managedAgent) => managedAgent.pubkey,
|
||||
) ?? [],
|
||||
mentionPubkeys,
|
||||
});
|
||||
const memberMentionPubkeys = [
|
||||
...new Set([
|
||||
...nonAgentMentionPubkeys({
|
||||
defaultAgentPubkey: agent.pubkey,
|
||||
identityPubkey: identityQuery.data?.pubkey,
|
||||
managedAgentPubkeys:
|
||||
managedAgentsQuery.data?.map(
|
||||
(managedAgent) => managedAgent.pubkey,
|
||||
) ?? [],
|
||||
mentionPubkeys,
|
||||
}),
|
||||
// People picked in the invite preset card.
|
||||
...invited.map((person) => normalizePubkey(person.pubkey)),
|
||||
]),
|
||||
];
|
||||
if (memberMentionPubkeys.length > 0) {
|
||||
const result = await addChannelMembers({
|
||||
channelId: chat.id,
|
||||
@@ -266,9 +378,12 @@ export function QuickStartChat({
|
||||
}
|
||||
},
|
||||
[
|
||||
agentPreset,
|
||||
applyAgents,
|
||||
applyCanvas,
|
||||
createChatMutation,
|
||||
createTeamAgents,
|
||||
invited,
|
||||
identityQuery.data?.pubkey,
|
||||
isCreating,
|
||||
managedAgentsQuery.data,
|
||||
@@ -303,12 +418,45 @@ export function QuickStartChat({
|
||||
transparentChrome
|
||||
/>
|
||||
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-4xl flex-1 items-center px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="w-full text-center">
|
||||
<h2 className="text-xl font-semibold tracking-tight">Start a chat</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Describe a task or ask a question.
|
||||
</p>
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-4xl flex-1 items-center overflow-y-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="w-full">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
Start a chat
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Describe a task or ask a question.
|
||||
</p>
|
||||
</div>
|
||||
<ChatStartPresets
|
||||
agentPreset={agentPreset}
|
||||
agents={managedAgentsQuery.data ?? []}
|
||||
defaultAgentName={WELCOME_GUIDE_AGENT_NAME}
|
||||
invited={invited}
|
||||
onAgentPresetChange={setAgentPreset}
|
||||
onInvitedChange={setInvited}
|
||||
projectCard={
|
||||
<ProjectPicker
|
||||
onCreateProject={handleCreateProject}
|
||||
onSelectProject={setSelectedProjectId}
|
||||
isNoProjectSelected={
|
||||
selectedProjectId === NO_PROJECT_SELECTION_ID
|
||||
}
|
||||
projects={allProjects}
|
||||
selectedProject={selectedProject}
|
||||
templates={templates}
|
||||
trigger={
|
||||
<ProjectPresetCard
|
||||
isNoProjectSelected={
|
||||
selectedProjectId === NO_PROJECT_SELECTION_ID
|
||||
}
|
||||
selectedProject={selectedProject}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
teams={usableTeams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -364,6 +512,7 @@ export function ProjectPicker({
|
||||
projects,
|
||||
selectedProject,
|
||||
templates,
|
||||
trigger,
|
||||
}: {
|
||||
isNoProjectSelected: boolean;
|
||||
onCreateProject: (project: ChatProject) => void;
|
||||
@@ -371,6 +520,8 @@ export function ProjectPicker({
|
||||
projects: ChatProject[];
|
||||
selectedProject: ChatProject | null;
|
||||
templates: ChannelTemplate[];
|
||||
/** Custom popover trigger; defaults to the composer's setup pill. */
|
||||
trigger?: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -392,13 +543,15 @@ export function ProjectPicker({
|
||||
<>
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<SetupPill className="max-w-64" testId="chat-project-picker">
|
||||
<Notebook className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedProject?.name ||
|
||||
(isNoProjectSelected ? "No project" : "Project")}
|
||||
</span>
|
||||
</SetupPill>
|
||||
{trigger ?? (
|
||||
<SetupPill className="max-w-64" testId="chat-project-picker">
|
||||
<Notebook className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedProject?.name ||
|
||||
(isNoProjectSelected ? "No project" : "Project")}
|
||||
</span>
|
||||
</SetupPill>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-2">
|
||||
<div className="relative mb-2">
|
||||
|
||||
@@ -196,6 +196,38 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
|
||||
await page.screenshot({ path: "test-results/agent-pr-card.png" });
|
||||
});
|
||||
|
||||
test("new chat screen shows agent, directory, and invite preset cards", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/#/chats");
|
||||
|
||||
const agentCard = page.getByTestId("chat-preset-agent");
|
||||
const directoryCard = page.getByTestId("chat-preset-directory");
|
||||
const inviteCard = page.getByTestId("chat-preset-invite");
|
||||
await expect(agentCard).toBeVisible();
|
||||
await expect(agentCard).toContainText("Fizz");
|
||||
await expect(directoryCard).toBeVisible();
|
||||
await expect(inviteCard).toBeVisible();
|
||||
await expect(inviteCard).toContainText("Invite");
|
||||
|
||||
// Agent picker lists the default agent option.
|
||||
await agentCard.click();
|
||||
await expect(
|
||||
page.getByRole("dialog").getByText("Default agent"),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Invite picker searches the user directory and stores a selection.
|
||||
await inviteCard.click();
|
||||
await page.getByPlaceholder("Search people").fill("alice");
|
||||
await page.getByRole("dialog").getByText("alice").click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(inviteCard).toContainText("1 invited");
|
||||
|
||||
await page.screenshot({ path: "test-results/chat-start-presets.png" });
|
||||
});
|
||||
|
||||
test("sidebar chat title shimmers while the agent has an active turn", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user