From b74f5e42a803dbff8ea1809d81bd6d181f1ac579 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 16:13:26 +0100 Subject: [PATCH] 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 --- .../features/agents/activeAgentTurnsStore.ts | 9 +- .../features/agents/ui/useObserverEvents.ts | 56 +++ desktop/src/features/chats/ui/ChatDetail.tsx | 51 +- .../features/chats/ui/ChatStartPresets.tsx | 451 ++++++++++++++++++ .../src/features/chats/ui/QuickStartChat.tsx | 203 +++++++- desktop/tests/e2e/chats-first-message.spec.ts | 32 ++ 6 files changed, 760 insertions(+), 42 deletions(-) create mode 100644 desktop/src/features/chats/ui/ChatStartPresets.tsx diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 020109a01..13252de50 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -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 } + { anchorAt: number; agentPubkeys: Set; 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; diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 5c767d187..7a59db48c 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -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 diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index a6a39f1ff..365d41118 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -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], diff --git a/desktop/src/features/chats/ui/ChatStartPresets.tsx b/desktop/src/features/chats/ui/ChatStartPresets.tsx new file mode 100644 index 000000000..88bdaea52 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatStartPresets.tsx @@ -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 ( +
+ + {projectCard} + +
+ ); +} + +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 ( + + ); +} + +function PickerRow({ + checked, + icon, + label, + meta, + onSelect, +}: { + checked?: boolean; + icon: React.ReactNode; + label: string; + meta?: string | null; + onSelect: () => void; +}) { + return ( + + ); +} + +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" ? ( + + ) : selectedAgent?.avatarUrl ? ( + + ) : ( + + ); + + return ( + + + + + +
+ } + label={defaultAgentName} + meta="Default agent" + onSelect={() => { + onAgentPresetChange({ kind: "default" }); + setOpen(false); + }} + /> + {agents.map((agent) => ( + + } + 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 ? ( + <> +
+
+ Teams +
+ {teams.map((team) => ( + } + 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} +
+ + + ); +} + +export function ProjectPresetCard({ + isNoProjectSelected, + selectedProject, + ...props +}: { + isNoProjectSelected: boolean; + selectedProject: ChatProject | null; +} & React.ComponentPropsWithoutRef<"button">) { + return ( + + ) : ( + + ) + } + 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([]); + 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 ( + + + } + 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`} + /> + + +
+ + setQuery(event.target.value)} + placeholder="Search people" + value={query} + /> +
+ {invited.length > 0 ? ( +
+ {invited.map((person) => ( +
+ + + {person.displayName ?? person.pubkey.slice(0, 8)} + + +
+ ))} +
+ ) : null} +
+ {results + .filter((user) => !invitedPubkeys.has(normalizePubkey(user.pubkey))) + .map((user) => ( + + } + 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 ? ( +
+ No people found +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/chats/ui/QuickStartChat.tsx b/desktop/src/features/chats/ui/QuickStartChat.tsx index 26b4b9c3b..40271c14c 100644 --- a/desktop/src/features/chats/ui/QuickStartChat.tsx +++ b/desktop/src/features/chats/ui/QuickStartChat.tsx @@ -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({ + kind: "default", + }); + const [invited, setInvited] = React.useState([]); 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 => { + 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 /> -
-
-

Start a chat

-

- Describe a task or ask a question. -

+
+
+
+

+ Start a chat +

+

+ Describe a task or ask a question. +

+
+ + } + /> + } + teams={usableTeams} + />
@@ -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({ <> - - - - {selectedProject?.name || - (isNoProjectSelected ? "No project" : "Project")} - - + {trigger ?? ( + + + + {selectedProject?.name || + (isNoProjectSelected ? "No project" : "Project")} + + + )}
diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 6d8bc4f8c..dc15e42dc 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -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, }) => {