Files
buzz/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx
Wintermute a86f9e2d85 fix(desktop): scope agent sends to their captured tenant; thread follow-ups onto the opener
Two fifth-round review findings on the Projects agent send path.

Cross-tenant in-flight sends (P1): the submit flow suspends across
managed-agent startup, DM open, and the send itself. A community switch
during any suspension does not cancel the callback — remounting only
removes the UI — so a stale callback could open a DM or publish the
captured tenant's content on the NEW tenant's relay. Fix: the caller
captures the community relay scope before its first await and passes it
as expected_relay_url through open_dm and send_channel_message. Each
command resolves its relay base exactly once, asserts the captured scope
against it (assert_expected_relay_scope, ws(s)→http(s) normalized), and
uses that same base for every side effect — thread-ref read, submit, and
metadata read. A mismatch fails closed ("active community changed before
the message was submitted; not sent") rather than publishing to the
wrong tenant. Absent scope preserves unscoped behavior for callers with
no tenant boundary. submit_event_with_created_at becomes
submit_event_at_created_at (explicit base) so the scope-checked base is
the one used at submit time — re-resolving there would reopen the race.

Same-second follow-up hiding (P2 of the pair): follow-up sends carried
no parentEventId, so a follow-up signed within the opener's second got a
random event id and roughly half sorted on the rejected side of the
same-second id tiebreak in isAtOrAfterConversationOpener — an immediate
follow-up could vanish. Fix: follow-ups now reply to the opener
(parentEventId = opener.eventId); the comparator always admits causal
e-tag replies, so visibility no longer depends on id luck.

Both submit handlers (ProjectAgentChatPanel, ProjectsAgentPromptPage)
route through a new pure orchestration function,
submitProjectAgentMessage, which never re-reads the scope after capture
and is unit-tested without React: switch-during-startup publishes
nothing to either tenant, switch-during-DM-open fails closed, the
captured scope rides every relay side effect, and follow-ups reference
the opener. The e2e bridge mirrors the backend check after its injected
delays so specs can drive a mid-flight community switch
deterministically.

File-size ratchet keeps its discipline: the scope check lives in
relay/scope.rs, resolve_thread_ref moves to
commands/messages/thread_ref.rs, sendChannelMessage moves to
shared/api/tauriMessages.ts (re-exported from tauri.ts), and OpenDmInput
moves next to openDm in tauriChannels.ts.

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-17 00:44:54 -04:00

776 lines
26 KiB
TypeScript

import { EditorContent } from "@tiptap/react";
import {
ALargeSmall,
ChevronDown,
Loader2,
SendHorizontal,
Trash2,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
import {
getMentionableAgentPubkeys,
getSharedChannelIds,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
import { useCommunities } from "@/features/communities/useCommunities";
import {
useChannelMessagesQuery,
useChannelSubscription,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
import {
type LinkSelectionInfo,
useRichTextEditor,
} from "@/features/messages/lib/useRichTextEditor";
import { FormattingToolbar } from "@/features/messages/ui/FormattingToolbar";
import { TimelineMessageList } from "@/features/messages/ui/TimelineMessageList";
import type { TimelineMessage } from "@/features/messages/types";
import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import type { Project } from "@/features/projects/hooks";
import { AgentContextPayloadPreview } from "./AgentContextPayloadPreview";
import {
UNTRUSTED_CONTEXT_NOTICE,
untrustedPromptValue,
} from "@/features/projects/lib/projectDetailAgentContext";
import {
isAtOrAfterConversationOpener,
mergeProjectAgentConversationEvents,
restoreProjectsAgentConversation,
submitProjectAgentMessage,
} from "@/features/projects/lib/projectAgentConversation";
import {
clearStoredProjectsAgentConversation,
type ProjectsConversationOpener,
readStoredProjectsAgentConversation,
type StoredProjectsAgentConversation,
writeStoredProjectsAgentConversation,
} from "@/features/projects/lib/projectAgentConversationStorage";
import { useIdentityQuery } from "@/shared/api/hooks";
import { sendChannelMessage } from "@/shared/api/tauriMessages";
import type { Channel } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { UserAvatar } from "@/shared/ui/UserAvatar";
export type AgentCandidate = {
pubkey: string;
name: string;
/** Managed agents can be auto-started before the prompt is sent. */
isManaged: boolean;
isActive: boolean;
};
type ProjectAgentConversation = {
channel: Channel;
agent: AgentCandidate;
opener: ProjectsConversationOpener;
};
const MAX_CONTEXT_REPOS = 8;
const REPO_CONTEXT_MARKER = "Workspace repositories:";
/** Compact machine-readable footer so the agent can scope git queries
* (repo announcements are addressable by these coordinates). Only sent
* with the first message of a conversation. Project and repository names
* are relay-controlled — each value is neutralized and quoted, and the
* block carries the shared untrusted-data framing. */
function repoContextBlock(projects: readonly Project[]) {
if (projects.length === 0) return "";
const repositories = projects.flatMap((project) =>
project.repositories.map((repository) => ({
label:
project.repositories.length > 1
? `${project.name} / ${repository.name}`
: project.name,
repoAddress: repository.repoAddress,
})),
);
const listed = repositories
.slice(0, MAX_CONTEXT_REPOS)
.map(
(repository) =>
`- ${untrustedPromptValue(repository.label)} (address: ${untrustedPromptValue(repository.repoAddress, 400)})`,
);
const remaining = repositories.length - listed.length;
return ["", "---", REPO_CONTEXT_MARKER, ...listed]
.concat(remaining > 0 ? [`…and ${remaining} more`] : [])
.concat([UNTRUSTED_CONTEXT_NOTICE])
.join("\n");
}
function buildSuggestions(projects: readonly Project[]) {
const firstRepo = projects[0]?.name;
return [
{
label: "Reviews",
prompt: "Which reviews need attention today?",
},
{
label: "Release check",
prompt: firstRepo
? `Are we safe to cut a release of ${firstRepo} this week?`
: "Are we safe to cut a release this week?",
},
{
label: "Tasks",
prompt: "Summarize the open tasks and flag anything urgent.",
},
{
label: "Activity",
prompt: firstRepo
? `Summarize recent activity in ${firstRepo}.`
: "Summarize recent repository activity.",
},
];
}
/** Sorts runnable agents first so the default pick can answer immediately. */
export function useAgentCandidates() {
const identityQuery = useIdentityQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const channelsQuery = useChannelsQuery();
return React.useMemo(() => {
const managed = managedAgentsQuery.data ?? [];
const relayAgents = relayAgentsQuery.data ?? [];
const managedByPubkey = new Map(
managed.map((agent) => [normalizePubkey(agent.pubkey), agent]),
);
const mentionable = getMentionableAgentPubkeys({
currentPubkey: identityQuery.data?.pubkey,
eligibilityScope: { type: "community" },
managedAgentPubkeys: managedByPubkey.keys(),
relayAgents,
sharedChannelIds: getSharedChannelIds(channelsQuery.data),
});
const candidates: AgentCandidate[] = managed.map((agent) => ({
pubkey: normalizePubkey(agent.pubkey),
name: agent.name,
isManaged: true,
isActive: isManagedAgentActive(agent),
}));
for (const agent of relayAgents) {
const pubkey = normalizePubkey(agent.pubkey);
if (managedByPubkey.has(pubkey) || !mentionable.has(pubkey)) continue;
candidates.push({
pubkey,
name: agent.name,
isManaged: false,
isActive: agent.status !== "offline",
});
}
return candidates.sort((left, right) => {
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
if (left.isManaged !== right.isManaged) return left.isManaged ? -1 : 1;
return left.name.localeCompare(right.name);
});
}, [
channelsQuery.data,
identityQuery.data?.pubkey,
managedAgentsQuery.data,
relayAgentsQuery.data,
]);
}
/** Live message feed for the conversation's backing DM channel, reduced to
* plain chat rows (kind 9 / 40002 only). The user's own messages render
* verbatim — including any machine-appended context footer — so the exact
* payload signed under the user's identity is always visible. Hiding it
* while sending it would let relay-controlled metadata ride invisibly on
* the user's signature. */
export function ConversationThread({
channel,
agent,
agentAvatarUrl,
currentPubkey,
selfAvatarUrl,
opener,
}: {
channel: Channel;
agent: AgentCandidate;
agentAvatarUrl: string | null;
currentPubkey: string | null;
selfAvatarUrl: string | null;
opener: ProjectsConversationOpener;
}) {
useChannelSubscription(channel);
const messagesQuery = useChannelMessagesQuery(channel);
const threadRootIds = React.useMemo(
() =>
(messagesQuery.data ?? [])
.filter(
(event) =>
(event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2) &&
isAtOrAfterConversationOpener(event, opener) &&
getThreadReference(event.tags).parentId === null,
)
.map((event) => event.id),
[messagesQuery.data, opener],
);
const threadReplies = useThreadRepliesForRoots(channel, threadRootIds);
const toggleReactionMutation = useToggleReactionMutation();
const agentWorking = useAgentWorking(agent.pubkey, channel.id);
const bottomRef = React.useRef<HTMLDivElement>(null);
const normalizedCurrent = currentPubkey
? normalizePubkey(currentPubkey)
: null;
const profiles = React.useMemo(
() => ({
[normalizePubkey(agent.pubkey)]: {
avatarUrl: agentAvatarUrl,
displayName: agent.name,
isAgent: true,
name: agent.name,
nip05Handle: null,
ownerPubkey: null,
},
...(normalizedCurrent
? {
[normalizedCurrent]: {
avatarUrl: selfAvatarUrl,
displayName: "You",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
}
: {}),
}),
[
agent.name,
agent.pubkey,
agentAvatarUrl,
normalizedCurrent,
selfAvatarUrl,
],
);
const messages = React.useMemo(() => {
const events = mergeProjectAgentConversationEvents(
messagesQuery.data ?? [],
threadReplies.events,
);
return formatTimelineMessages(
events,
channel,
currentPubkey ?? undefined,
selfAvatarUrl,
profiles,
).filter(
(message) =>
(message.kind === KIND_STREAM_MESSAGE ||
message.kind === KIND_STREAM_MESSAGE_V2) &&
isAtOrAfterConversationOpener(
{ created_at: message.createdAt, id: message.id, tags: message.tags },
opener,
),
);
}, [
channel,
currentPubkey,
messagesQuery.data,
profiles,
selfAvatarUrl,
threadReplies.events,
opener,
]);
const conversationEntries = React.useMemo(
() => messages.map((message) => ({ message, summary: null })),
[messages],
);
const lastMessageId = messages[messages.length - 1]?.id ?? null;
const handleToggleReaction = React.useCallback(
async (message: TimelineMessage, emoji: string, remove: boolean) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
},
[toggleReactionMutation.mutateAsync],
);
React.useEffect(() => {
if (!lastMessageId && !agentWorking.working) return;
bottomRef.current?.scrollIntoView({ block: "end" });
}, [lastMessageId, agentWorking.working]);
return (
<div data-project-agent-channel-id={channel.id}>
<TimelineMessageList
channelId={channel.id}
channelName={agent.name}
channelType="dm"
currentPubkey={currentPubkey ?? undefined}
hideDayDividers
mainEntries={conversationEntries}
messages={messages}
onToggleReaction={handleToggleReaction}
profiles={profiles}
useVirtualizer={false}
/>
{agentWorking.working ? (
<div className="flex items-center gap-2 pl-11 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{agent.name} is working…
</div>
) : null}
<div ref={bottomRef} />
</div>
);
}
/** Full-page agent prompt: ask an agent about the workspace repositories.
* The conversation stays inline on this page — the prompt is delivered
* through a DM with the agent under the hood, but no navigation happens. */
export function ProjectsAgentPromptPage({
projects,
onClose,
workspaceId,
}: {
projects: readonly Project[];
onClose: () => void;
workspaceId: string | null;
}) {
const [prompt, setPrompt] = React.useState("");
const [storedConversation, setStoredConversation] =
React.useState<StoredProjectsAgentConversation | null>(() =>
readStoredProjectsAgentConversation(workspaceId),
);
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
() => storedConversation?.agentPubkey ?? null,
);
const [isSending, setIsSending] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [conversation, setConversation] =
React.useState<ProjectAgentConversation | null>(null);
const submitPromptRef = React.useRef<() => void>(() => {});
const onEditLinkRef = React.useRef<
((info: LinkSelectionInfo) => void) | null
>(null);
const onLinkSelectionChangeRef = React.useRef<
((info: LinkSelectionInfo | null) => void) | null
>(null);
const onLinkShortcutRef = React.useRef<(() => boolean) | null>(null);
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const candidates = useAgentCandidates();
const channelsQuery = useChannelsQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();
const { activeCommunity } = useCommunities();
// Tenant scope for the submit sequence below: captured per render, so the
// value the callback closes over is the community that was active when the
// user pressed Ask — the backing commands fail closed if it changes while
// the callback is suspended.
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
: null;
const candidatePubkeys = React.useMemo(
() => candidates.map((candidate) => candidate.pubkey),
[candidates],
);
const candidateProfilesQuery = useUsersBatchQuery(candidatePubkeys);
const avatarUrlFor = React.useCallback(
(pubkey: string) =>
candidateProfilesQuery.data?.profiles[normalizePubkey(pubkey)]
?.avatarUrl ?? null,
[candidateProfilesQuery.data],
);
// Restore strictly from the pointer this feature persisted — never infer
// a conversation from existing agent DMs (their history is unrelated).
const restorableConversation = React.useMemo(
() =>
restoreProjectsAgentConversation({
candidates,
channels: channelsQuery.data ?? [],
currentPubkey: identityQuery.data?.pubkey ?? null,
stored: storedConversation,
}),
[
candidates,
channelsQuery.data,
identityQuery.data?.pubkey,
storedConversation,
],
);
React.useEffect(() => {
if (conversation || !restorableConversation) return;
setConversation(restorableConversation);
setSelectedPubkey(restorableConversation.agent.pubkey);
}, [conversation, restorableConversation]);
const selectedAgent =
conversation?.agent ??
candidates.find((candidate) => candidate.pubkey === selectedPubkey) ??
candidates[0] ??
null;
const richText = useRichTextEditor({
editable: !isSending,
onEditLink: (info) => onEditLinkRef.current?.(info),
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onSubmit: () => submitPromptRef.current(),
onUpdate: ({ text }) => setPrompt(text),
placeholder: conversation
? `Reply to ${conversation.agent.name}…`
: "Are we safe to release this week?",
});
const linkEditor = useLinkEditor(richText);
onEditLinkRef.current = linkEditor.openFromClick;
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
onLinkShortcutRef.current = linkEditor.openFromShortcut;
React.useEffect(() => {
if (!richText.editor) return;
const frame = window.requestAnimationFrame(richText.focusPreserve);
return () => window.cancelAnimationFrame(frame);
}, [richText.editor, richText.focusPreserve]);
const suggestions = React.useMemo(
() => buildSuggestions(projects),
[projects],
);
// Computed once so the pre-send preview and the appended opener payload
// are byte-identical: the user inspects exactly what will be signed under
// their key. Repo context rides only on the conversation opener.
const repoContextPayload = React.useMemo(
() => repoContextBlock(projects),
[projects],
);
const canSubmit = Boolean(prompt.trim() && selectedAgent && !isSending);
const handleSubmit = React.useCallback(async () => {
const trimmed = richText.getMarkdown().trim();
if (!trimmed || !selectedAgent || isSending) return;
setIsSending(true);
try {
// Repo context rides only on the conversation opener.
const content = conversation
? trimmed
: `${trimmed}${repoContextPayload}`;
// The awaits inside suspend across a possible community switch;
// `submitProjectAgentMessage` binds every relay side effect to the
// scope captured at render (fail closed) and threads follow-ups onto
// the opener so a same-second follow-up cannot be hidden by id
// ordering.
const { channel, sent } = await submitProjectAgentMessage({
agent: selectedAgent,
conversation,
content,
mentionPubkeys: [selectedAgent.pubkey],
relayScope,
startAgent: (agentPubkey) =>
startAgentMutation.mutateAsync(agentPubkey),
openDm: (input) => openDmMutation.mutateAsync(input),
send: (request) =>
sendChannelMessage(
request.channelId,
request.content,
request.parentEventId,
undefined,
request.mentionPubkeys,
undefined,
undefined,
undefined,
undefined,
undefined,
request.expectedRelayUrl,
),
});
if (!conversation) {
// Anchor the conversation to the exact accepted opener event: a bare
// timestamp cannot isolate it from unrelated same-second DM history.
const opener = {
createdAt: sent.createdAt,
eventId: sent.eventId,
};
const nextConversation = {
channel,
agent: selectedAgent,
opener,
};
const stored = {
agentPubkey: selectedAgent.pubkey,
channelId: channel.id,
opener,
};
setConversation(nextConversation);
setStoredConversation(stored);
writeStoredProjectsAgentConversation(workspaceId, stored);
}
setPrompt("");
richText.clearContent();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to reach the agent",
);
} finally {
setIsSending(false);
}
}, [
conversation,
isSending,
openDmMutation,
relayScope,
repoContextPayload,
richText.clearContent,
richText.getMarkdown,
selectedAgent,
startAgentMutation,
workspaceId,
]);
submitPromptRef.current = () => {
void handleSubmit();
};
const handleClearConversation = React.useCallback(() => {
clearStoredProjectsAgentConversation(workspaceId);
setStoredConversation(null);
setConversation(null);
setSelectedPubkey(null);
setPrompt("");
richText.clearContent();
}, [richText.clearContent, workspaceId]);
const promptBox = (
<>
<div className="rounded-2xl border border-border/60 bg-card p-3 shadow-sm">
{/* biome-ignore lint/a11y/noStaticElementInteractions: Escape closes the full-page prompt while Tiptap owns text input */}
<div
className="rich-text-composer relative max-h-40 min-h-10 overflow-y-auto"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
}}
>
<EditorContent editor={richText.editor} />
</div>
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex min-w-0 items-center gap-1">
<Button
aria-label="Toggle formatting"
aria-pressed={isFormattingOpen}
className="h-7 w-7 shrink-0 px-0"
disabled={isSending}
onClick={() => setIsFormattingOpen((open) => !open)}
size="icon"
title="Formatting"
type="button"
variant={isFormattingOpen ? "default" : "ghost"}
>
<ALargeSmall className="h-4 w-4" />
</Button>
{isFormattingOpen ? (
<div className="min-w-0 overflow-x-auto">
<FormattingToolbar
disabled={isSending}
editor={richText.editor}
onLinkButton={linkEditor.openFromToolbar}
/>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-7 max-w-56 gap-1.5 rounded-full px-2.5 text-xs"
data-testid="projects-agent-picker"
disabled={candidates.length === 0 || conversation !== null}
size="sm"
type="button"
variant="ghost"
>
{selectedAgent ? (
<UserAvatar
accent
avatarUrl={avatarUrlFor(selectedAgent.pubkey)}
className="shrink-0"
displayName={selectedAgent.name}
size="xs"
/>
) : null}
<span className="min-w-0 truncate">
{selectedAgent?.name ?? "No agents available"}
</span>
{candidates.length > 0 && conversation === null ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-52">
<DropdownMenuRadioGroup
onValueChange={setSelectedPubkey}
value={selectedAgent?.pubkey ?? ""}
>
{candidates.map((candidate) => (
<DropdownMenuRadioItem
key={candidate.pubkey}
value={candidate.pubkey}
>
<UserAvatar
accent
avatarUrl={avatarUrlFor(candidate.pubkey)}
className="mr-2 shrink-0"
displayName={candidate.name}
size="xs"
/>
<span className="min-w-0 truncate">
{candidate.name}
</span>
<span
className={cn(
"ml-2 h-1.5 w-1.5 shrink-0 rounded-full",
candidate.isActive
? "bg-emerald-500"
: "bg-muted-foreground/40",
)}
/>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Button
className="h-7 gap-1.5 rounded-full px-3 text-xs"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
size="sm"
type="button"
variant="default"
>
{isSending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<SendHorizontal className="h-3.5 w-3.5" />
)}
Ask
</Button>
</div>
{conversation ? null : (
<div className="flex justify-end pt-1">
<AgentContextPayloadPreview
payload={repoContextPayload}
triggerLabel="Context appended to your first message"
/>
</div>
)}
</div>
{linkEditor.card}
{linkEditor.dialog}
</>
);
if (conversation) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-4">
<div className="w-full pb-6 pt-[calc(var(--buzz-channel-content-top-padding,5.75rem)_+_1rem)]">
<div className="mb-4 flex justify-end">
<Button
className="h-8 gap-1.5 rounded-full px-3 text-xs text-muted-foreground"
onClick={handleClearConversation}
size="sm"
type="button"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
Clear conversation
</Button>
</div>
<ConversationThread
agent={conversation.agent}
agentAvatarUrl={avatarUrlFor(conversation.agent.pubkey)}
channel={conversation.channel}
currentPubkey={identityQuery.data?.pubkey ?? null}
selfAvatarUrl={profileQuery.data?.avatarUrl ?? null}
opener={conversation.opener}
/>
</div>
</div>
<div className="px-4 pb-4">
<div className="w-full">{promptBox}</div>
</div>
</div>
);
}
return (
<div className="flex flex-1 items-center justify-center overflow-y-auto px-4">
<div className="w-full max-w-xl space-y-6 py-10">
<h2 className="text-center text-lg font-semibold text-foreground">
Ask an agent about your projects
</h2>
{promptBox}
{candidates.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">
No agents available yet — create or start one from the Agents view
to ask about your repositories.
</p>
) : (
<div className="space-y-1.5">
{suggestions.map((suggestion) => (
<button
className="flex w-full items-baseline gap-2 rounded-xl border border-border/50 bg-card/60 px-4 py-2 text-left transition-colors duration-150 hover:bg-muted/30"
key={suggestion.label}
onClick={() => {
setPrompt(suggestion.prompt);
richText.setContent(suggestion.prompt);
richText.focusEnd();
}}
type="button"
>
<span className="shrink-0 text-xs font-semibold text-foreground">
{suggestion.label}
</span>
<span className="min-w-0 truncate text-xs text-muted-foreground">
{suggestion.prompt}
</span>
</button>
))}
</div>
)}
</div>
</div>
);
}