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:
klopez4212
2026-07-07 07:51:55 +01:00
co-authored by Claude Fable 5
parent b6af79309f
commit b74f5e42a8
6 changed files with 760 additions and 42 deletions
@@ -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
+36 -15
View File
@@ -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>
);
}
+178 -25
View File
@@ -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,
}) => {